7 Commits 87ebcc7a6f ... af5a8e5d40

Autore SHA1 Messaggio Data
  PathGao af5a8e5d40 fix(database): create SQLite backup snapshots online (#6137) 11 ore fa
  PathGao ad288a7ecc fix(sub): honor trustedProxyCIDRs before forwarded URLs (#6135) 11 ore fa
  PathGao ad5f2a28cb fix(xray): synchronize lifecycle state (#6138) 11 ore fa
  PathGao e467b25f03 fix(sub): coalesce external subscription refreshes (#6139) 11 ore fa
  PathGao 03cc80bb9e fix(mtproto): synchronize child-process lifecycle (#6141) 11 ore fa
  PathGao 863473783d fix(frontend): preserve cancellation and reject invalid query data (#6143) 11 ore fa
  Sanaei c3fa73d5a0 feat(ui): redesign the overview page as a trend-first command deck 11 ore fa
67 ha cambiato i file con 2934 aggiunte e 965 eliminazioni
  1. 1 1
      docs/content/docs/en/config/panel.mdx
  2. 1 1
      docs/content/docs/fa/config/panel.mdx
  3. 1 1
      docs/content/docs/ru/config/panel.mdx
  4. 1 1
      docs/content/docs/zh/config/panel.mdx
  5. 24 2
      frontend/src/api/http-init.ts
  6. 4 0
      frontend/src/components/clients/ConfigBlock.css
  7. 17 9
      frontend/src/components/viz/Sparkline.tsx
  8. 1 1
      frontend/src/hooks/useClients.ts
  9. 27 19
      frontend/src/layouts/AppSidebar.css
  10. 38 31
      frontend/src/layouts/AppSidebar.tsx
  11. 8 3
      frontend/src/models/status.ts
  12. 74 0
      frontend/src/pages/index/ConnectionsCard.tsx
  13. 443 24
      frontend/src/pages/index/IndexPage.css
  14. 123 308
      frontend/src/pages/index/IndexPage.tsx
  15. 163 0
      frontend/src/pages/index/OverviewActionBar.tsx
  16. 0 9
      frontend/src/pages/index/StatusCard.css
  17. 0 115
      frontend/src/pages/index/StatusCard.tsx
  18. 97 0
      frontend/src/pages/index/SystemStrip.tsx
  19. 97 0
      frontend/src/pages/index/ThroughputCard.tsx
  20. 75 0
      frontend/src/pages/index/VitalTile.tsx
  21. 0 14
      frontend/src/pages/index/XrayStatusCard.css
  22. 0 123
      frontend/src/pages/index/XrayStatusCard.tsx
  23. 135 0
      frontend/src/pages/index/useOverviewHistory.ts
  24. 83 0
      frontend/src/test/http-init.test.tsx
  25. 28 0
      frontend/src/test/use-all-settings.test.tsx
  26. 41 0
      frontend/src/test/zodValidate.test.ts
  27. 8 0
      frontend/src/utils/index.ts
  28. 6 0
      frontend/src/utils/zodValidate.ts
  29. 1 1
      go.mod
  30. 186 0
      internal/database/backup_test.go
  31. 113 5
      internal/database/db.go
  32. 0 41
      internal/database/journal_mode_test.go
  33. 10 0
      internal/mtproto/manager_reload_test.go
  34. 46 25
      internal/mtproto/process.go
  35. 106 0
      internal/mtproto/process_race_test.go
  36. 54 9
      internal/sub/external_subscription.go
  37. 140 0
      internal/sub/external_subscription_test.go
  38. 92 0
      internal/sub/forwarded_trust.go
  39. 205 0
      internal/sub/forwarded_trust_test.go
  40. 17 6
      internal/sub/service.go
  41. 1 1
      internal/web/controller/util.go
  42. 4 4
      internal/web/service/inbound_disable.go
  43. 18 15
      internal/web/service/inbound_node.go
  44. 2 2
      internal/web/service/inbound_traffic.go
  45. 2 2
      internal/web/service/outbound_subscription.go
  46. 22 20
      internal/web/service/server.go
  47. 2 1
      internal/web/service/setting.go
  48. 2 2
      internal/web/service/tgbot/tgbot_client.go
  49. 23 10
      internal/web/service/tgbot/tgbot_report.go
  50. 90 50
      internal/web/service/xray.go
  51. 68 0
      internal/web/service/xray_lifecycle_test.go
  52. 2 2
      internal/web/service/xray_setting.go
  53. 12 8
      internal/web/translation/ar-EG.json
  54. 12 8
      internal/web/translation/en-US.json
  55. 12 8
      internal/web/translation/es-ES.json
  56. 12 8
      internal/web/translation/fa-IR.json
  57. 12 8
      internal/web/translation/id-ID.json
  58. 12 8
      internal/web/translation/ja-JP.json
  59. 12 8
      internal/web/translation/pt-BR.json
  60. 12 8
      internal/web/translation/ru-RU.json
  61. 12 8
      internal/web/translation/tr-TR.json
  62. 12 8
      internal/web/translation/uk-UA.json
  63. 12 8
      internal/web/translation/vi-VN.json
  64. 12 8
      internal/web/translation/zh-CN.json
  65. 12 8
      internal/web/translation/zh-TW.json
  66. 11 3
      internal/xray/process.go
  67. 65 0
      internal/xray/process_version_test.go

+ 1 - 1
docs/content/docs/en/config/panel.mdx

@@ -19,7 +19,7 @@ browser in full.
 | `webBasePath`       | `/`                     | URL path the panel is served under (always normalized to `/…/`).        |
 | `webBasePath`       | `/`                     | URL path the panel is served under (always normalized to `/…/`).        |
 | `webCertFile` / `webKeyFile` | _(none)_       | TLS certificate + key. When both are set, the panel serves **HTTPS**.   |
 | `webCertFile` / `webKeyFile` | _(none)_       | TLS certificate + key. When both are set, the panel serves **HTTPS**.   |
 | `sessionMaxAge`     | `360`                   | Session lifetime in **minutes** (default 6 hours).                      |
 | `sessionMaxAge`     | `360`                   | Session lifetime in **minutes** (default 6 hours).                      |
-| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IPs/CIDRs whose forwarded headers (real client IP) are trusted.         |
+| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. |
 | `panelOutbound`     | _(none)_                | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
 | `panelOutbound`     | _(none)_                | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
 
 
 After changing the port or base path, the panel URL becomes
 After changing the port or base path, the panel URL becomes

+ 1 - 1
docs/content/docs/fa/config/panel.mdx

@@ -19,7 +19,7 @@ icon: SlidersHorizontal
 | `webBasePath`       | `/`                     | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود).        |
 | `webBasePath`       | `/`                     | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود).        |
 | `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_   | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود.   |
 | `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_   | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود.   |
 | `sessionMaxAge`     | `360`                   | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت).                      |
 | `sessionMaxAge`     | `360`                   | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت).                      |
-| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است.         |
+| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینک‌های اشتراک را کنترل می‌کند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینک‌ها `subURI` را تنظیم کنید. |
 | `panelOutbound`     | _(هیچ‌کدام)_                | مسیریابی خروجیِ خود پنل (بررسی به‌روزرسانی‌ها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
 | `panelOutbound`     | _(هیچ‌کدام)_                | مسیریابی خروجیِ خود پنل (بررسی به‌روزرسانی‌ها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
 
 
 پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت
 پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت

+ 1 - 1
docs/content/docs/ru/config/panel.mdx

@@ -19,7 +19,7 @@ icon: SlidersHorizontal
 | `webBasePath`       | `/`                     | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
 | `webBasePath`       | `/`                     | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
 | `webCertFile` / `webKeyFile` | _(нет)_        | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
 | `webCertFile` / `webKeyFile` | _(нет)_        | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
 | `sessionMaxAge`     | `360`                   | Время жизни сессии в **минутах** (по умолчанию 6 часов).                |
 | `sessionMaxAge`     | `360`                   | Время жизни сессии в **минутах** (по умолчанию 6 часов).                |
-| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. |
+| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. |
 | `panelOutbound`     | _(нет)_                 | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
 | `panelOutbound`     | _(нет)_                 | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
 
 
 После изменения порта или базового пути URL панели становится
 После изменения порта или базового пути URL панели становится

+ 1 - 1
docs/content/docs/zh/config/panel.mdx

@@ -15,7 +15,7 @@ icon: SlidersHorizontal
 | `webBasePath`       | `/`                     | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。               |
 | `webBasePath`       | `/`                     | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。               |
 | `webCertFile` / `webKeyFile` | _(无)_       | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。           |
 | `webCertFile` / `webKeyFile` | _(无)_       | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。           |
 | `sessionMaxAge`     | `360`                   | 会话有效期,单位为**分钟**(默认 6 小时)。                              |
 | `sessionMaxAge`     | `360`                   | 会话有效期,单位为**分钟**(默认 6 小时)。                              |
-| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | 其转发头(真实客户端 IP)受信任的 IP/CIDR。                            |
+| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128`  | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 |
 | `panelOutbound`     | _(无)_                | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
 | `panelOutbound`     | _(无)_                | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
 
 
 更改端口或基础路径后,面板 URL 将变为
 更改端口或基础路径后,面板 URL 将变为

+ 24 - 2
frontend/src/api/http-init.ts

@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
   return parts.join('&');
   return parts.join('&');
 }
 }
 
 
+function appendQuery(url: string, query: string): string {
+  if (query === '') return url;
+  const hashIndex = url.indexOf('#');
+  const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
+  const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
+  const hasQuery = path.includes('?');
+  const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
+  return `${path}${separator}${query}${hash}`;
+}
+
+function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
+  if (!options.timeout) return options.signal;
+  const timeout = AbortSignal.timeout(options.timeout);
+  if (!options.signal) return timeout;
+  if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
+  const controller = new AbortController();
+  const abort = () => controller.abort();
+  options.signal.addEventListener('abort', abort, { once: true });
+  timeout.addEventListener('abort', abort, { once: true });
+  return controller.signal;
+}
+
 async function performFetch(
 async function performFetch(
   method: string,
   method: string,
   url: string,
   url: string,
@@ -121,8 +143,8 @@ async function performFetch(
   }
   }
 
 
   const query = encodeForm(options.params);
   const query = encodeForm(options.params);
-  const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
-  const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
+  const fullUrl = basePathPrefix + appendQuery(url, query);
+  const signal = requestSignal(options);
 
 
   return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
   return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
 }
 }

+ 4 - 0
frontend/src/components/clients/ConfigBlock.css

@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
   color: #874d00;
   color: #874d00;
 }
 }
 
 
+body.light .config-block-text {
+  color: #595959;
+}
+
 .config-block .ant-collapse-extra {
 .config-block .ant-collapse-extra {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 17 - 9
frontend/src/components/viz/Sparkline.tsx

@@ -48,6 +48,7 @@ interface SparklineProps {
   yTickStep?: number;
   yTickStep?: number;
   tickCountX?: number;
   tickCountX?: number;
   showTooltip?: boolean;
   showTooltip?: boolean;
+  showLegend?: boolean;
   valueMin?: number;
   valueMin?: number;
   valueMax?: number | null;
   valueMax?: number | null;
   yFormatter?: (v: number) => string;
   yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
   extremaPoints: ExtremaResult | null;
   extremaPoints: ExtremaResult | null;
 }
 }
 
 
-function hexToRgba(hex: string, alpha: number): string {
-  let h = hex.trim();
+function hexToRgba(color: string, alpha: number): string {
+  const trimmed = color.trim();
+  const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
+  if (fn) {
+    const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
+    if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
+      const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
+      return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
+    }
+    return trimmed;
+  }
+  let h = trimmed;
   if (h.startsWith('#')) h = h.slice(1);
   if (h.startsWith('#')) h = h.slice(1);
   if (h.length === 3) h = h.split('').map((c) => c + c).join('');
   if (h.length === 3) h = h.split('').map((c) => c + c).join('');
-  if (h.length !== 6) return hex;
+  if (h.length !== 6) return trimmed;
   const int = Number.parseInt(h, 16);
   const int = Number.parseInt(h, 16);
-  if (Number.isNaN(int)) return hex;
+  if (Number.isNaN(int)) return trimmed;
   const r = (int >> 16) & 255;
   const r = (int >> 16) & 255;
   const g = (int >> 8) & 255;
   const g = (int >> 8) & 255;
   const b = int & 255;
   const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
     yTickStep = 25,
     yTickStep = 25,
     tickCountX = 4,
     tickCountX = 4,
     showTooltip = false,
     showTooltip = false,
+    showLegend = true,
     valueMin = 0,
     valueMin = 0,
     valueMax = 100,
     valueMax = 100,
     yFormatter = (v: number) => `${Math.round(v)}%`,
     yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
     );
     );
   }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
   }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
 
 
-  useEffect(() => {
-    plotRef.current?.redraw(false);
-  });
-
   useEffect(() => {
   useEffect(() => {
     const redraw = () => plotRef.current?.redraw(false);
     const redraw = () => plotRef.current?.redraw(false);
     const moBody = new MutationObserver(redraw);
     const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
           </span>
           </span>
         </div>
         </div>
       )}
       )}
-      {legendItems.length > 0 && (
+      {showLegend && legendItems.length > 0 && (
         <div className="sparkline-legend" aria-hidden="true">
         <div className="sparkline-legend" aria-hidden="true">
           {legendItems.map((s) => (
           {legendItems.map((s) => (
             <span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
             <span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>

+ 1 - 1
frontend/src/hooks/useClients.ts

@@ -155,7 +155,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
   const qs = buildQS(params);
   const qs = buildQS(params);
   const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
   const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
-  const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
+  const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
   if (!validated.obj) throw new Error('Empty clients response');
   if (!validated.obj) throw new Error('Empty clients response');
   return validated.obj;
   return validated.obj;
 }
 }

+ 27 - 19
frontend/src/layouts/AppSidebar.css

@@ -1,3 +1,10 @@
+.ant-sidebar {
+  flex: 0 0 var(--sider-rail, 72px);
+  width: var(--sider-rail, 72px);
+  position: relative;
+  z-index: 210;
+}
+
 .ant-sidebar > .ant-layout-sider {
 .ant-sidebar > .ant-layout-sider {
   position: sticky;
   position: sticky;
   top: 0;
   top: 0;
@@ -5,6 +12,16 @@
   align-self: flex-start;
   align-self: flex-start;
 }
 }
 
 
+.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
+  box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
+}
+
+.sider-nav .ant-menu-item .anticon,
+.sider-nav .ant-menu-submenu-title .anticon,
+.sider-utility .ant-menu-item .anticon {
+  font-size: 16px;
+}
+
 .sider-brand,
 .sider-brand,
 .drawer-brand {
 .drawer-brand {
   font-weight: 600;
   font-weight: 600;
@@ -18,16 +35,12 @@
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
   gap: 8px;
   gap: 8px;
-  padding: 14px 16px 14px 24px;
+  height: 58px;
+  padding: 0 16px 0 24px;
   border-bottom: 1px solid var(--ant-color-border-secondary);
   border-bottom: 1px solid var(--ant-color-border-secondary);
   user-select: none;
   user-select: none;
-}
-
-.sider-brand-collapsed {
-  justify-content: center;
-  font-size: 16px;
-  padding: 14px 4px;
-  letter-spacing: 0;
+  white-space: nowrap;
+  overflow: hidden;
 }
 }
 
 
 .brand-block {
 .brand-block {
@@ -37,10 +50,6 @@
   line-height: 1.1;
   line-height: 1.1;
 }
 }
 
 
-.sider-brand-collapsed .brand-block {
-  flex: 0 0 auto;
-}
-
 .brand-actions {
 .brand-actions {
   display: inline-flex;
   display: inline-flex;
   align-items: center;
   align-items: center;
@@ -246,11 +255,6 @@
   outline: none;
   outline: none;
 }
 }
 
 
-.sider-version.is-collapsed {
-  justify-content: center;
-  padding: 8px 0;
-}
-
 .drawer-footer {
 .drawer-footer {
   flex: 0 0 auto;
   flex: 0 0 auto;
   padding: 8px 8px 12px;
   padding: 8px 8px 12px;
@@ -261,8 +265,7 @@
     display: inline-flex;
     display: inline-flex;
   }
   }
 
 
-  .ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
-  .ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
+  .ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
     display: none;
     display: none;
   }
   }
 
 
@@ -272,6 +275,11 @@
     min-width: 0 !important;
     min-width: 0 !important;
     width: 0 !important;
     width: 0 !important;
   }
   }
+
+  .ant-sidebar {
+    flex: 0 0 0;
+    width: 0;
+  }
 }
 }
 
 
 body.dark .ant-drawer-content,
 body.dark .ant-drawer-content,

+ 38 - 31
frontend/src/layouts/AppSidebar.tsx

@@ -1,5 +1,5 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import type { ComponentType } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ComponentType, CSSProperties } from 'react';
 import { useLocation, useNavigate } from 'react-router';
 import { useLocation, useNavigate } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Drawer, Layout, Menu } from 'antd';
 import { Drawer, Layout, Menu } from 'antd';
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import './AppSidebar.css';
 import './AppSidebar.css';
 
 
-const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const DOCS_URL = 'https://docs.sanaei.dev/';
 const DOCS_URL = 'https://docs.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 const LOGOUT_KEY = '__logout__';
+const RAIL_WIDTH = 72;
+const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
+
+let hoveredAcrossRemounts = false;
 
 
 type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
 type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
 
 
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
   routing: SwapOutlined,
   routing: SwapOutlined,
 };
 };
 
 
-function readCollapsed(): boolean {
-  try {
-    return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
-  } catch {
-    return false;
-  }
-}
-
 function DonateButton({ ariaLabel }: { ariaLabel: string }) {
 function DonateButton({ ariaLabel }: { ariaLabel: string }) {
   return (
   return (
     <a
     <a
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
       href={REPO_URL}
       href={REPO_URL}
       target="_blank"
       target="_blank"
       rel="noopener noreferrer"
       rel="noopener noreferrer"
-      className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
+      className="sider-version"
       aria-label={`GitHub ${label}`}
       aria-label={`GitHub ${label}`}
       title={label}
       title={label}
     >
     >
@@ -148,8 +143,23 @@ export default function AppSidebar() {
   const { allSetting } = useAllSettings();
   const { allSetting } = useAllSettings();
   const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
   const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
 
 
-  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
+  const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
   const [drawerOpen, setDrawerOpen] = useState(false);
   const [drawerOpen, setDrawerOpen] = useState(false);
+  const railCollapsed = !hovered;
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  const updateHovered = useCallback((value: boolean) => {
+    hoveredAcrossRemounts = value;
+    setHovered(value);
+  }, []);
+
+  useEffect(() => {
+    const timer = window.setTimeout(() => {
+      const el = rootRef.current;
+      if (el) updateHovered(el.matches(':hover'));
+    }, 150);
+    return () => window.clearTimeout(timer);
+  }, [updateHovered]);
 
 
   const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
   const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
   const panelVersion = window.X_UI_CUR_VER || '';
   const panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +228,7 @@ export default function AppSidebar() {
       if (tab.key === '/xray') {
       if (tab.key === '/xray') {
         return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
         return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
       }
       }
-      return { key: tab.key, icon: <Icon />, label: tab.title };
+      return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
     }),
     }),
   [settingsChildren, xrayChildren]);
   [settingsChildren, xrayChildren]);
 
 
@@ -235,13 +245,6 @@ export default function AppSidebar() {
     openLink(String(key));
     openLink(String(key));
   }, [openLink]);
   }, [openLink]);
 
 
-  const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
-    if (type === 'clickTrigger') {
-      localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
-      setCollapsed(isCollapsed);
-    }
-  }, []);
-
   const cycleTheme = useCallback((id: string) => {
   const cycleTheme = useCallback((id: string) => {
     pauseAnimationsUntilLeave(id);
     pauseAnimationsUntilLeave(id);
     if (!isDark) {
     if (!isDark) {
@@ -256,20 +259,24 @@ export default function AppSidebar() {
   }, [isDark, isUltra, toggleTheme, toggleUltra]);
   }, [isDark, isUltra, toggleTheme, toggleUltra]);
 
 
   return (
   return (
-    <div className="ant-sidebar">
+    <div
+      ref={rootRef}
+      className="ant-sidebar"
+      style={railStyle}
+      onMouseEnter={() => updateHovered(true)}
+      onMouseLeave={() => updateHovered(false)}
+    >
       <Layout.Sider
       <Layout.Sider
         theme={currentTheme}
         theme={currentTheme}
         width={220}
         width={220}
-        collapsible
-        collapsed={collapsed}
-        breakpoint="md"
-        onCollapse={onSiderCollapse}
+        collapsedWidth={RAIL_WIDTH}
+        collapsed={railCollapsed}
       >
       >
-        <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
+        <div className="sider-brand">
           <div className="brand-block">
           <div className="brand-block">
-            <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
+            <span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
           </div>
           </div>
-          {!collapsed && (
+          {!railCollapsed && (
             <div className="brand-actions">
             <div className="brand-actions">
               <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
               <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -287,7 +294,7 @@ export default function AppSidebar() {
           theme={currentTheme}
           theme={currentTheme}
           mode="inline"
           mode="inline"
           selectedKeys={[selectedKey]}
           selectedKeys={[selectedKey]}
-          openKeys={collapsed ? undefined : openKeys}
+          openKeys={railCollapsed ? undefined : openKeys}
           onOpenChange={(keys) => setOpenKeys(keys as string[])}
           onOpenChange={(keys) => setOpenKeys(keys as string[])}
           className="sider-nav"
           className="sider-nav"
           items={toMenuItems(navItems)}
           items={toMenuItems(navItems)}
@@ -302,7 +309,7 @@ export default function AppSidebar() {
           onClick={onMenuClick}
           onClick={onMenuClick}
         />
         />
         <div className="sider-footer">
         <div className="sider-footer">
-          <VersionBadge version={panelVersion} collapsed={collapsed} />
+          <VersionBadge version={panelVersion} collapsed={railCollapsed} />
         </div>
         </div>
       </Layout.Sider>
       </Layout.Sider>
 
 

+ 8 - 3
frontend/src/models/status.ts

@@ -1,5 +1,10 @@
 import { NumberFormatter } from '@/utils';
 import { NumberFormatter } from '@/utils';
 
 
+export const USAGE_WARN_PERCENT = 80;
+export const USAGE_CRIT_PERCENT = 90;
+export const USAGE_WARN_COLOR = '#faad14';
+export const USAGE_CRIT_COLOR = '#ff4d4f';
+
 export class CurTotal {
 export class CurTotal {
   current: number;
   current: number;
   total: number;
   total: number;
@@ -16,9 +21,9 @@ export class CurTotal {
 
 
   get color(): string {
   get color(): string {
     const p = this.percent;
     const p = this.percent;
-    if (p < 80) return '#1677ff';
-    if (p < 90) return '#faad14';
-    return '#ff4d4f';
+    if (p < USAGE_WARN_PERCENT) return '#1677ff';
+    if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
+    return USAGE_CRIT_COLOR;
   }
   }
 }
 }
 
 

+ 74 - 0
frontend/src/pages/index/ConnectionsCard.tsx

@@ -0,0 +1,74 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, theme } from 'antd';
+
+import { Sparkline } from '@/components/viz';
+import type { Status } from '@/models/status';
+
+interface ConnectionsCardProps {
+  status: Status;
+  tcp: number[];
+  udp: number[];
+  labels: string[];
+  isMobile: boolean;
+}
+
+export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
+  const { t } = useTranslation();
+  const { token } = theme.useToken();
+  const accent = token.colorPrimary;
+  const udpColor = token.colorTextTertiary;
+
+  const referenceLines = useMemo(
+    () => [
+      { y: status.udpCount, color: udpColor, dash: '2 4' },
+      { y: status.tcpCount, color: accent, dash: '2 4' },
+    ],
+    [status.tcpCount, status.udpCount, accent, udpColor],
+  );
+
+  return (
+    <Card hoverable styles={{ body: { padding: 0 } }}>
+      <div className="ov-wide-head ov-wide-head-stack">
+        <div className="ov-kicker">{t('pages.index.connectionCount')}</div>
+        <div className="ov-conn-total">
+          <span className="ov-tile-number">{status.tcpCount + status.udpCount}</span>
+          <span className="ov-tile-unit">{t('pages.index.openSockets')}</span>
+        </div>
+      </div>
+
+      <div className="ov-conn-legend">
+        <div className="ov-legend-label">
+          <span className="ov-swatch" style={{ background: accent }} />
+          TCP
+          <span className="ov-legend-num">{status.tcpCount.toLocaleString()}</span>
+        </div>
+        <div className="ov-legend-label">
+          <span className="ov-swatch" style={{ background: udpColor }} />
+          UDP
+          <span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
+        </div>
+      </div>
+
+      <div className="ov-wide-chart">
+        <Sparkline
+          data={tcp}
+          data2={udp}
+          labels={labels}
+          height={isMobile ? 120 : 170}
+          strokeWidth={1.5}
+          fillOpacity={0.24}
+          showTooltip
+          showLegend={false}
+          valueMax={null}
+          stroke={accent}
+          stroke2={udpColor}
+          name1="TCP"
+          name2="UDP"
+          yFormatter={(v) => Math.round(v).toLocaleString()}
+          referenceLines={referenceLines}
+        />
+      </div>
+    </Card>
+  );
+}

+ 443 - 24
frontend/src/pages/index/IndexPage.css

@@ -1,51 +1,470 @@
+/* Overview page — trend-first layout. Every colour comes from the AntD theme
+   tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
+   Set --ov-accent once here if you want a fixed accent instead of the primary. */
+
+.index-page {
+  --ov-accent: var(--ant-color-primary);
+  --ov-line: var(--ant-color-border);
+  --ov-label: var(--ant-color-text-secondary);
+  --ov-faint: var(--ant-color-text-tertiary);
+  --ov-gap: 12px;
+  --ov-pad: 20px;
+}
+
 @media (max-width: 768px) {
 @media (max-width: 768px) {
   .index-page .content-area {
   .index-page .content-area {
     padding: 12px;
     padding: 12px;
     padding-top: 64px;
     padding-top: 64px;
   }
   }
+
+  .index-page {
+    --ov-gap: 8px;
+    --ov-pad: 14px;
+  }
+}
+
+.ov-page {
+  display: flex;
+  flex-direction: column;
+  gap: var(--ov-gap);
+}
+
+/* — action bar — */
+
+.ov-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.ov-state {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 12px;
+  border: 1px solid var(--ov-line);
+  border-radius: 999px;
+  font-size: 13px;
+  color: var(--ant-color-text);
+}
+
+.ov-state-dot {
+  position: relative;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+  flex: none;
+}
+
+.ov-state[data-state='running'] .ov-state-dot::after {
+  content: '';
+  position: absolute;
+  inset: -1px;
+  border-radius: 50%;
+  border: 1px solid currentColor;
+  animation: ovPulse 1.6s infinite ease-out;
+}
+
+@keyframes ovPulse {
+  0% { transform: scale(0.9); opacity: 0.5; }
+  100% { transform: scale(2.4); opacity: 0; }
 }
 }
 
 
-.index-page .action {
+@media (prefers-reduced-motion: reduce) {
+  .ov-state[data-state='running'] .ov-state-dot::after {
+    animation: none;
+  }
+}
+
+.ov-state-version,
+.ov-panel-version {
+  padding: 0;
+  border: 0;
+  background: transparent;
+  font: inherit;
   cursor: pointer;
   cursor: pointer;
-  justify-content: center;
-  max-width: 100%;
-  flex-wrap: nowrap;
+  color: var(--ov-label);
+  transition: color 0.2s;
 }
 }
 
 
-.index-page .action > span:not(.anticon) {
-  white-space: nowrap;
+.ov-state-version:hover,
+.ov-state-version:focus-visible,
+.ov-panel-version:hover,
+.ov-panel-version:focus-visible {
+  color: var(--ant-color-primary);
+}
+
+.ov-panel-version {
+  font-size: 12px;
+  color: var(--ov-faint);
+}
+
+.ov-update-tag {
+  cursor: pointer;
+  margin-inline-end: 0;
+}
+
+.ov-error-detail {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.ov-bar-actions {
+  margin-inline-start: auto;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  flex-wrap: wrap;
+}
+
+@media (max-width: 768px) {
+  .ov-bar-actions {
+    margin-inline-start: 0;
+    width: 100%;
+    justify-content: space-between;
+  }
+}
+
+.ov-bar-sep {
+  width: 1px;
+  height: 20px;
+  background: var(--ov-line);
+  margin: 0 4px;
+}
+
+.ov-health {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12.5px;
+}
+
+.ov-health-mark {
+  width: 14px;
+  height: 1px;
+  background: currentColor;
+  flex: none;
+}
+
+.ov-rule {
+  height: 1px;
+  border: 0;
+  margin: 0;
+  background: linear-gradient(
+    to right,
+    transparent,
+    var(--ov-line) 48px,
+    var(--ov-line) calc(100% - 48px),
+    transparent
+  );
+}
+
+/* — shared type — */
+
+.ov-kicker {
+  font-size: 11px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ov-label);
+}
+
+.ov-kicker-icon {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+}
+
+.ov-sub {
+  font-size: 12.5px;
+  margin-top: 4px;
+  color: var(--ov-faint);
+}
+
+.ov-mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+/* — vitals tiles — */
+
+.ov-vitals {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: var(--ov-gap);
+}
+
+@media (max-width: 1100px) {
+  .ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 560px) {
+  .ov-vitals { grid-template-columns: minmax(0, 1fr); }
+}
+
+.ov-tile {
   overflow: hidden;
   overflow: hidden;
-  text-overflow: ellipsis;
-  min-width: 0;
 }
 }
 
 
-.index-page .action-update {
-  color: var(--ant-color-warning);
+.ov-tile-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 14px var(--ov-pad) 0;
+  color: var(--ov-accent);
+}
+
+.ov-tile-icon {
+  display: inline-flex;
+  font-size: 15px;
+}
+
+.ov-tile-value {
+  display: flex;
+  align-items: baseline;
+  gap: 4px;
+  padding: 12px var(--ov-pad) 0;
+}
+
+.ov-tile-number {
+  font-size: 34px;
   font-weight: 600;
   font-weight: 600;
+  line-height: 1;
+  letter-spacing: -0.02em;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
 }
 }
 
 
-.index-page .action-update .anticon {
-  color: var(--ant-color-warning);
+.ov-tile-unit {
+  font-size: 14px;
+  color: var(--ov-label);
 }
 }
 
 
-.index-page .history-tag {
-  cursor: pointer;
-  display: inline-flex;
+.ov-tile-detail {
+  padding: 5px var(--ov-pad) 0;
+  font-size: 12px;
+  color: var(--ov-label);
+}
+
+.ov-tile-foot {
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 14px var(--ov-pad) 0;
+  font-size: 10px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--ov-faint);
+}
+
+.ov-tile-chart {
+  margin-top: 6px;
+}
+
+/* — throughput + connections — */
+
+.ov-mid {
+  display: grid;
+  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
+  gap: var(--ov-gap);
+}
+
+@media (max-width: 1100px) {
+  .ov-mid { grid-template-columns: minmax(0, 1fr); }
+}
+
+.ov-wide-head {
+  display: flex;
+  align-items: flex-start;
+  flex-wrap: wrap;
+  gap: 16px;
+  padding: var(--ov-pad) var(--ov-pad) 0;
+}
+
+.ov-wide-head-stack {
+  flex-direction: column;
+  gap: 0;
+}
+
+.ov-wide-legend {
+  margin-inline-start: auto;
+  display: flex;
+  gap: 22px;
+  text-align: end;
+}
+
+.ov-legend-label {
+  display: flex;
   align-items: center;
   align-items: center;
-  gap: 4px;
-  margin-inline-end: 0;
+  justify-content: flex-end;
+  gap: 6px;
+  font-size: 11px;
+  color: var(--ov-label);
 }
 }
 
 
-.index-page .ip-toggle-icon {
-  cursor: pointer;
-  font-size: 16px;
+.ov-legend-num {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
 }
 }
 
 
-.index-page .ip-hidden .ant-statistic-content-value {
-  filter: blur(6px);
+.ov-conn-total {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  margin-top: 12px;
+}
+
+.ov-conn-legend {
+  display: flex;
+  gap: 16px;
+  padding: 16px var(--ov-pad) 0;
+}
+
+.ov-conn-legend > div {
+  flex: 1 1 0;
+}
+
+.ov-conn-legend .ov-legend-label {
+  justify-content: flex-start;
+}
+
+.ov-swatch {
+  width: 14px;
+  height: 2px;
+  flex: none;
+}
+
+.ov-wide-chart {
+  padding: 12px 8px 0;
+}
+
+.ov-wide-foot {
+  display: flex;
+  gap: 16px;
+  margin: 12px var(--ov-pad) 0;
+  padding: 14px 0 var(--ov-pad);
+  border-top: 1px solid var(--ov-line);
+}
+
+.ov-wide-foot > div {
+  flex: 1 1 0;
+}
+
+.ov-foot-sep {
+  width: 1px;
+  background: var(--ov-line);
+}
+
+.ov-foot-value {
+  font-size: 18px;
+  font-weight: 600;
+  margin-top: 4px;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
+}
+
+.ov-foot-part {
+  display: inline-block;
+  white-space: nowrap;
+}
+
+@media (max-width: 560px) {
+  .ov-wide-foot {
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .ov-wide-foot .ov-foot-sep {
+    display: none;
+  }
+}
+
+/* — system strip — */
+
+/* tracks follow SystemStrip's cell order:
+   uptime (xray | os) · panel (memory | threads) · ip addresses */
+.ov-strip-grid {
+  display: grid;
+  grid-template-columns:
+    minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
+  gap: 16px;
+  padding: var(--ov-pad);
+}
+
+@media (max-width: 1439px) {
+  .ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 1100px) {
+  .ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 560px) {
+  .ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
+}
+
+@media (min-width: 1440px) {
+  .ov-strip-cell + .ov-strip-cell {
+    border-inline-start: 1px solid var(--ov-line);
+    padding-inline-start: 16px;
+  }
+}
+
+.ov-strip-value {
+  font-size: 19px;
+  font-weight: 600;
+  margin-top: 6px;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
+}
+
+.ov-strip-split {
+  display: flex;
+  align-items: stretch;
+  gap: 14px;
+}
+
+.ov-strip-split-sep {
+  width: 1px;
+  background: var(--ov-line);
+  margin-top: 8px;
+}
+
+.ov-strip-sub {
+  font-size: 10px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  margin-top: 8px;
+  color: var(--ov-faint);
+}
+
+.ov-strip-sub + .ov-strip-value {
+  margin-top: 2px;
+}
+
+.ov-ip {
+  margin-top: 7px;
+  font-size: 13px;
+  overflow-wrap: anywhere;
   transition: filter 0.2s ease;
   transition: filter 0.2s ease;
 }
 }
 
 
-.index-page .ip-visible .ant-statistic-content-value {
-  filter: none;
+.ov-ip-v6 {
+  margin-top: 3px;
+  color: var(--ov-label);
+}
+
+/* — preserved from the previous overview — */
+
+.index-page .ip-toggle-icon {
+  cursor: pointer;
+  font-size: 15px;
+  margin-inline-start: auto;
+}
+
+.index-page .ip-hidden {
+  filter: blur(6px);
 }
 }

+ 123 - 308
frontend/src/pages/index/IndexPage.tsx

@@ -1,53 +1,29 @@
 import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
 import {
 import {
-  Button,
-  Card,
-  Col,
-  ConfigProvider,
-  Layout,
-  message,
-  Modal,
-  Result,
-  Row,
-  Space,
-  Spin,
-  Statistic,
-  Tag,
-  Tooltip,
-} from 'antd';
-import {
-  BarsOutlined,
-  ControlOutlined,
-  CloudServerOutlined,
+  CopyOutlined,
   CloudDownloadOutlined,
   CloudDownloadOutlined,
-  CloudUploadOutlined,
-  ArrowUpOutlined,
-  ArrowDownOutlined,
-  AreaChartOutlined,
-  GlobalOutlined,
-  SwapOutlined,
-  EyeOutlined,
-  EyeInvisibleOutlined,
-  ThunderboltOutlined,
-  DesktopOutlined,
+  DashboardOutlined,
   DatabaseOutlined,
   DatabaseOutlined,
-  ForkOutlined,
-  CopyOutlined,
-  TelegramFilled,
+  HddOutlined,
+  SwapOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
-import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
-import { formatPanelVersion } from '@/lib/panel-version';
-import { activateOnKey } from '@/utils/a11y';
+import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
+import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
 import { useTheme } from '@/hooks/useTheme';
 import { useTheme } from '@/hooks/useTheme';
 import { useStatusQuery } from '@/api/queries/useStatusQuery';
 import { useStatusQuery } from '@/api/queries/useStatusQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import AppSidebar from '@/layouts/AppSidebar';
 import AppSidebar from '@/layouts/AppSidebar';
 import { LazyMount } from '@/components/utility';
 import { LazyMount } from '@/components/utility';
 import { setMessageInstance } from '@/utils/messageBus';
 import { setMessageInstance } from '@/utils/messageBus';
-import StatusCard from './StatusCard';
-import XrayStatusCard from './XrayStatusCard';
+import OverviewActionBar from './OverviewActionBar';
+import VitalTile from './VitalTile';
+import ThroughputCard from './ThroughputCard';
+import ConnectionsCard from './ConnectionsCard';
+import SystemStrip from './SystemStrip';
+import { mean, peak, useOverviewHistory } from './useOverviewHistory';
 import type { PanelUpdateInfo } from './PanelUpdateModal';
 import type { PanelUpdateInfo } from './PanelUpdateModal';
 const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
 const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
 const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
 const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
   const [loadingTip, setLoadingTip] = useState(t('loading'));
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
 
+  const history = useOverviewHistory(status, fetched && !fetchError);
+
   useEffect(() => {
   useEffect(() => {
     HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
     HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
       '/panel/api/setting/defaultSettings',
       '/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
     await refresh();
     await refresh();
   }, [refresh]);
   }, [refresh]);
 
 
-  function openPanelVersion() {
-    setPanelUpdateOpen(true);
-  }
-
   async function handleChannelChange(dev: boolean) {
   async function handleChannelChange(dev: boolean) {
     const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
     const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
     if (!res?.success) return;
     if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
     if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
     if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
   }
   }
 
 
-  function openTelegram() {
-    window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
-  }
-
   async function openConfig() {
   async function openConfig() {
     setLoading(true);
     setLoading(true);
     try {
     try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
   }
   }
 
 
   const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
   const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
+  const totalDisk = status.disk.total;
+  const freeDisk = Math.max(0, totalDisk - status.disk.current);
+
+  const health = useMemo(() => {
+    const items = [
+      { name: t('pages.index.cpu'), value: status.cpu.percent },
+      { name: t('pages.index.memory'), value: status.mem.percent },
+      { name: t('pages.index.swap'), value: status.swap.percent },
+      { name: t('pages.index.storage'), value: status.disk.percent },
+    ];
+    const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
+    const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
+    if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
+    const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
+    if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
+    return null;
+  }, [status, t]);
 
 
   return (
   return (
     <ConfigProvider theme={antdThemeConfig}>
     <ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
                   extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
                   extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
                 />
                 />
               ) : (
               ) : (
-                <Row gutter={[isMobile ? 8 : 16, 12]}>
-                  <Col span={24}>
-                    <StatusCard status={status} isMobile={isMobile} />
-                  </Col>
+                <div className="ov-page">
+                  <OverviewActionBar
+                    status={status}
+                    isMobile={isMobile}
+                    accessLogEnable={accessLogEnable}
+                    panelVersion={displayVersion}
+                    latestVersion={panelUpdateInfo.latestVersion}
+                    updateAvailable={panelUpdateInfo.updateAvailable}
+                    onStopXray={stopXray}
+                    onRestartXray={restartXray}
+                    onOpenLogs={() => setLogsOpen(true)}
+                    onOpenXrayLogs={() => setXrayLogsOpen(true)}
+                    onOpenConfig={openConfig}
+                    onOpenBackup={() => setBackupOpen(true)}
+                    onOpenSystemHistory={() => setSysHistoryOpen(true)}
+                    onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
+                    onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
+                    onOpenVersionSwitch={() => setVersionOpen(true)}
+                  />
 
 
-                  <Col xs={24} lg={12}>
-                    <XrayStatusCard
-                      status={status}
+                  {health && (
+                    <div className="ov-health" style={{ color: health.color }}>
+                      <span className="ov-health-mark" />
+                      {health.text}
+                    </div>
+                  )}
+
+                  <hr className="ov-rule" />
+
+                  <div className="ov-vitals">
+                    <VitalTile
+                      icon={<DashboardOutlined />}
+                      label={t('pages.index.cpu')}
+                      percent={status.cpu.percent}
+                      statusColor={status.cpu.color}
+                      detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
+                      footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
+                      footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
+                      data={history.series.cpu}
                       isMobile={isMobile}
                       isMobile={isMobile}
-                      accessLogEnable={accessLogEnable}
-                      onStopXray={stopXray}
-                      onRestartXray={restartXray}
-                      onOpenXrayLogs={() => setXrayLogsOpen(true)}
-                      onOpenLogs={() => setLogsOpen(true)}
-                      onOpenVersionSwitch={() => setVersionOpen(true)}
                     />
                     />
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card
-                      title={t('menu.link')}
-                      hoverable
-                      actions={[
-                        <Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
-                          <BarsOutlined />
-                          {!isMobile && <span>{t('pages.index.logs')}</span>}
-                        </Space>,
-                        <Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
-                          <ControlOutlined />
-                          {!isMobile && <span>{t('pages.index.config')}</span>}
-                        </Space>,
-                        <Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
-                          <CloudServerOutlined />
-                          {!isMobile && <span>{t('pages.index.backupTitle')}</span>}
-                        </Space>,
-                      ]}
+                    <VitalTile
+                      icon={<DatabaseOutlined />}
+                      label={t('pages.index.memory')}
+                      percent={status.mem.percent}
+                      statusColor={status.mem.color}
+                      detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
+                      footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
+                      footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
+                      data={history.series.mem}
+                      isMobile={isMobile}
                     />
                     />
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card
-                      title={
-                        <Space>
-                          <span>3X-UI</span>
-                          {isMobile && displayVersion && (
-                            <Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
-                              {panelUpdateInfo.updateAvailable
-                                ? formatPanelVersion(panelUpdateInfo.latestVersion)
-                                : formatPanelVersion(displayVersion)}
-                            </Tag>
-                          )}
-                        </Space>
-                      }
-                      hoverable
-                      actions={[
-                        <Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
-                          <TelegramFilled aria-hidden="true" />
-                          {!isMobile && <span>@XrayUI</span>}
-                        </Space>,
-                        <Space
-                          key="panel-version"
-                          className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
-                          role="button"
-                          tabIndex={0}
-                          aria-label={t('pages.index.updatePanel')}
-                          onClick={openPanelVersion}
-                          onKeyDown={activateOnKey(openPanelVersion)}
-                        >
-                          <CloudDownloadOutlined />
-                          {!isMobile && (
-                            <span>
-                              {panelUpdateInfo.updateAvailable
-                                ? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
-                                : formatPanelVersion(displayVersion)}
-                            </span>
-                          )}
-                        </Space>,
-                      ]}
+                    <VitalTile
+                      icon={<SwapOutlined />}
+                      label={t('pages.index.swap')}
+                      percent={status.swap.percent}
+                      statusColor={status.swap.color}
+                      detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
+                      footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
+                      footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
+                      data={history.series.swap}
+                      isMobile={isMobile}
                     />
                     />
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card
-                      title={t('pages.index.charts')}
-                      hoverable
-                      actions={[
-                        <Space
-                          className="action"
-                          key="sys-history"
-                          role="button"
-                          tabIndex={0}
-                          aria-label={t('pages.index.systemHistoryTitle')}
-                          onClick={() => setSysHistoryOpen(true)}
-                          onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
-                        >
-                          <AreaChartOutlined />
-                          {!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
-                        </Space>,
-                        <Space
-                          className="action"
-                          key="xray-metrics"
-                          role="button"
-                          tabIndex={0}
-                          aria-label={t('pages.index.xrayMetricsTitle')}
-                          onClick={() => setXrayMetricsOpen(true)}
-                          onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
-                        >
-                          <AreaChartOutlined />
-                          {!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
-                        </Space>,
-                      ]}
+                    <VitalTile
+                      icon={<HddOutlined />}
+                      label={t('pages.index.storage')}
+                      percent={status.disk.percent}
+                      statusColor={status.disk.color}
+                      detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
+                      footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
+                      footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
+                      data={history.series.diskUsage}
+                      isMobile={isMobile}
                     />
                     />
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card title={t('pages.index.operationHours')} hoverable>
-                      <Row gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={12}>
-                          <Statistic
-                            title="Xray"
-                            value={TimeFormatter.formatSecond(status.appStats.uptime)}
-                            prefix={<ThunderboltOutlined />}
-                          />
-                        </Col>
-                        <Col span={12}>
-                          <Statistic
-                            title="OS"
-                            value={TimeFormatter.formatSecond(status.uptime)}
-                            prefix={<DesktopOutlined />}
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card title={t('usage')} hoverable>
-                      <Row gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.memory')}
-                            value={SizeFormatter.sizeFormat(status.appStats.mem)}
-                            prefix={<DatabaseOutlined />}
-                          />
-                        </Col>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.threads')}
-                            value={status.appStats.threads}
-                            prefix={<ForkOutlined />}
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card title={t('pages.index.overallSpeed')} hoverable>
-                      <Row gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.upload')}
-                            value={SizeFormatter.sizeFormat(status.netIO.up)}
-                            prefix={<ArrowUpOutlined />}
-                            suffix="/s"
-                          />
-                        </Col>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.download')}
-                            value={SizeFormatter.sizeFormat(status.netIO.down)}
-                            prefix={<ArrowDownOutlined />}
-                            suffix="/s"
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
+                  </div>
 
 
-                  <Col xs={24} lg={12}>
-                    <Card title={t('pages.index.totalData')} hoverable>
-                      <Row gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.sent')}
-                            value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
-                            prefix={<CloudUploadOutlined />}
-                          />
-                        </Col>
-                        <Col span={12}>
-                          <Statistic
-                            title={t('pages.index.received')}
-                            value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
-                            prefix={<CloudDownloadOutlined />}
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
-
-                  <Col xs={24} lg={12}>
-                    <Card
-                      title={t('pages.index.ipAddresses')}
-                      hoverable
-                      extra={
-                        <Tooltip
-                          title={t('pages.index.toggleIpVisibility')}
-                          placement={isMobile ? 'topRight' : 'top'}
-                        >
-                          {showIp ? (
-                            <EyeOutlined
-                              className="ip-toggle-icon"
-                              role="button"
-                              tabIndex={0}
-                              aria-label={t('pages.index.toggleIpVisibility')}
-                              onClick={() => setShowIp(false)}
-                              onKeyDown={activateOnKey(() => setShowIp(false))}
-                            />
-                          ) : (
-                            <EyeInvisibleOutlined
-                              className="ip-toggle-icon"
-                              role="button"
-                              tabIndex={0}
-                              aria-label={t('pages.index.toggleIpVisibility')}
-                              onClick={() => setShowIp(true)}
-                              onKeyDown={activateOnKey(() => setShowIp(true))}
-                            />
-                          )}
-                        </Tooltip>
-                      }
-                    >
-                      <Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={isMobile ? 24 : 12}>
-                          <Statistic
-                            title="IPv4"
-                            value={status.publicIP.ipv4}
-                            prefix={<GlobalOutlined />}
-                          />
-                        </Col>
-                        <Col span={isMobile ? 24 : 12}>
-                          <Statistic
-                            title="IPv6"
-                            value={status.publicIP.ipv6}
-                            prefix={<GlobalOutlined />}
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
+                  <div className="ov-mid">
+                    <ThroughputCard
+                      status={status}
+                      up={history.series.netUp}
+                      down={history.series.netDown}
+                      labels={history.labels}
+                      isMobile={isMobile}
+                    />
+                    <ConnectionsCard
+                      status={status}
+                      tcp={history.series.tcpCount}
+                      udp={history.series.udpCount}
+                      labels={history.labels}
+                      isMobile={isMobile}
+                    />
+                  </div>
 
 
-                  <Col xs={24} lg={12}>
-                    <Card title={t('pages.index.connectionCount')} hoverable>
-                      <Row gutter={isMobile ? [8, 8] : 0}>
-                        <Col span={12}>
-                          <Statistic
-                            title="TCP"
-                            value={status.tcpCount}
-                            prefix={<SwapOutlined />}
-                          />
-                        </Col>
-                        <Col span={12}>
-                          <Statistic
-                            title="UDP"
-                            value={status.udpCount}
-                            prefix={<SwapOutlined />}
-                          />
-                        </Col>
-                      </Row>
-                    </Card>
-                  </Col>
-                </Row>
+                  <SystemStrip
+                    status={status}
+                    showIp={showIp}
+                    onToggleIp={() => setShowIp((v) => !v)}
+                  />
+                </div>
               )}
               )}
             </Spin>
             </Spin>
           </Layout.Content>
           </Layout.Content>

+ 163 - 0
frontend/src/pages/index/OverviewActionBar.tsx

@@ -0,0 +1,163 @@
+import { Fragment } from 'react';
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Tag, Tooltip } from 'antd';
+import {
+  ArrowUpOutlined,
+  AreaChartOutlined,
+  BarsOutlined,
+  CloudDownloadOutlined,
+  CloudServerOutlined,
+  ControlOutlined,
+  FileTextOutlined,
+  PoweroffOutlined,
+  ReloadOutlined,
+} from '@ant-design/icons';
+
+import { formatPanelVersion } from '@/lib/panel-version';
+import type { Status } from '@/models/status';
+
+interface OverviewActionBarProps {
+  status: Status;
+  isMobile: boolean;
+  accessLogEnable: boolean;
+  panelVersion: string;
+  latestVersion: string;
+  updateAvailable: boolean;
+  onStopXray: () => void;
+  onRestartXray: () => void;
+  onOpenLogs: () => void;
+  onOpenXrayLogs: () => void;
+  onOpenConfig: () => void;
+  onOpenBackup: () => void;
+  onOpenSystemHistory: () => void;
+  onOpenXrayMetrics: () => void;
+  onOpenPanelUpdate: () => void;
+  onOpenVersionSwitch: () => void;
+}
+
+interface BarAction {
+  key: string;
+  icon: ReactNode;
+  text: string;
+  onClick: () => void;
+  primary?: boolean;
+}
+
+const XRAY_STATE_KEYS: Record<string, string> = {
+  running: 'pages.index.xrayStatusRunning',
+  stop: 'pages.index.xrayStatusStop',
+  error: 'pages.index.xrayStatusError',
+};
+
+export default function OverviewActionBar({
+  status,
+  isMobile,
+  accessLogEnable,
+  panelVersion,
+  latestVersion,
+  updateAvailable,
+  onStopXray,
+  onRestartXray,
+  onOpenLogs,
+  onOpenXrayLogs,
+  onOpenConfig,
+  onOpenBackup,
+  onOpenSystemHistory,
+  onOpenXrayMetrics,
+  onOpenPanelUpdate,
+  onOpenVersionSwitch,
+}: OverviewActionBarProps) {
+  const { t } = useTranslation();
+  const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
+  const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
+  const size = isMobile ? ('small' as const) : ('middle' as const);
+
+  const actionGroups: BarAction[][] = [
+    [
+      { key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
+      { key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
+    ],
+    [
+      { key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
+      ...(accessLogEnable
+        ? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
+        : []),
+      { key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
+      { key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
+    ],
+    [
+      { key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
+      { key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
+    ],
+  ];
+
+  const statePill = (
+    <span className="ov-state" data-state={status.xray.state}>
+      <span className="ov-state-dot" style={{ color: status.xray.color }} />
+      <span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
+      {hasVersion && (
+        <Tooltip title={t('pages.index.xraySwitch')}>
+          <button
+            type="button"
+            className="ov-state-version"
+            onClick={onOpenVersionSwitch}
+          >
+            {`v${status.xray.version}`}
+          </button>
+        </Tooltip>
+      )}
+    </span>
+  );
+
+  return (
+    <div className="ov-bar">
+      {status.xray.state === 'error' && status.xray.errorMsg ? (
+        <Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
+          {statePill}
+        </Tooltip>
+      ) : (
+        statePill
+      )}
+
+      {updateAvailable ? (
+        <Tag
+          className="ov-update-tag"
+          color="warning"
+          icon={<CloudDownloadOutlined />}
+          onClick={onOpenPanelUpdate}
+        >
+          {`${t('update')} ${formatPanelVersion(latestVersion)}`}
+        </Tag>
+      ) : (
+        <Tooltip title={t('pages.index.updatePanel')}>
+          <button type="button" className="ov-panel-version ov-mono" onClick={onOpenPanelUpdate}>
+            {formatPanelVersion(panelVersion)}
+          </button>
+        </Tooltip>
+      )}
+
+      <div className="ov-bar-actions">
+        {actionGroups.map((group, groupIndex) => (
+          <Fragment key={group[0].key}>
+            {groupIndex > 0 && <span className="ov-bar-sep" />}
+            {group.map((action) => (
+              <Button
+                key={action.key}
+                type={action.primary ? undefined : 'text'}
+                color={action.primary ? 'primary' : undefined}
+                variant={action.primary ? 'outlined' : undefined}
+                size={size}
+                icon={action.icon}
+                aria-label={action.text}
+                onClick={action.onClick}
+              >
+                {isMobile ? undefined : action.text}
+              </Button>
+            ))}
+          </Fragment>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 0 - 9
frontend/src/pages/index/StatusCard.css

@@ -1,9 +0,0 @@
-.status-card .text-center {
-  text-align: center;
-}
-
-.status-card .ant-progress-text,
-.status-card .ant-progress-indicator {
-  font-size: 12px !important;
-  font-weight: 500;
-}

+ 0 - 115
frontend/src/pages/index/StatusCard.tsx

@@ -1,115 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { Card, Col, Progress, Row, Tooltip } from 'antd';
-import { AreaChartOutlined } from '@ant-design/icons';
-
-import { CPUFormatter, SizeFormatter } from '@/utils';
-import { useTheme } from '@/hooks/useTheme';
-import type { Status } from '@/models/status';
-import './StatusCard.css';
-
-interface StatusCardProps {
-  status: Status;
-  isMobile: boolean;
-}
-
-export default function StatusCard({ status, isMobile }: StatusCardProps) {
-  const { t } = useTranslation();
-  const { isDark, isUltra } = useTheme();
-  const gaugeSize = isMobile ? 60 : 90;
-  const strokeWidth = isMobile ? 7 : 5;
-  const railColor = isDark
-    ? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
-    : 'rgba(0, 0, 0, 0.08)';
-
-  return (
-    <Card hoverable className="status-card">
-      <Row gutter={[0, isMobile ? 16 : 0]}>
-        <Col xs={24} md={12}>
-          <Row>
-            <Col span={12} className="text-center">
-              <Progress
-                type="dashboard"
-                status="normal"
-                strokeColor={status.cpu.color}
-                railColor={railColor}
-                strokeWidth={strokeWidth}
-                percent={status.cpu.percent}
-                size={gaugeSize}
-              />
-              <div>
-                <b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
-                <Tooltip
-                  title={
-                    <>
-                      <div>
-                        <b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
-                      </div>
-                      <div>
-                        <b>{t('pages.index.frequency')}:</b>{' '}
-                        {CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
-                      </div>
-                    </>
-                  }
-                >
-                  <AreaChartOutlined />
-                </Tooltip>
-              </div>
-            </Col>
-
-            <Col span={12} className="text-center">
-              <Progress
-                type="dashboard"
-                status="normal"
-                strokeColor={status.mem.color}
-                railColor={railColor}
-                strokeWidth={strokeWidth}
-                percent={status.mem.percent}
-                size={gaugeSize}
-              />
-              <div>
-                <b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
-                {SizeFormatter.sizeFormat(status.mem.total)}
-              </div>
-            </Col>
-          </Row>
-        </Col>
-
-        <Col xs={24} md={12}>
-          <Row>
-            <Col span={12} className="text-center">
-              <Progress
-                type="dashboard"
-                status="normal"
-                strokeColor={status.swap.color}
-                railColor={railColor}
-                strokeWidth={strokeWidth}
-                percent={status.swap.percent}
-                size={gaugeSize}
-              />
-              <div>
-                <b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
-                {SizeFormatter.sizeFormat(status.swap.total)}
-              </div>
-            </Col>
-
-            <Col span={12} className="text-center">
-              <Progress
-                type="dashboard"
-                status="normal"
-                strokeColor={status.disk.color}
-                railColor={railColor}
-                strokeWidth={strokeWidth}
-                percent={status.disk.percent}
-                size={gaugeSize}
-              />
-              <div>
-                <b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
-                {SizeFormatter.sizeFormat(status.disk.total)}
-              </div>
-            </Col>
-          </Row>
-        </Col>
-      </Row>
-    </Card>
-  );
-}

+ 97 - 0
frontend/src/pages/index/SystemStrip.tsx

@@ -0,0 +1,97 @@
+import { useTranslation } from 'react-i18next';
+import { Card, Tooltip } from 'antd';
+import {
+  ClockCircleOutlined,
+  DatabaseOutlined,
+  EyeInvisibleOutlined,
+  EyeOutlined,
+  GlobalOutlined,
+} from '@ant-design/icons';
+
+import { SizeFormatter, TimeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
+import type { Status } from '@/models/status';
+
+interface SystemStripProps {
+  status: Status;
+  showIp: boolean;
+  onToggleIp: () => void;
+}
+
+export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripProps) {
+  const { t } = useTranslation();
+
+  return (
+    <Card hoverable styles={{ body: { padding: 0 } }}>
+      <div className="ov-strip-grid">
+        <div className="ov-strip-cell">
+          <div className="ov-kicker ov-kicker-icon">
+            <ClockCircleOutlined />
+            {t('pages.index.uptime')}
+          </div>
+          <div className="ov-strip-split">
+            <div>
+              <div className="ov-strip-sub">Xray</div>
+              <div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
+            </div>
+            <span className="ov-strip-split-sep" />
+            <div>
+              <div className="ov-strip-sub">OS</div>
+              <div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
+            </div>
+          </div>
+        </div>
+
+        <div className="ov-strip-cell">
+          <div className="ov-kicker ov-kicker-icon">
+            <DatabaseOutlined />
+            {t('pages.index.panel')}
+          </div>
+          <div className="ov-strip-split">
+            <div>
+              <div className="ov-strip-sub">{t('pages.index.memory')}</div>
+              <div className="ov-strip-value">{SizeFormatter.sizeFormat(status.appStats.mem)}</div>
+            </div>
+            <span className="ov-strip-split-sep" />
+            <div>
+              <div className="ov-strip-sub">{t('pages.index.threads')}</div>
+              <div className="ov-strip-value">{status.appStats.threads}</div>
+            </div>
+          </div>
+        </div>
+
+        <div className="ov-strip-cell">
+          <div className="ov-kicker ov-kicker-icon">
+            <GlobalOutlined />
+            {t('pages.index.ipAddresses')}
+            <Tooltip title={t('pages.index.toggleIpVisibility')}>
+              {showIp ? (
+                <EyeOutlined
+                  className="ip-toggle-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('pages.index.toggleIpVisibility')}
+                  onClick={onToggleIp}
+                  onKeyDown={activateOnKey(onToggleIp)}
+                />
+              ) : (
+                <EyeInvisibleOutlined
+                  className="ip-toggle-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('pages.index.toggleIpVisibility')}
+                  onClick={onToggleIp}
+                  onKeyDown={activateOnKey(onToggleIp)}
+                />
+              )}
+            </Tooltip>
+          </div>
+          <div className={`ov-ip${showIp ? '' : ' ip-hidden'}`}>
+            <div className="ov-mono">{status.publicIP.ipv4}</div>
+            <div className="ov-mono ov-ip-v6">{status.publicIP.ipv6}</div>
+          </div>
+        </div>
+      </div>
+    </Card>
+  );
+}

+ 97 - 0
frontend/src/pages/index/ThroughputCard.tsx

@@ -0,0 +1,97 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, theme } from 'antd';
+import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
+
+import { SizeFormatter } from '@/utils';
+import { Sparkline } from '@/components/viz';
+import type { Status } from '@/models/status';
+import { mean, peak } from './useOverviewHistory';
+
+interface ThroughputCardProps {
+  status: Status;
+  up: number[];
+  down: number[];
+  labels: string[];
+  isMobile: boolean;
+}
+
+export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
+  const { t } = useTranslation();
+  const { token } = theme.useToken();
+  const accent = token.colorPrimary;
+  const downColor = token.colorTextTertiary;
+
+  const referenceLines = useMemo(
+    () => [
+      { y: status.netIO.down, color: downColor, dash: '2 4' },
+      { y: status.netIO.up, color: accent, dash: '2 4' },
+    ],
+    [status.netIO.up, status.netIO.down, accent, downColor],
+  );
+
+  return (
+    <Card hoverable styles={{ body: { padding: 0 } }}>
+      <div className="ov-wide-head">
+        <div>
+          <div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
+          <div className="ov-sub">
+            {`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
+          </div>
+        </div>
+        <div className="ov-wide-legend">
+          <div className="ov-legend-label">
+            <ArrowUpOutlined style={{ color: accent }} />
+            {t('pages.index.upload')}
+            <span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
+          </div>
+          <div className="ov-legend-label">
+            <ArrowDownOutlined style={{ color: downColor }} />
+            {t('pages.index.download')}
+            <span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
+          </div>
+        </div>
+      </div>
+
+      <div className="ov-wide-chart">
+        <Sparkline
+          data={up}
+          data2={down}
+          labels={labels}
+          height={isMobile ? 140 : 186}
+          strokeWidth={1.75}
+          fillOpacity={0.24}
+          showTooltip
+          showLegend={false}
+          valueMax={null}
+          stroke={accent}
+          stroke2={downColor}
+          name1={t('pages.index.upload')}
+          name2={t('pages.index.download')}
+          yFormatter={SizeFormatter.speedFormat}
+          referenceLines={referenceLines}
+        />
+      </div>
+
+      <div className="ov-wide-foot">
+        <div>
+          <div className="ov-kicker">{t('pages.index.sent')}</div>
+          <div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.sent)}</div>
+        </div>
+        <span className="ov-foot-sep" />
+        <div>
+          <div className="ov-kicker">{t('pages.index.received')}</div>
+          <div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
+        </div>
+        <span className="ov-foot-sep" />
+        <div>
+          <div className="ov-kicker">{t('pages.index.avgWindow')}</div>
+          <div className="ov-foot-value">
+            <span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
+            <span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
+          </div>
+        </div>
+      </div>
+    </Card>
+  );
+}

+ 75 - 0
frontend/src/pages/index/VitalTile.tsx

@@ -0,0 +1,75 @@
+import { useMemo } from 'react';
+import type { ReactNode } from 'react';
+import { Card, theme } from 'antd';
+
+import { Sparkline } from '@/components/viz';
+import { mean, peak } from './useOverviewHistory';
+
+interface VitalTileProps {
+  icon: ReactNode;
+  label: string;
+  percent: number;
+  statusColor: string;
+  detail: string;
+  footLeft: string;
+  footRight: string;
+  data: number[];
+  isMobile: boolean;
+}
+
+export default function VitalTile({
+  icon,
+  label,
+  percent,
+  statusColor,
+  detail,
+  footLeft,
+  footRight,
+  data,
+  isMobile,
+}: VitalTileProps) {
+  const { token } = theme.useToken();
+  const meanColor = token.colorTextTertiary;
+
+  const referenceLines = useMemo(
+    () => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
+    [data, meanColor],
+  );
+
+  return (
+    <Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
+      <div className="ov-tile-head">
+        <span className="ov-tile-icon">{icon}</span>
+        <span className="ov-kicker">{label}</span>
+      </div>
+
+      <div className="ov-tile-value">
+        <span className="ov-tile-number">{percent.toFixed(1)}</span>
+        <span className="ov-tile-unit">%</span>
+      </div>
+
+      <div className="ov-tile-detail">{detail}</div>
+
+      <div className="ov-tile-foot">
+        <span>{footLeft}</span>
+        <span>{footRight}</span>
+      </div>
+
+      <div className="ov-tile-chart">
+        <Sparkline
+          data={data}
+          height={isMobile ? 48 : 62}
+          strokeWidth={1.5}
+          fillOpacity={0.3}
+          showGrid={false}
+          showMarker={false}
+          valueMax={peak(data) > 0 ? null : 100}
+          stroke={statusColor}
+          referenceLines={referenceLines}
+          yFormatter={(v) => `${v.toFixed(0)}%`}
+          name1={label}
+        />
+      </div>
+    </Card>
+  );
+}

+ 0 - 14
frontend/src/pages/index/XrayStatusCard.css

@@ -1,14 +0,0 @@
-.xray-status-card .action {
-  cursor: pointer;
-  justify-content: center;
-}
-
-.error-line {
-  display: block;
-  max-width: 400px;
-  white-space: pre-wrap;
-}
-
-.cursor-pointer {
-  cursor: pointer;
-}

+ 0 - 123
frontend/src/pages/index/XrayStatusCard.tsx

@@ -1,123 +0,0 @@
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
-import {
-  BarsOutlined,
-  PoweroffOutlined,
-  ReloadOutlined,
-  ToolOutlined,
-} from '@ant-design/icons';
-
-import type { Status } from '@/models/status';
-import { activateOnKey } from '@/utils/a11y';
-import './XrayStatusCard.css';
-
-interface XrayStatusCardProps {
-  status: Status;
-  isMobile: boolean;
-  accessLogEnable: boolean;
-  onStopXray: () => void;
-  onRestartXray: () => void;
-  onOpenLogs: () => void;
-  onOpenXrayLogs: () => void;
-  onOpenVersionSwitch: () => void;
-}
-
-const XRAY_STATE_KEYS: Record<string, string> = {
-  running: 'pages.index.xrayStatusRunning',
-  stop: 'pages.index.xrayStatusStop',
-  error: 'pages.index.xrayStatusError',
-};
-
-export default function XrayStatusCard({
-  status,
-  isMobile,
-  accessLogEnable,
-  onStopXray,
-  onRestartXray,
-  onOpenLogs,
-  onOpenXrayLogs,
-  onOpenVersionSwitch,
-}: XrayStatusCardProps) {
-  const { t } = useTranslation();
-
-  const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
-
-  const title = (
-    <Space>
-      <span>{t('pages.index.xrayStatus')}</span>
-      {isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
-        <Tag color="green">v{status.xray.version}</Tag>
-      )}
-    </Space>
-  );
-
-  const errorLines = useMemo(
-    () => (status.xray.errorMsg || '').split('\n'),
-    [status.xray.errorMsg],
-  );
-
-  const extra =
-    status.xray.state !== 'error' ? (
-      <Badge status="processing" text={stateText} color={status.xray.color} />
-    ) : (
-      <Popover
-        title={
-          <Row align="middle" justify="space-between">
-            <Col>
-              <span>{t('pages.index.xrayStatusError')}</span>
-            </Col>
-            <Col>
-              <BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
-            </Col>
-          </Row>
-        }
-        content={
-          <>
-            {errorLines.map((line, i) => (
-              <span key={i} className="error-line">
-                {line}
-              </span>
-            ))}
-          </>
-        }
-      >
-        <Badge status="processing" text={stateText} color={status.xray.color} />
-      </Popover>
-    );
-
-  const actions = [
-    // the xray log viewer reads the access log file, so the button only makes
-    // sense when one is configured (unlike IP limit, which no longer needs it)
-    ...(accessLogEnable
-      ? [
-          <Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
-            <BarsOutlined />
-            {!isMobile && <span>{t('pages.index.accessLogs')}</span>}
-          </Space>,
-        ]
-      : []),
-    <Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
-      <PoweroffOutlined />
-      {!isMobile && <span>{t('pages.index.stopXray')}</span>}
-    </Space>,
-    <Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
-      <ReloadOutlined />
-      {!isMobile && <span>{t('pages.index.restartXray')}</span>}
-    </Space>,
-    <Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
-      <ToolOutlined />
-      {!isMobile && (
-        <span>
-          {status.xray.version && status.xray.version !== 'Unknown'
-            ? `v${status.xray.version}`
-            : t('pages.index.xraySwitch')}
-        </span>
-      )}
-    </Space>,
-  ];
-
-  return (
-    <Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
-  );
-}

+ 135 - 0
frontend/src/pages/index/useOverviewHistory.ts

@@ -0,0 +1,135 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import { HttpUtil, TimeFormatter } from '@/utils';
+import type { Status } from '@/models/status';
+
+const OVERVIEW_WINDOW = 72;
+const SEED_BUCKET_SECONDS = 2;
+
+const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
+
+export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
+
+export interface OverviewHistory {
+  series: Record<OverviewSeriesKey, number[]>;
+  labels: string[];
+}
+
+interface HistoryPoint {
+  t: number;
+  v: number;
+}
+
+interface HistoryWindow {
+  series: Record<OverviewSeriesKey, number[]>;
+  times: number[];
+}
+
+function emptySeries(): Record<OverviewSeriesKey, number[]> {
+  return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
+}
+
+function emptyWindow(): HistoryWindow {
+  return { series: emptySeries(), times: [] };
+}
+
+function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
+  return {
+    cpu: status.cpu.percent,
+    mem: status.mem.percent,
+    swap: status.swap.percent,
+    diskUsage: status.disk.percent,
+    netUp: status.netIO.up,
+    netDown: status.netIO.down,
+    tcpCount: status.tcpCount,
+    udpCount: status.udpCount,
+  };
+}
+
+function tailWindow<T>(values: T[]): T[] {
+  return values.slice(-OVERVIEW_WINDOW);
+}
+
+export function mean(values: number[]): number {
+  if (values.length === 0) return 0;
+  let total = 0;
+  for (const v of values) total += v;
+  return total / values.length;
+}
+
+export function peak(values: number[]): number {
+  let max = 0;
+  for (const v of values) if (v > max) max = v;
+  return max;
+}
+
+/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
+   2s is the smallest and matches the status poll cadence */
+export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
+  const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
+
+  useEffect(() => {
+    let cancelled = false;
+
+    const seed = async () => {
+      const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
+      await Promise.all(
+        SERIES_KEYS.map(async (key) => {
+          const msg = await HttpUtil.get<HistoryPoint[]>(
+            `/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
+            undefined,
+            { silent: true },
+          );
+          if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
+        }),
+      );
+      if (cancelled || responses.size === 0) return;
+
+      let axis: HistoryPoint[] = [];
+      for (const points of responses.values()) {
+        if (points.length > axis.length) axis = points;
+      }
+      axis = tailWindow(axis);
+      if (axis.length === 0) return;
+
+      const seedTimes = axis.map((p) => Number(p.t) || 0);
+      const seedSeries = emptySeries();
+      for (const key of SERIES_KEYS) {
+        const byTs = new Map<number, number>();
+        for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
+        seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
+      }
+
+      setTrend((prev) => {
+        const merged = emptyWindow();
+        merged.times = tailWindow(seedTimes.concat(prev.times));
+        for (const key of SERIES_KEYS) {
+          merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
+        }
+        return merged;
+      });
+    };
+
+    seed().catch(() => undefined);
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!hasData) return;
+    setTrend((prev) => {
+      const point = sampleOf(status);
+      const next = emptyWindow();
+      next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
+      for (const key of SERIES_KEYS) {
+        next.series[key] = tailWindow(prev.series[key].concat(point[key]));
+      }
+      return next;
+    });
+  }, [status, hasData]);
+
+  const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
+
+  return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
+}

+ 83 - 0
frontend/src/test/http-init.test.tsx

@@ -193,4 +193,87 @@ describe('http-init fetch wrapper', () => {
 
 
     expect(initOf().signal).toBeInstanceOf(AbortSignal);
     expect(initOf().signal).toBeInstanceOf(AbortSignal);
   });
   });
+
+  it('preserves a caller cancellation signal when a timeout is set', async () => {
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+    const controller = new AbortController();
+
+    await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
+    controller.abort();
+
+    expect(initOf().signal?.aborted).toBe(true);
+  });
+
+  it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
+    const timeout = AbortSignal.timeout.bind(AbortSignal);
+    vi.resetModules();
+    vi.stubGlobal('AbortSignal', { timeout });
+    http = await import('@/api/http-init');
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+    const controller = new AbortController();
+
+    await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
+    controller.abort();
+
+    expect(initOf().signal?.aborted).toBe(true);
+  });
+
+  it('times out when AbortSignal.any is unavailable', async () => {
+    const timeout = AbortSignal.timeout.bind(AbortSignal);
+    vi.resetModules();
+    vi.stubGlobal('AbortSignal', { timeout });
+    http = await import('@/api/http-init');
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+    const controller = new AbortController();
+
+    await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
+    const signal = initOf().signal as AbortSignal;
+    await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
+
+    expect(signal.aborted).toBe(true);
+  });
+
+  it('aborts on the timeout when a caller signal is present', async () => {
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+    const controller = new AbortController();
+
+    await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
+    const signal = initOf().signal as AbortSignal;
+    await new Promise<void>((resolve) => {
+      if (signal.aborted) {
+        resolve();
+        return;
+      }
+      signal.addEventListener('abort', () => resolve(), { once: true });
+    });
+
+    expect(signal.aborted).toBe(true);
+  });
+
+  it.each([
+    ['/x?keep=1', '/x?keep=1&added=yes'],
+    ['/x?', '/x?added=yes'],
+    ['/x#frag', '/x?added=yes#frag'],
+    ['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
+  ])('appends encoded params to %s', async (url, expected) => {
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
+
+    expect(urlOf()).toBe(expected);
+  });
+
+  it('preserves the URL when no params are supplied', async () => {
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    await http.httpRequest('GET', '/x?keep=1#frag');
+
+    expect(urlOf()).toBe('/x?keep=1#frag');
+  });
 });
 });

+ 28 - 0
frontend/src/test/use-all-settings.test.tsx

@@ -0,0 +1,28 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useAllSettings } from '@/api/queries/useAllSettings';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('useAllSettings', () => {
+  it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
+    const subJsonUserAgentRegex = 'x'.repeat(2_049);
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
+  });
+});

+ 41 - 0
frontend/src/test/zodValidate.test.ts

@@ -0,0 +1,41 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { ClientPageResponseSchema } from '@/schemas/client';
+import { fetchXrayConfig } from '@/hooks/useXraySetting';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('parseMsg', () => {
+  it('rejects a successful response whose payload violates its schema', () => {
+    const msg = new Msg(true, '', { id: 'not-a-number' });
+
+    expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
+      'test/value response failed validation',
+    );
+  });
+
+  it('preserves a missing successful payload for callers that handle empty values', () => {
+    expect(parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj).toBeNull();
+  });
+
+  it('rejects malformed paged-client payloads', () => {
+    const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
+
+    expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
+      'clients/list/paged response failed validation',
+    );
+  });
+});
+
+describe('fetchXrayConfig', () => {
+  it('keeps a malformed xray payload available for repair', async () => {
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
+
+    await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
+  });
+});

+ 8 - 0
frontend/src/utils/index.ts

@@ -685,6 +685,14 @@ export class CPUFormatter {
 }
 }
 
 
 export class TimeFormatter {
 export class TimeFormatter {
+  static formatClock(unixSec: number): string {
+    const d = new Date(unixSec * 1000);
+    const hh = String(d.getHours()).padStart(2, '0');
+    const mm = String(d.getMinutes()).padStart(2, '0');
+    const ss = String(d.getSeconds()).padStart(2, '0');
+    return `${hh}:${mm}:${ss}`;
+  }
+
   static formatSecond(second: number): string {
   static formatSecond(second: number): string {
     if (second < 60) return second.toFixed(0) + 's';
     if (second < 60) return second.toFixed(0) + 's';
     if (second < 3600) return (second / 60).toFixed(0) + 'm';
     if (second < 3600) return (second / 60).toFixed(0) + 'm';

+ 6 - 0
frontend/src/utils/zodValidate.ts

@@ -1,10 +1,15 @@
 import type { z } from 'zod';
 import type { z } from 'zod';
 import { Msg } from '@/utils';
 import { Msg } from '@/utils';
 
 
+interface ParseMsgOptions {
+  strict?: boolean;
+}
+
 export function parseMsg<T extends z.ZodType>(
 export function parseMsg<T extends z.ZodType>(
   msg: Msg<unknown>,
   msg: Msg<unknown>,
   schema: T,
   schema: T,
   context: string,
   context: string,
+  options: ParseMsgOptions = {},
 ): Msg<z.infer<T>> {
 ): Msg<z.infer<T>> {
   if (!msg.success || msg.obj == null) {
   if (!msg.success || msg.obj == null) {
     return msg as Msg<z.infer<T>>;
     return msg as Msg<z.infer<T>>;
@@ -12,6 +17,7 @@ export function parseMsg<T extends z.ZodType>(
   const result = schema.safeParse(msg.obj);
   const result = schema.safeParse(msg.obj);
   if (!result.success) {
   if (!result.success) {
     console.warn(`[zod] ${context} response failed validation`, result.error.issues);
     console.warn(`[zod] ${context} response failed validation`, result.error.issues);
+    if (options.strict) throw new Error(`${context} response failed validation`);
     return msg as Msg<z.infer<T>>;
     return msg as Msg<z.infer<T>>;
   }
   }
   return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
   return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);

+ 1 - 1
go.mod

@@ -13,6 +13,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/joho/godotenv v1.5.1
+	github.com/mattn/go-sqlite3 v1.14.48
 	github.com/mymmrac/telego v1.11.1
 	github.com/mymmrac/telego v1.11.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
@@ -68,7 +69,6 @@ require (
 	github.com/leodido/go-urn v1.5.0 // indirect
 	github.com/leodido/go-urn v1.5.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
-	github.com/mattn/go-sqlite3 v1.14.48 // indirect
 	github.com/miekg/dns v1.1.72 // indirect
 	github.com/miekg/dns v1.1.72 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect

+ 186 - 0
internal/database/backup_test.go

@@ -0,0 +1,186 @@
+package database
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	seed := make([]model.Setting, 128)
+	value := strings.Repeat("x", 1024)
+	for i := range seed {
+		seed[i] = model.Setting{Key: fmt.Sprintf("backup-seed-%d", i), Value: value}
+	}
+	if err := db.Create(&seed).Error; err != nil {
+		t.Fatalf("seed database: %v", err)
+	}
+
+	stop := make(chan struct{})
+	firstWrite := make(chan error, 1)
+	writesDone := make(chan error, 1)
+	go func() {
+		for i := 0; i < 128; i++ {
+			if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
+				if i == 0 {
+					firstWrite <- err
+				}
+				writesDone <- err
+				return
+			}
+			if i == 0 {
+				firstWrite <- nil
+			}
+			select {
+			case <-stop:
+				writesDone <- nil
+				return
+			default:
+			}
+		}
+		writesDone <- nil
+	}()
+
+	if err := <-firstWrite; err != nil {
+		t.Fatalf("first concurrent write: %v", err)
+	}
+	backupPath := filepath.Join(t.TempDir(), "backup.db")
+	if err := BackupSQLite(backupPath); err != nil {
+		close(stop)
+		<-writesDone
+		t.Fatalf("BackupSQLite: %v", err)
+	}
+	close(stop)
+	if err := <-writesDone; err != nil {
+		t.Fatalf("concurrent write: %v", err)
+	}
+	if err := ValidateSQLiteDB(backupPath); err != nil {
+		t.Fatalf("validate backup: %v", err)
+	}
+
+	backup, err := sql.Open("sqlite3", backupPath)
+	if err != nil {
+		t.Fatalf("open backup: %v", err)
+	}
+	defer backup.Close()
+
+	var seedCount int
+	if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key LIKE 'backup-seed-%'").Scan(&seedCount); err != nil {
+		t.Fatalf("count seeded rows: %v", err)
+	}
+	if seedCount != 128 {
+		t.Fatalf("seeded row count = %d, want 128", seedCount)
+	}
+	var firstWriteCount int
+	if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key = 'backup-write-0'").Scan(&firstWriteCount); err != nil {
+		t.Fatalf("count first concurrent write: %v", err)
+	}
+	if firstWriteCount != 1 {
+		t.Fatalf("first concurrent write count = %d, want 1", firstWriteCount)
+	}
+}
+
+func TestBackupSQLiteTimesOutWaitingForSourceConnection(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("get database connection pool: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	held, err := sqlDB.Conn(context.Background())
+	if err != nil {
+		t.Fatalf("hold source connection: %v", err)
+	}
+	defer held.Close()
+
+	previousTimeout := backupSQLiteTimeout
+	backupSQLiteTimeout = 20 * time.Millisecond
+	t.Cleanup(func() { backupSQLiteTimeout = previousTimeout })
+	err = BackupSQLite(filepath.Join(t.TempDir(), "backup.db"))
+	if !errors.Is(err, context.DeadlineExceeded) {
+		t.Fatalf("BackupSQLite error = %v, want context deadline exceeded", err)
+	}
+}
+
+func TestBackupSQLiteRefusesExistingDestination(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	backupPath := filepath.Join(t.TempDir(), "backup.db")
+	if err := os.WriteFile(backupPath, []byte("existing backup"), 0o600); err != nil {
+		t.Fatalf("create existing destination: %v", err)
+	}
+	err := BackupSQLite(backupPath)
+	want := fmt.Sprintf("sqlite backup destination already exists: %s", backupPath)
+	if err == nil || err.Error() != want {
+		t.Fatalf("BackupSQLite error = %v, want %q", err, want)
+	}
+	data, err := os.ReadFile(backupPath)
+	if err != nil {
+		t.Fatalf("read existing destination: %v", err)
+	}
+	if string(data) != "existing backup" {
+		t.Fatalf("existing destination = %q, want %q", data, "existing backup")
+	}
+}
+
+func TestBackupSQLiteStepPages(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	if got := backupSQLiteStepPages(); got != -1 {
+		t.Fatalf("WAL backup step pages = %d, want -1", got)
+	}
+	t.Setenv("XUI_DB_JOURNAL_MODE", "DELETE")
+	if got := backupSQLiteStepPages(); got != 128 {
+		t.Fatalf("DELETE backup step pages = %d, want 128", got)
+	}
+}
+
+func TestInitDBCleansBackupDirectories(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := t.TempDir()
+	orphanDir := filepath.Join(dbDir, sqliteBackupDirPrefix+"orphan")
+	if err := os.Mkdir(orphanDir, 0o700); err != nil {
+		t.Fatalf("create orphan backup directory: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(orphanDir, "backup.db"), []byte("backup"), 0o600); err != nil {
+		t.Fatalf("write orphan backup: %v", err)
+	}
+	regularDir := filepath.Join(dbDir, ".x-ui-keep")
+	if err := os.Mkdir(regularDir, 0o700); err != nil {
+		t.Fatalf("create regular directory: %v", err)
+	}
+
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	if _, err := os.Stat(orphanDir); !errors.Is(err, os.ErrNotExist) {
+		t.Fatalf("orphan backup directory error = %v, want not exist", err)
+	}
+	if info, err := os.Stat(regularDir); err != nil || !info.IsDir() {
+		t.Fatalf("regular directory info = %v, %v; want existing directory", info, err)
+	}
+}

+ 113 - 5
internal/database/db.go

@@ -3,6 +3,7 @@ package database
 import (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
+	"database/sql"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -12,6 +13,7 @@ import (
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"path"
 	"path"
+	"path/filepath"
 	"runtime"
 	"runtime"
 	"slices"
 	"slices"
 	"strconv"
 	"strconv"
@@ -24,6 +26,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 
+	"github.com/mattn/go-sqlite3"
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -32,6 +35,8 @@ import (
 
 
 var db *gorm.DB
 var db *gorm.DB
 
 
+var backupSQLiteTimeout = 2 * time.Minute
+
 const (
 const (
 	DialectSQLite   = "sqlite"
 	DialectSQLite   = "sqlite"
 	DialectPostgres = "postgres"
 	DialectPostgres = "postgres"
@@ -52,8 +57,9 @@ func Dialect() string {
 }
 }
 
 
 const (
 const (
-	defaultUsername = "admin"
-	defaultPassword = "admin"
+	defaultUsername       = "admin"
+	defaultPassword       = "admin"
+	sqliteBackupDirPrefix = ".x-ui-backup-"
 )
 )
 
 
 func allModels() []any {
 func allModels() []any {
@@ -1944,6 +1950,9 @@ func InitDB(dbPath string) error {
 		if err = os.MkdirAll(dir, 0o755); err != nil {
 		if err = os.MkdirAll(dir, 0o755); err != nil {
 			return err
 			return err
 		}
 		}
+		if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
+			log.Printf("clean SQLite backup directories: %v", err)
+		}
 
 
 		sync := sqliteSynchronous()
 		sync := sqliteSynchronous()
 		journal := sqliteJournalMode()
 		journal := sqliteJournalMode()
@@ -2045,6 +2054,31 @@ func sqliteJournalMode() string {
 	}
 	}
 }
 }
 
 
+func backupSQLiteStepPages() int {
+	if sqliteJournalMode() == "DELETE" {
+		return 128
+	}
+	return -1
+}
+
+func cleanupSQLiteBackupDirs(dir string) error {
+	entries, err := os.ReadDir(dir)
+	if errors.Is(err, os.ErrNotExist) {
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	for _, entry := range entries {
+		if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
+			if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func sqliteSynchronous() string {
 func sqliteSynchronous() string {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	case "OFF":
 	case "OFF":
@@ -2099,11 +2133,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
 	return bytes.Equal(buf, signature), nil
 	return bytes.Equal(buf, signature), nil
 }
 }
 
 
-func Checkpoint() error {
+func BackupSQLite(dstPath string) (err error) {
 	if IsPostgres() {
 	if IsPostgres() {
-		return nil
+		return errors.New("sqlite backup is unavailable for PostgreSQL")
+	}
+	if db == nil {
+		return errors.New("database is not initialized")
+	}
+	if _, err := os.Lstat(dstPath); err == nil {
+		return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
+	} else if !errors.Is(err, os.ErrNotExist) {
+		return err
 	}
 	}
-	return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
+	defer func() {
+		if err != nil {
+			_ = os.Remove(dstPath)
+		}
+	}()
+
+	ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
+	defer cancel()
+
+	sourceDB, err := db.DB()
+	if err != nil {
+		return err
+	}
+	sourceConn, err := sourceDB.Conn(ctx)
+	if err != nil {
+		return err
+	}
+	defer sourceConn.Close()
+
+	destinationDB, err := sql.Open("sqlite3", dstPath)
+	if err != nil {
+		return err
+	}
+	defer destinationDB.Close()
+	destinationConn, err := destinationDB.Conn(ctx)
+	if err != nil {
+		return err
+	}
+	defer destinationConn.Close()
+
+	return sourceConn.Raw(func(sourceDriver any) error {
+		source, ok := sourceDriver.(*sqlite3.SQLiteConn)
+		if !ok {
+			return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
+		}
+		return destinationConn.Raw(func(destinationDriver any) error {
+			destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
+			if !ok {
+				return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
+			}
+			backup, err := destination.Backup("main", source, "main")
+			if err != nil {
+				return err
+			}
+			finished := false
+			defer func() {
+				if !finished {
+					_ = backup.Finish()
+				}
+			}()
+			for {
+				done, err := backup.Step(backupSQLiteStepPages())
+				if err != nil {
+					return err
+				}
+				if done {
+					finished = true
+					return backup.Finish()
+				}
+				select {
+				case <-ctx.Done():
+					return ctx.Err()
+				case <-time.After(10 * time.Millisecond):
+				}
+			}
+		})
+	})
 }
 }
 
 
 func ValidateSQLiteDB(dbPath string) error {
 func ValidateSQLiteDB(dbPath string) error {

+ 0 - 41
internal/database/journal_mode_test.go

@@ -1,12 +1,8 @@
 package database
 package database
 
 
 import (
 import (
-	"bytes"
-	"os"
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
 func journalModeOf(t *testing.T) string {
 func journalModeOf(t *testing.T) string {
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
 		t.Fatalf("journal_mode = %q, want delete", got)
 		t.Fatalf("journal_mode = %q, want delete", got)
 	}
 	}
 }
 }
-
-func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
-	t.Setenv("XUI_DB_JOURNAL_MODE", "")
-	dbDir := t.TempDir()
-	dbPath := filepath.Join(dbDir, "x-ui.db")
-	if err := InitDB(dbPath); err != nil {
-		t.Fatalf("InitDB: %v", err)
-	}
-	t.Cleanup(func() { _ = CloseDB() })
-
-	if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
-		t.Fatalf("write setting: %v", err)
-	}
-	if err := Checkpoint(); err != nil {
-		t.Fatalf("Checkpoint: %v", err)
-	}
-
-	raw, err := os.ReadFile(dbPath)
-	if err != nil {
-		t.Fatalf("read db file: %v", err)
-	}
-	copyPath := filepath.Join(t.TempDir(), "copy.db")
-	if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
-		t.Fatalf("write copy: %v", err)
-	}
-	if err := ValidateSQLiteDB(copyPath); err != nil {
-		t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
-	}
-
-	dump, err := DumpSQLiteToBytes(copyPath)
-	if err != nil {
-		t.Fatalf("dump copy: %v", err)
-	}
-	if !bytes.Contains(dump, []byte("walBackupProbe")) {
-		t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
-	}
-}

+ 10 - 0
internal/mtproto/manager_reload_test.go

@@ -21,6 +21,16 @@ func TestMain(m *testing.M) {
 			fmt.Fprintf(f, "%d\n", os.Getpid())
 			fmt.Fprintf(f, "%d\n", os.Getpid())
 			f.Close()
 			f.Close()
 		}
 		}
+		if exitFile := os.Getenv("MTG_FAKE_EXIT_FILE"); exitFile != "" {
+			for {
+				if _, err := os.Stat(exitFile); err == nil {
+					os.Exit(1)
+				} else if !os.IsNotExist(err) {
+					os.Exit(2)
+				}
+				time.Sleep(time.Millisecond)
+			}
+		}
 		select {}
 		select {}
 	}
 	}
 	os.Exit(m.Run())
 	os.Exit(m.Run())

+ 46 - 25
internal/mtproto/process.go

@@ -109,6 +109,7 @@ func (w *procLogWriter) LastLine() string {
 
 
 // Process wraps a single mtg process invocation for one mtproto inbound.
 // Process wraps a single mtg process invocation for one mtproto inbound.
 type Process struct {
 type Process struct {
+	mu              sync.RWMutex
 	cmd             *exec.Cmd
 	cmd             *exec.Cmd
 	done            chan struct{}
 	done            chan struct{}
 	configPath      string
 	configPath      string
@@ -126,20 +127,20 @@ func newProcess(configPath, label string) *Process {
 
 
 // IsRunning reports whether the mtg process is currently running.
 // IsRunning reports whether the mtg process is currently running.
 func (p *Process) IsRunning() bool {
 func (p *Process) IsRunning() bool {
-	if p.cmd == nil || p.cmd.Process == nil {
+	p.mu.RLock()
+	cmd, done := p.cmd, p.done
+	p.mu.RUnlock()
+	if cmd == nil || cmd.Process == nil {
 		return false
 		return false
 	}
 	}
-	if p.done != nil {
+	if done != nil {
 		select {
 		select {
-		case <-p.done:
+		case <-done:
 			return false
 			return false
 		default:
 		default:
 		}
 		}
 	}
 	}
-	if p.cmd.ProcessState == nil {
-		return true
-	}
-	return false
+	return true
 }
 }
 
 
 // GetResult returns the last log line or the exit error from the mtg process.
 // GetResult returns the last log line or the exit error from the mtg process.
@@ -147,8 +148,11 @@ func (p *Process) GetResult() string {
 	if line := p.logWriter.LastLine(); line != "" {
 	if line := p.logWriter.LastLine(); line != "" {
 		return line
 		return line
 	}
 	}
-	if p.exitErr != nil {
-		return p.exitErr.Error()
+	p.mu.RLock()
+	exitErr := p.exitErr
+	p.mu.RUnlock()
+	if exitErr != nil {
+		return exitErr.Error()
 	}
 	}
 	return ""
 	return ""
 }
 }
@@ -161,22 +165,27 @@ func (p *Process) Start() error {
 	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
 	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
 	cmd.Stdout = p.logWriter
 	cmd.Stdout = p.logWriter
 	cmd.Stderr = p.logWriter
 	cmd.Stderr = p.logWriter
+	done := make(chan struct{})
+	p.mu.Lock()
 	p.cmd = cmd
 	p.cmd = cmd
-	p.done = make(chan struct{})
+	p.done = done
 	p.exitErr = nil
 	p.exitErr = nil
+	p.mu.Unlock()
 	p.intentionalStop.Store(false)
 	p.intentionalStop.Store(false)
 	if err := cmd.Start(); err != nil {
 	if err := cmd.Start(); err != nil {
-		close(p.done)
+		close(done)
+		p.mu.Lock()
 		p.cmd = nil
 		p.cmd = nil
+		p.mu.Unlock()
 		return err
 		return err
 	}
 	}
 	attachChildLifetime(cmd)
 	attachChildLifetime(cmd)
-	go p.wait(cmd)
+	go p.wait(cmd, done)
 	return nil
 	return nil
 }
 }
 
 
-func (p *Process) wait(cmd *exec.Cmd) {
-	defer close(p.done)
+func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
+	defer close(done)
 	err := cmd.Wait()
 	err := cmd.Wait()
 	p.logWriter.Flush()
 	p.logWriter.Flush()
 	if err == nil || p.intentionalStop.Load() {
 	if err == nil || p.intentionalStop.Load() {
@@ -184,12 +193,18 @@ func (p *Process) wait(cmd *exec.Cmd) {
 	}
 	}
 	if runtime.GOOS == "windows" {
 	if runtime.GOOS == "windows" {
 		if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
 		if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
-			p.exitErr = err
+			p.setExitErr(err)
 			return
 			return
 		}
 		}
 	}
 	}
 	logger.Errorf("mtproto: mtg process exited: %v", err)
 	logger.Errorf("mtproto: mtg process exited: %v", err)
+	p.setExitErr(err)
+}
+
+func (p *Process) setExitErr(err error) {
+	p.mu.Lock()
 	p.exitErr = err
 	p.exitErr = err
+	p.mu.Unlock()
 }
 }
 
 
 // Stop terminates the running mtg process gracefully, falling back to a kill.
 // Stop terminates the running mtg process gracefully, falling back to a kill.
@@ -198,40 +213,46 @@ func (p *Process) Stop() error {
 		return errors.New("mtg is not running")
 		return errors.New("mtg is not running")
 	}
 	}
 	p.intentionalStop.Store(true)
 	p.intentionalStop.Store(true)
+	p.mu.RLock()
+	cmd, done := p.cmd, p.done
+	p.mu.RUnlock()
+	if cmd == nil || cmd.Process == nil {
+		return errors.New("mtg is not running")
+	}
 
 
 	if runtime.GOOS == "windows" {
 	if runtime.GOOS == "windows" {
-		if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
 			return err
 			return err
 		}
 		}
-		return p.waitForExit(forceStopTimeout)
+		return waitForExit(done, forceStopTimeout)
 	}
 	}
 
 
-	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
+	if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
 		if errors.Is(err, os.ErrProcessDone) {
 		if errors.Is(err, os.ErrProcessDone) {
-			return p.waitForExit(forceStopTimeout)
+			return waitForExit(done, forceStopTimeout)
 		}
 		}
 		return err
 		return err
 	}
 	}
 
 
-	if err := p.waitForExit(gracefulStopTimeout); err == nil {
+	if err := waitForExit(done, gracefulStopTimeout); err == nil {
 		return nil
 		return nil
 	}
 	}
 
 
 	logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
 	logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
-	if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
 		return err
 		return err
 	}
 	}
-	return p.waitForExit(forceStopTimeout)
+	return waitForExit(done, forceStopTimeout)
 }
 }
 
 
-func (p *Process) waitForExit(timeout time.Duration) error {
-	if p.done == nil {
+func waitForExit(done <-chan struct{}, timeout time.Duration) error {
+	if done == nil {
 		return nil
 		return nil
 	}
 	}
 	timer := time.NewTimer(timeout)
 	timer := time.NewTimer(timeout)
 	defer timer.Stop()
 	defer timer.Stop()
 	select {
 	select {
-	case <-p.done:
+	case <-done:
 		return nil
 		return nil
 	case <-timer.C:
 	case <-timer.C:
 		return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
 		return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)

+ 106 - 0
internal/mtproto/process_race_test.go

@@ -0,0 +1,106 @@
+package mtproto
+
+import (
+	"errors"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
+	proc := newProcess("", "test")
+	stop := make(chan struct{})
+	var workers sync.WaitGroup
+	defer func() {
+		close(stop)
+		workers.Wait()
+	}()
+
+	workers.Go(func() {
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+			}
+			proc.mu.Lock()
+			proc.cmd = &exec.Cmd{}
+			proc.done = make(chan struct{})
+			proc.mu.Unlock()
+			proc.setExitErr(errors.New("exit"))
+		}
+	})
+	for range 4 {
+		workers.Go(func() {
+			for {
+				select {
+				case <-stop:
+					return
+				default:
+				}
+				_ = proc.IsRunning()
+				_ = proc.GetResult()
+			}
+		})
+	}
+
+	time.Sleep(50 * time.Millisecond)
+}
+
+func TestProcessStatusDuringExit(t *testing.T) {
+	pidFile := installFakeMtg(t)
+	exitFile := filepath.Join(t.TempDir(), "exit")
+	t.Setenv("MTG_FAKE_EXIT_FILE", exitFile)
+	configPath := filepath.Join(t.TempDir(), "mtg.toml")
+	if err := os.WriteFile(configPath, nil, 0o600); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+	proc := newProcess(configPath, "test")
+	if err := proc.Start(); err != nil {
+		t.Fatalf("start process: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = proc.Stop()
+	})
+	waitSpawnCount(t, pidFile, 1)
+
+	stopReads := make(chan struct{})
+	var readers sync.WaitGroup
+	defer func() {
+		close(stopReads)
+		readers.Wait()
+	}()
+	for range 4 {
+		readers.Go(func() {
+			for {
+				select {
+				case <-stopReads:
+					return
+				default:
+					_ = proc.IsRunning()
+					_ = proc.GetResult()
+				}
+			}
+		})
+	}
+
+	proc.mu.RLock()
+	done := proc.done
+	proc.mu.RUnlock()
+	if err := os.WriteFile(exitFile, nil, 0o600); err != nil {
+		t.Fatalf("trigger exit: %v", err)
+	}
+	if err := waitForExit(done, time.Second); err != nil {
+		t.Fatalf("wait for process exit: %v", err)
+	}
+	if proc.IsRunning() {
+		t.Fatal("process must not be running after exit")
+	}
+	if got := proc.GetResult(); !strings.Contains(got, "exit status 1") {
+		t.Fatalf("GetResult after an unexpected exit = %q, want exit status", got)
+	}
+}

+ 54 - 9
internal/sub/external_subscription.go

@@ -16,8 +16,9 @@ import (
 // with a short timeout so a slow/dead provider can't stall a client's sub.
 // with a short timeout so a slow/dead provider can't stall a client's sub.
 
 
 const (
 const (
-	subscriptionCacheTTL = 5 * time.Minute
-	subscriptionMaxBytes = 2 << 20 // 2 MiB
+	subscriptionCacheTTL      = 5 * time.Minute
+	subscriptionMaxBytes      = 2 << 20 // 2 MiB
+	subscriptionCacheCapacity = 256
 )
 )
 
 
 var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
 var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
 	fetchedAt time.Time
 	fetchedAt time.Time
 }
 }
 
 
+type subscriptionFetch struct {
+	done  chan struct{}
+	links []string
+}
+
 var subscriptionCache = struct {
 var subscriptionCache = struct {
 	sync.Mutex
 	sync.Mutex
-	m map[string]subscriptionCacheEntry
-}{m: make(map[string]subscriptionCacheEntry)}
+	m        map[string]subscriptionCacheEntry
+	inflight map[string]*subscriptionFetch
+}{
+	m:        make(map[string]subscriptionCacheEntry),
+	inflight: make(map[string]*subscriptionFetch),
+}
 
 
 // fetchSubscriptionLinks returns the share links contained in a remote
 // fetchSubscriptionLinks returns the share links contained in a remote
 // subscription URL, using a short-lived cache. On any failure it returns the
 // subscription URL, using a short-lived cache. On any failure it returns the
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
 
 
 	subscriptionCache.Lock()
 	subscriptionCache.Lock()
 	cached, ok := subscriptionCache.m[rawURL]
 	cached, ok := subscriptionCache.m[rawURL]
-	subscriptionCache.Unlock()
 	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
 	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
+		subscriptionCache.Unlock()
 		return cached.links
 		return cached.links
 	}
 	}
+	if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
+		subscriptionCache.Unlock()
+		<-fetch.done
+		return fetch.links
+	}
+	fetch := &subscriptionFetch{done: make(chan struct{})}
+	subscriptionCache.inflight[rawURL] = fetch
+	subscriptionCache.Unlock()
+	defer func() {
+		subscriptionCache.Lock()
+		close(fetch.done)
+		delete(subscriptionCache.inflight, rawURL)
+		subscriptionCache.Unlock()
+	}()
 
 
 	links, err := doFetchSubscriptionLinks(rawURL)
 	links, err := doFetchSubscriptionLinks(rawURL)
 	if err != nil {
 	if err != nil {
-		// Serve stale on error rather than dropping the client's configs.
 		if ok {
 		if ok {
-			return cached.links
+			fetch.links = cached.links
 		}
 		}
-		return nil
+		return fetch.links
 	}
 	}
 
 
 	subscriptionCache.Lock()
 	subscriptionCache.Lock()
 	subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
 	subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
+	trimSubscriptionCacheLocked(rawURL)
 	subscriptionCache.Unlock()
 	subscriptionCache.Unlock()
-	return links
+	fetch.links = links
+	return fetch.links
+}
+
+func trimSubscriptionCacheLocked(keep string) {
+	for len(subscriptionCache.m) > subscriptionCacheCapacity {
+		var oldestURL string
+		var oldest time.Time
+		for rawURL, entry := range subscriptionCache.m {
+			if rawURL == keep {
+				continue
+			}
+			if oldestURL == "" || entry.fetchedAt.Before(oldest) {
+				oldestURL = rawURL
+				oldest = entry.fetchedAt
+			}
+		}
+		if oldestURL == "" {
+			return
+		}
+		delete(subscriptionCache.m, oldestURL)
+	}
 }
 }
 
 
 func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 func doFetchSubscriptionLinks(rawURL string) ([]string, error) {

+ 140 - 0
internal/sub/external_subscription_test.go

@@ -4,10 +4,150 @@ import (
 	"errors"
 	"errors"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
+	"strconv"
 	"strings"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"testing"
 	"testing"
+	"time"
 )
 )
 
 
+func resetSubscriptionCache(t *testing.T) {
+	t.Helper()
+	subscriptionCache.Lock()
+	previousEntries := subscriptionCache.m
+	previousInflight := subscriptionCache.inflight
+	subscriptionCache.m = make(map[string]subscriptionCacheEntry)
+	subscriptionCache.inflight = make(map[string]*subscriptionFetch)
+	subscriptionCache.Unlock()
+	t.Cleanup(func() {
+		subscriptionCache.Lock()
+		subscriptionCache.m = previousEntries
+		subscriptionCache.inflight = previousInflight
+		subscriptionCache.Unlock()
+	})
+}
+
+func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
+	resetSubscriptionCache(t)
+	var requests atomic.Int32
+	release := make(chan struct{})
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requests.Add(1)
+		<-release
+		_, _ = w.Write([]byte("vless://[email protected]:443"))
+	}))
+	defer srv.Close()
+
+	const callers = 16
+	results := make(chan []string, callers)
+	var wg sync.WaitGroup
+	for range callers {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			results <- fetchSubscriptionLinks(srv.URL)
+		}()
+	}
+
+	time.Sleep(100 * time.Millisecond)
+	close(release)
+	wg.Wait()
+	close(results)
+
+	for links := range results {
+		if len(links) != 1 || links[0] != "vless://[email protected]:443" {
+			t.Fatalf("links = %#v", links)
+		}
+	}
+	if got := requests.Load(); got != 1 {
+		t.Fatalf("requests = %d, want 1", got)
+	}
+}
+
+func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
+	resetSubscriptionCache(t)
+	var requests atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requests.Add(1)
+		_, _ = w.Write([]byte("vless://[email protected]:443"))
+	}))
+	defer srv.Close()
+
+	for i := range subscriptionCacheCapacity + 1 {
+		links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
+		if len(links) != 1 {
+			t.Fatalf("links at %d = %#v", i, links)
+		}
+	}
+
+	subscriptionCache.Lock()
+	entries := len(subscriptionCache.m)
+	subscriptionCache.Unlock()
+	if entries != subscriptionCacheCapacity {
+		t.Fatalf("cache entries = %d, want %d", entries, subscriptionCacheCapacity)
+	}
+	if got := requests.Load(); got != subscriptionCacheCapacity+1 {
+		t.Fatalf("requests = %d, want %d", got, subscriptionCacheCapacity+1)
+	}
+}
+
+func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
+	resetSubscriptionCache(t)
+	stale := []string{"vless://[email protected]:443"}
+	release := make(chan struct{})
+	var staleRequests atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/stale" {
+			staleRequests.Add(1)
+			<-release
+			w.WriteHeader(http.StatusBadGateway)
+			return
+		}
+		_, _ = w.Write([]byte("vless://[email protected]:443"))
+	}))
+	defer srv.Close()
+	staleURL := srv.URL + "/stale"
+
+	subscriptionCache.Lock()
+	subscriptionCache.m[staleURL] = subscriptionCacheEntry{
+		links:     stale,
+		fetchedAt: time.Now().Add(-subscriptionCacheTTL),
+	}
+	for i := range subscriptionCacheCapacity - 1 {
+		subscriptionCache.m["cached-"+strconv.Itoa(i)] = subscriptionCacheEntry{fetchedAt: time.Now()}
+	}
+	subscriptionCache.Unlock()
+
+	const callers = 16
+	results := make(chan []string, callers)
+	var wg sync.WaitGroup
+	for range callers {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			results <- fetchSubscriptionLinks(staleURL)
+		}()
+	}
+
+	time.Sleep(100 * time.Millisecond)
+	if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://[email protected]:443" {
+		t.Fatalf("fresh links = %#v", links)
+	}
+	close(release)
+	wg.Wait()
+	close(results)
+
+	for links := range results {
+		if len(links) != 1 || links[0] != stale[0] {
+			t.Fatalf("links = %#v, want %#v", links, stale)
+		}
+	}
+	if got := staleRequests.Load(); got != 1 {
+		t.Fatalf("requests = %d, want 1", got)
+	}
+}
+
 func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
 func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
 		_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))

+ 92 - 0
internal/sub/forwarded_trust.go

@@ -0,0 +1,92 @@
+package sub
+
+import (
+	"net"
+	"net/netip"
+	"strings"
+	"sync"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+var warnSuppressedForwardedOnce sync.Once
+
+var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"}
+
+func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) {
+	if !hasForwardedHeaders(c) {
+		return true
+	}
+
+	trusted = true
+	defer func() {
+		_ = recover()
+	}()
+
+	configured, err := s.settingService.GetTrustedProxyCIDRs()
+	if err != nil {
+		return true
+	}
+	configured = strings.TrimSpace(configured)
+	if configured == "" || configured == service.DefaultTrustedProxyCIDRs {
+		return true
+	}
+	return remoteAddrInCIDRs(c.Request.RemoteAddr, configured)
+}
+
+func hasForwardedHeaders(c *gin.Context) bool {
+	for _, name := range forwardedHeaderNames {
+		if c.GetHeader(name) != "" {
+			return true
+		}
+	}
+	return false
+}
+
+func warnSuppressedForwardedHeaders(c *gin.Context) {
+	present := make([]string, 0, 3)
+	for _, name := range forwardedHeaderNames {
+		if c.GetHeader(name) != "" {
+			present = append(present, name)
+		}
+	}
+	if len(present) == 0 {
+		return
+	}
+	headers := strings.Join(present, ", ")
+	logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr)
+	warnSuppressedForwardedOnce.Do(func() {
+		logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr)
+	})
+}
+
+func remoteAddrInCIDRs(remoteAddr, cidrs string) bool {
+	host := remoteAddr
+	if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
+		host = h
+	}
+	addr, err := netip.ParseAddr(strings.TrimSpace(host))
+	if err != nil {
+		return false
+	}
+	addr = addr.Unmap()
+	for value := range strings.SplitSeq(cidrs, ",") {
+		value = strings.TrimSpace(value)
+		if value == "" {
+			continue
+		}
+		if prefix, err := netip.ParsePrefix(value); err == nil {
+			if prefix.Contains(addr) {
+				return true
+			}
+			continue
+		}
+		if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
+			return true
+		}
+	}
+	return false
+}

+ 205 - 0
internal/sub/forwarded_trust_test.go

@@ -0,0 +1,205 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
+	req.Host = "panel.example.com:2096"
+	req.RemoteAddr = remoteAddr
+	for k, v := range headers {
+		req.Header.Set(k, v)
+	}
+	c, _ := gin.CreateTestContext(httptest.NewRecorder())
+	c.Request = req
+	return c
+}
+
+func setTrustedProxyCIDRs(t *testing.T, value string) {
+	t.Helper()
+	if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil {
+		t.Fatalf("set trustedProxyCIDRs: %v", err)
+	}
+	settingService := service.SettingService{}
+	stored, err := settingService.GetTrustedProxyCIDRs()
+	if err != nil {
+		t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err)
+	}
+	if stored != value {
+		t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value)
+	}
+}
+
+func storedAs(value string) *string {
+	return &value
+}
+
+func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
+	tests := []struct {
+		name             string
+		stored           *string
+		remoteAddr       string
+		wantScheme       string
+		wantHost         string
+		wantHostWithPort string
+		wantHostHeader   string
+	}{
+		{
+			name:             "no stored row keeps trusting forwarded headers",
+			stored:           nil,
+			remoteAddr:       "203.0.113.9:51000",
+			wantScheme:       "https",
+			wantHost:         "sub.example.net",
+			wantHostWithPort: "sub.example.net",
+			wantHostHeader:   "sub.example.net",
+		},
+		{
+			name:             "empty stored value keeps trusting forwarded headers",
+			stored:           storedAs(""),
+			remoteAddr:       "203.0.113.9:51000",
+			wantScheme:       "https",
+			wantHost:         "sub.example.net",
+			wantHostWithPort: "sub.example.net",
+			wantHostHeader:   "sub.example.net",
+		},
+		{
+			name:             "stored shipped default keeps trusting forwarded headers",
+			stored:           storedAs(service.DefaultTrustedProxyCIDRs),
+			remoteAddr:       "203.0.113.9:51000",
+			wantScheme:       "https",
+			wantHost:         "sub.example.net",
+			wantHostWithPort: "sub.example.net",
+			wantHostHeader:   "sub.example.net",
+		},
+		{
+			name:             "declared boundary ignores an origin outside it",
+			stored:           storedAs("10.0.0.0/8"),
+			remoteAddr:       "203.0.113.9:51000",
+			wantScheme:       "http",
+			wantHost:         "panel.example.com",
+			wantHostWithPort: "panel.example.com:2096",
+			wantHostHeader:   "panel.example.com",
+		},
+		{
+			name:             "declared boundary trusts an origin inside it",
+			stored:           storedAs("10.0.0.0/8"),
+			remoteAddr:       "10.1.2.3:44000",
+			wantScheme:       "https",
+			wantHost:         "sub.example.net",
+			wantHostWithPort: "sub.example.net",
+			wantHostHeader:   "sub.example.net",
+		},
+		{
+			name:             "declared boundary ignores an unparsable origin",
+			stored:           storedAs("10.0.0.0/8"),
+			remoteAddr:       "not-an-ip",
+			wantScheme:       "http",
+			wantHost:         "panel.example.com",
+			wantHostWithPort: "panel.example.com:2096",
+			wantHostHeader:   "panel.example.com",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			initSubDB(t)
+			if tc.stored != nil {
+				setTrustedProxyCIDRs(t, *tc.stored)
+			}
+			s := &SubService{}
+
+			c := requestFrom(t, tc.remoteAddr, map[string]string{
+				"X-Forwarded-Host":  "sub.example.net",
+				"X-Forwarded-Proto": "https",
+			})
+			scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c)
+
+			if scheme != tc.wantScheme {
+				t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme)
+			}
+			if host != tc.wantHost {
+				t.Errorf("host = %q, want %q", host, tc.wantHost)
+			}
+			if hostWithPort != tc.wantHostWithPort {
+				t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort)
+			}
+			if hostHeader != tc.wantHostHeader {
+				t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader)
+			}
+		})
+	}
+}
+
+func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
+	initSubDB(t)
+	setTrustedProxyCIDRs(t, "10.0.0.0/8")
+	s := &SubService{}
+
+	c := requestFrom(t, "203.0.113.9:51000", map[string]string{
+		"X-Real-IP": "198.51.100.7",
+	})
+	_, host, _, hostHeader := s.ResolveRequest(c)
+
+	if host != "panel.example.com" {
+		t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host)
+	}
+	if hostHeader != "panel.example.com" {
+		t.Errorf("hostHeader = %q, want the request host", hostHeader)
+	}
+}
+
+func TestHasForwardedHeaders(t *testing.T) {
+	tests := []struct {
+		name    string
+		headers map[string]string
+		want    bool
+	}{
+		{name: "no forwarded headers", want: false},
+		{name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true},
+		{name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true},
+		{name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want {
+				t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestRemoteAddrInCIDRs(t *testing.T) {
+	tests := []struct {
+		name       string
+		remoteAddr string
+		cidrs      string
+		want       bool
+	}{
+		{name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true},
+		{name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true},
+		{name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false},
+		{name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true},
+		{name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true},
+		{name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true},
+		{name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false},
+		{name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want {
+				t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want)
+			}
+		})
+	}
+}

+ 17 - 6
internal/sub/service.go

@@ -2486,18 +2486,29 @@ type PageData struct {
 // ResolveRequest extracts scheme and host info from request/headers consistently.
 // ResolveRequest extracts scheme and host info from request/headers consistently.
 // ResolveRequest extracts scheme, host, and header information from an HTTP request.
 // ResolveRequest extracts scheme, host, and header information from an HTTP request.
 func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
 func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
+	trusted := s.forwardedHeadersTrusted(c)
+	if !trusted {
+		warnSuppressedForwardedHeaders(c)
+	}
+	forwarded := func(name string) string {
+		if !trusted {
+			return ""
+		}
+		return c.GetHeader(name)
+	}
+
 	// scheme
 	// scheme
 	scheme = "http"
 	scheme = "http"
-	if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+	if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") {
 		scheme = "https"
 		scheme = "https"
 	}
 	}
 
 
 	// base host (no port)
 	// base host (no port)
-	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
+	if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
 		host = h
 		host = h
 	}
 	}
 	if host == "" {
 	if host == "" {
-		host = c.GetHeader("X-Real-IP")
+		host = forwarded("X-Real-IP")
 	}
 	}
 	if host == "" {
 	if host == "" {
 		var err error
 		var err error
@@ -2508,7 +2519,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 	}
 	}
 
 
 	// host:port for URLs
 	// host:port for URLs
-	hostWithPort = c.GetHeader("X-Forwarded-Host")
+	hostWithPort = forwarded("X-Forwarded-Host")
 	if hostWithPort == "" {
 	if hostWithPort == "" {
 		hostWithPort = c.Request.Host
 		hostWithPort = c.Request.Host
 	}
 	}
@@ -2517,9 +2528,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 	}
 	}
 
 
 	// header display host
 	// header display host
-	hostHeader = c.GetHeader("X-Forwarded-Host")
+	hostHeader = forwarded("X-Forwarded-Host")
 	if hostHeader == "" {
 	if hostHeader == "" {
-		hostHeader = c.GetHeader("X-Real-IP")
+		hostHeader = forwarded("X-Real-IP")
 	}
 	}
 	if hostHeader == "" {
 	if hostHeader == "" {
 		hostHeader = host
 		hostHeader = host

+ 1 - 1
internal/web/controller/util.go

@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
 }
 }
 
 
 func trustedProxyCIDRs() (trusted string) {
 func trustedProxyCIDRs() (trusted string) {
-	trusted = "127.0.0.1/32,::1/128"
+	trusted = service.DefaultTrustedProxyCIDRs
 	defer func() {
 	defer func() {
 		_ = recover()
 		_ = recover()
 	}()
 	}()

+ 4 - 4
internal/web/service/inbound_disable.go

@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 	now := time.Now().Unix() * 1000
 	now := time.Now().Unix() * 1000
 	needRestart := false
 	needRestart := false
 
 
-	if p != nil {
+	if process := currentXrayProcess(); process != nil {
 		var tags []string
 		var tags []string
 		err := tx.Table("inbounds").
 		err := tx.Table("inbounds").
 			Select("inbounds.tag").
 			Select("inbounds.tag").
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 		if err != nil {
 		if err != nil {
 			return false, 0, err
 			return false, 0, err
 		}
 		}
-		_ = s.xrayApi.Init(p.GetAPIPort())
+		_ = s.xrayApi.Init(process.GetAPIPort())
 		for _, tag := range tags {
 		for _, tag := range tags {
 			err1 := s.xrayApi.DelInbound(tag)
 			err1 := s.xrayApi.DelInbound(tag)
 			if err1 == nil {
 			if err1 == nil {
@@ -153,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 		}
 		}
 	}
 	}
 
 
-	if p != nil && len(localTargets) > 0 {
-		_ = s.xrayApi.Init(p.GetAPIPort())
+	if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
+		_ = s.xrayApi.Init(process.GetAPIPort())
 		for _, t := range localTargets {
 		for _, t := range localTargets {
 			err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
 			err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
 			if err1 == nil {
 			if err1 == nil {

+ 18 - 15
internal/web/service/inbound_node.go

@@ -1036,7 +1036,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 		}
 	}
 	}
 
 
-	if p != nil {
+	if process := currentXrayProcess(); process != nil {
 		tree := snap.OnlineTree
 		tree := snap.OnlineTree
 		switch {
 		switch {
 		case len(tree) == 0 && len(snap.OnlineEmails) > 0:
 		case len(tree) == 0 && len(snap.OnlineEmails) > 0:
@@ -1058,17 +1058,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				tree = remapped
 				tree = remapped
 			}
 			}
 		}
 		}
-		p.SetNodeOnlineTree(nodeID, tree)
+		process.SetNodeOnlineTree(nodeID, tree)
 	}
 	}
 
 
 	return structuralChange, nil
 	return structuralChange, nil
 }
 }
 
 
 func (s *InboundService) GetOnlineClients() []string {
 func (s *InboundService) GetOnlineClients() []string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return []string{}
 		return []string{}
 	}
 	}
-	return p.GetOnlineClients()
+	return process.GetOnlineClients()
 }
 }
 
 
 // GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
 // GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
@@ -1077,11 +1078,12 @@ func (s *InboundService) GetOnlineClients() []string {
 // node-id keying so a client three hops down is attributed to its real node,
 // node-id keying so a client three hops down is attributed to its real node,
 // not the intermediate one it was synced through.
 // not the intermediate one it was synced through.
 func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
 func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return map[string][]string{}
 		return map[string][]string{}
 	}
 	}
-	out := p.GetMergedNodeTrees()
-	if local := p.GetLocalOnlineClients(); len(local) > 0 {
+	out := process.GetMergedNodeTrees()
+	if local := process.GetLocalOnlineClients(); len(local) > 0 {
 		if guid := s.panelGuid(); guid != "" {
 		if guid := s.panelGuid(); guid != "" {
 			out[guid] = mergeEmails(out[guid], local)
 			out[guid] = mergeEmails(out[guid], local)
 		}
 		}
@@ -1094,10 +1096,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
 // report per-inbound activity, so a GUID missing from the map means "don't
 // report per-inbound activity, so a GUID missing from the map means "don't
 // gate" for that node's inbounds.
 // gate" for that node's inbounds.
 func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
 func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return map[string][]string{}
 		return map[string][]string{}
 	}
 	}
-	active := p.GetLocalActiveInbounds()
+	active := process.GetLocalActiveInbounds()
 	if len(active) == 0 {
 	if len(active) == 0 {
 		return map[string][]string{}
 		return map[string][]string{}
 	}
 	}
@@ -1109,14 +1112,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
 }
 }
 
 
 func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
 func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
-	if p != nil {
-		p.SetNodeOnlineTree(nodeID, tree)
+	if process := currentXrayProcess(); process != nil {
+		process.SetNodeOnlineTree(nodeID, tree)
 	}
 	}
 }
 }
 
 
 func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
 func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
-	if p != nil {
-		p.ClearNodeOnlineClients(nodeID)
+	if process := currentXrayProcess(); process != nil {
+		process.ClearNodeOnlineClients(nodeID)
 	}
 	}
 }
 }
 
 
@@ -1177,8 +1180,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 // xray.Process for why the local sets are kept separate from the shared
 // xray.Process for why the local sets are kept separate from the shared
 // last_online column.
 // last_online column.
 func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
 func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
-	if p != nil {
-		p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
+	if process := currentXrayProcess(); process != nil {
+		process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
 	}
 	}
 }
 }
 
 

+ 2 - 2
internal/web/service/inbound_traffic.go

@@ -452,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
 	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
 		return false, 0, err
 		return false, 0, err
 	}
 	}
-	if p != nil {
-		err1 = s.xrayApi.Init(p.GetAPIPort())
+	if process := currentXrayProcess(); process != nil {
+		err1 = s.xrayApi.Init(process.GetAPIPort())
 		if err1 != nil {
 		if err1 != nil {
 			return true, int64(len(traffics)), nil
 			return true, int64(len(traffics)), nil
 		}
 		}

+ 2 - 2
internal/web/service/outbound_subscription.go

@@ -30,8 +30,8 @@ import (
 // CheckXrayConfig's version gate.
 // CheckXrayConfig's version gate.
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
 	coreVersion := "Unknown"
 	coreVersion := "Unknown"
-	if p != nil {
-		coreVersion = p.GetXrayVersion()
+	if process := currentXrayProcess(); process != nil {
+		coreVersion = process.GetXrayVersion()
 	}
 	}
 	kept := make([]any, 0, len(outbounds))
 	kept := make([]any, 0, len(outbounds))
 	var dropped []string
 	var dropped []string

+ 22 - 20
internal/web/service/server.go

@@ -624,8 +624,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		status.AppStats.Mem = rtm.Sys
 		status.AppStats.Mem = rtm.Sys
 	}
 	}
 	status.AppStats.Threads = uint32(runtime.NumGoroutine())
 	status.AppStats.Threads = uint32(runtime.NumGoroutine())
-	if p != nil && p.IsRunning() {
-		status.AppStats.Uptime = p.GetUptime()
+	if process := currentXrayProcess(); process != nil && process.IsRunning() {
+		status.AppStats.Uptime = process.GetUptime()
 	} else {
 	} else {
 		status.AppStats.Uptime = 0
 		status.AppStats.Uptime = 0
 	}
 	}
@@ -668,8 +668,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
 	systemMetrics.append("tcpCount", t, float64(status.TcpCount))
 	systemMetrics.append("tcpCount", t, float64(status.TcpCount))
 	systemMetrics.append("udpCount", t, float64(status.UdpCount))
 	systemMetrics.append("udpCount", t, float64(status.UdpCount))
 	online := 0
 	online := 0
-	if p != nil && p.IsRunning() {
-		online = len(p.GetOnlineClients())
+	if process := currentXrayProcess(); process != nil && process.IsRunning() {
+		online = len(process.GetOnlineClients())
 	}
 	}
 	systemMetrics.append("online", t, float64(online))
 	systemMetrics.append("online", t, float64(online))
 	if len(status.Loads) >= 3 {
 	if len(status.Loads) >= 3 {
@@ -1303,25 +1303,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
 	if database.IsPostgres() {
 	if database.IsPostgres() {
 		return s.exportPostgresDB()
 		return s.exportPostgresDB()
 	}
 	}
-	// Update by manually trigger a checkpoint operation
-	err := database.Checkpoint()
+	backupPath, cleanup, err := s.backupSQLite()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	// Open the file for reading
-	file, err := os.Open(config.GetDBPath())
-	if err != nil {
-		return nil, err
-	}
-	defer file.Close()
+	defer cleanup()
+	return os.ReadFile(backupPath)
+}
 
 
-	// Read the file contents
-	fileContents, err := io.ReadAll(file)
+func (s *ServerService) backupSQLite() (string, func(), error) {
+	backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return "", nil, err
 	}
 	}
-
-	return fileContents, nil
+	cleanup := func() { _ = os.RemoveAll(backupDir) }
+	backupPath := filepath.Join(backupDir, "backup.db")
+	if err := database.BackupSQLite(backupPath); err != nil {
+		cleanup()
+		return "", nil, err
+	}
+	return backupPath, cleanup, nil
 }
 }
 
 
 // BackupFilename returns the filename for a database backup, named after the
 // BackupFilename returns the filename for a database backup, named after the
@@ -1421,11 +1422,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
 		return data, "x-ui.db", nil
 		return data, "x-ui.db", nil
 	}
 	}
 
 
-	// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
-	if err := database.Checkpoint(); err != nil {
+	backupPath, cleanup, err := s.backupSQLite()
+	if err != nil {
 		return nil, "", err
 		return nil, "", err
 	}
 	}
-	data, err := database.DumpSQLiteToBytes(config.GetDBPath())
+	defer cleanup()
+	data, err := database.DumpSQLiteToBytes(backupPath)
 	if err != nil {
 	if err != nil {
 		return nil, "", err
 		return nil, "", err
 	}
 	}

+ 2 - 1
internal/web/service/setting.go

@@ -37,6 +37,7 @@ const (
 	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
 	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
 	DefaultSubJsonUserAgentRegex  = ``
 	DefaultSubJsonUserAgentRegex  = ``
 	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
 	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	DefaultTrustedProxyCIDRs      = "127.0.0.1/32,::1/128"
 	maxRegexLength                = 2048
 	maxRegexLength                = 2048
 )
 )
 
 
@@ -61,7 +62,7 @@ var defaultValueMap = map[string]string{
 	"nodeMtlsClientCAPem":         "",
 	"nodeMtlsClientCAPem":         "",
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"sessionMaxAge":               "360",
-	"trustedProxyCIDRs":           "127.0.0.1/32,::1/128",
+	"trustedProxyCIDRs":           DefaultTrustedProxyCIDRs,
 	"pageSize":                    "25",
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
 	"trafficDiff":                 "0",

+ 2 - 2
internal/web/service/tgbot/tgbot_client.go

@@ -494,8 +494,8 @@ func (t *Tgbot) clientInfoMsg(
 
 
 	status := t.I18nBot("tgbot.offline")
 	status := t.I18nBot("tgbot.offline")
 	isOnline := false
 	isOnline := false
-	if service.XrayProcess().IsRunning() {
-		if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) {
+	if process := service.XrayProcess(); process != nil && process.IsRunning() {
+		if slices.Contains(process.GetOnlineClients(), traffic.Email) {
 			status = t.I18nBot("tgbot.online")
 			status = t.I18nBot("tgbot.online")
 			isOnline = true
 			isOnline = true
 		}
 		}

+ 23 - 10
internal/web/service/tgbot/tgbot_report.go

@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
 	if !t.IsRunning() {
 	if !t.IsRunning() {
 		return
 		return
 	}
 	}
+	dbData, err := t.serverService.GetDb()
+	if err != nil {
+		logger.Error("Error in getting db backup: ", err)
+	}
+	dbFilename := t.serverService.BackupFilename("")
 	for i, adminId := range adminIds {
 	for i, adminId := range adminIds {
-		t.sendBackup(adminId)
+		t.sendBackupData(adminId, dbData, dbFilename)
 		// Add delay between sends to avoid Telegram rate limits
 		// Add delay between sends to avoid Telegram rate limits
 		if i < len(adminIds)-1 {
 		if i < len(adminIds)-1 {
 			time.Sleep(1 * time.Second)
 			time.Sleep(1 * time.Second)
@@ -105,7 +110,10 @@ func (t *Tgbot) prepareServerUsageInfo() string {
 		t.lastStatus = t.serverService.GetStatus(t.lastStatus)
 		t.lastStatus = t.serverService.GetStatus(t.lastStatus)
 		t.setCachedStatus(t.lastStatus)
 		t.setCachedStatus(t.lastStatus)
 	}
 	}
-	onlines := service.XrayProcess().GetOnlineClients()
+	var onlines []string
+	if process := service.XrayProcess(); process != nil {
+		onlines = process.GetOnlineClients()
+	}
 
 
 	info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
 	info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
 	info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
 	info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
@@ -361,11 +369,12 @@ func (t *Tgbot) notifyExhausted() {
 
 
 // onlineClients retrieves and sends information about online clients.
 // onlineClients retrieves and sends information about online clients.
 func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
 func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
-	if !service.XrayProcess().IsRunning() {
+	process := service.XrayProcess()
+	if process == nil || !process.IsRunning() {
 		return
 		return
 	}
 	}
 
 
-	onlines := service.XrayProcess().GetOnlineClients()
+	onlines := process.GetOnlineClients()
 	onlinesCount := len(onlines)
 	onlinesCount := len(onlines)
 	output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
 	output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
 	keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
 	keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
@@ -400,26 +409,30 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
 
 
 // sendBackup sends a backup of the database and configuration files.
 // sendBackup sends a backup of the database and configuration files.
 func (t *Tgbot) sendBackup(chatId int64) {
 func (t *Tgbot) sendBackup(chatId int64) {
+	dbData, err := t.serverService.GetDb()
+	if err != nil {
+		logger.Error("Error in getting db backup: ", err)
+	}
+	t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
+}
+
+func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
 	output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
 	output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
 	output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
 	output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
 	t.SendMsgToTgbot(chatId, output)
 	t.SendMsgToTgbot(chatId, output)
 
 
 	// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
 	// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
-	dbData, err := t.serverService.GetDb()
-	if err == nil {
-		dbFilename := t.serverService.BackupFilename("")
+	if dbData != nil {
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		document := tu.Document(
 		document := tu.Document(
 			tu.ID(chatId),
 			tu.ID(chatId),
 			tu.FileFromBytes(dbData, dbFilename),
 			tu.FileFromBytes(dbData, dbFilename),
 		)
 		)
-		_, err = bot.SendDocument(ctx, document)
+		_, err := bot.SendDocument(ctx, document)
 		cancel()
 		cancel()
 		if err != nil {
 		if err != nil {
 			logger.Error("Error in uploading backup: ", err)
 			logger.Error("Error in uploading backup: ", err)
 		}
 		}
-	} else {
-		logger.Error("Error in getting db backup: ", err)
 	}
 	}
 
 
 	// Small delay between file sends
 	// Small delay between file sends

+ 90 - 50
internal/web/service/xray.go

@@ -20,13 +20,44 @@ import (
 )
 )
 
 
 var (
 var (
-	p                 *xray.Process
 	lock              sync.Mutex
 	lock              sync.Mutex
 	isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
 	isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
 	isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
 	isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
-	result            string
+	xrayState         xrayLifecycle
 )
 )
 
 
+type xrayLifecycle struct {
+	mu      sync.RWMutex
+	process *xray.Process
+	result  string
+}
+
+func (s *xrayLifecycle) snapshot() (*xray.Process, string) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.process, s.result
+}
+
+func (s *xrayLifecycle) replace(process *xray.Process) {
+	s.mu.Lock()
+	s.process = process
+	s.result = ""
+	s.mu.Unlock()
+}
+
+func (s *xrayLifecycle) storeResult(process *xray.Process, result string) {
+	s.mu.Lock()
+	if s.process == process && s.result == "" {
+		s.result = result
+	}
+	s.mu.Unlock()
+}
+
+func currentXrayProcess() *xray.Process {
+	process, _ := xrayState.snapshot()
+	return process
+}
+
 // XrayService provides business logic for Xray process management.
 // XrayService provides business logic for Xray process management.
 // It handles starting, stopping, restarting Xray, and managing its configuration.
 // It handles starting, stopping, restarting Xray, and managing its configuration.
 type XrayService struct {
 type XrayService struct {
@@ -38,23 +69,25 @@ type XrayService struct {
 
 
 // IsXrayRunning checks if the Xray process is currently running.
 // IsXrayRunning checks if the Xray process is currently running.
 func (s *XrayService) IsXrayRunning() bool {
 func (s *XrayService) IsXrayRunning() bool {
-	return p != nil && p.IsRunning()
+	process := currentXrayProcess()
+	return process != nil && process.IsRunning()
 }
 }
 
 
 // XrayProcess returns the current Xray process instance (may be nil when Xray
 // XrayProcess returns the current Xray process instance (may be nil when Xray
-// is not running). It exposes the package-level process to callers outside this
-// package (e.g. the tgbot subpackage) without changing access semantics.
+// is not running). It exposes the lifecycle snapshot to callers outside this
+// package (e.g. the tgbot subpackage).
 func XrayProcess() *xray.Process {
 func XrayProcess() *xray.Process {
-	return p
+	return currentXrayProcess()
 }
 }
 
 
 // GetXrayErr returns the error from the Xray process, if any.
 // GetXrayErr returns the error from the Xray process, if any.
 func (s *XrayService) GetXrayErr() error {
 func (s *XrayService) GetXrayErr() error {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return nil
 		return nil
 	}
 	}
 
 
-	err := p.GetErr()
+	err := process.GetErr()
 	if err == nil {
 	if err == nil {
 		return nil
 		return nil
 	}
 	}
@@ -70,17 +103,15 @@ func (s *XrayService) GetXrayErr() error {
 
 
 // GetXrayResult returns the result string from the Xray process.
 // GetXrayResult returns the result string from the Xray process.
 func (s *XrayService) GetXrayResult() string {
 func (s *XrayService) GetXrayResult() string {
-	if result != "" {
-		return result
-	}
-	if s.IsXrayRunning() {
-		return ""
+	process, cachedResult := xrayState.snapshot()
+	if cachedResult != "" {
+		return cachedResult
 	}
 	}
-	if p == nil {
+	if process == nil || process.IsRunning() {
 		return ""
 		return ""
 	}
 	}
 
 
-	result = p.GetResult()
+	result := process.GetResult()
 
 
 	if runtime.GOOS == "windows" && result == "exit status 1" {
 	if runtime.GOOS == "windows" && result == "exit status 1" {
 		// exit status 1 on Windows means that Xray process was killed
 		// exit status 1 on Windows means that Xray process was killed
@@ -88,15 +119,17 @@ func (s *XrayService) GetXrayResult() string {
 		return ""
 		return ""
 	}
 	}
 
 
+	xrayState.storeResult(process, result)
 	return result
 	return result
 }
 }
 
 
 // GetXrayVersion returns the version of the running Xray process.
 // GetXrayVersion returns the version of the running Xray process.
 func (s *XrayService) GetXrayVersion() string {
 func (s *XrayService) GetXrayVersion() string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return "Unknown"
 		return "Unknown"
 	}
 	}
-	return p.GetXrayVersion()
+	return process.GetXrayVersion()
 }
 }
 
 
 // RemoveIndex removes an element at the specified index from a slice.
 // RemoveIndex removes an element at the specified index from a slice.
@@ -838,12 +871,13 @@ func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
 
 
 // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
 // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
 func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
 func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		err := errors.New("xray is not running")
 		err := errors.New("xray is not running")
 		logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
 		logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
 		return nil, nil, err
 		return nil, nil, err
 	}
 	}
-	apiPort := p.GetAPIPort()
+	apiPort := process.GetAPIPort()
 	if err := s.xrayAPI.Init(apiPort); err != nil {
 	if err := s.xrayAPI.Init(apiPort); err != nil {
 		logger.Debug("Failed to initialize Xray API:", err)
 		logger.Debug("Failed to initialize Xray API:", err)
 		return nil, nil, err
 		return nil, nil, err
@@ -866,13 +900,14 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
 // core as unsupported until the next restart, while transient errors leave the
 // core as unsupported until the next restart, while transient errors leave the
 // capability undecided so a flaky poll can't lock in legacy mode.
 // capability undecided so a flaky poll can't lock in legacy mode.
 func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
 func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return nil, false, nil
 		return nil, false, nil
 	}
 	}
-	if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
+	if process.OnlineAPISupport() == xray.OnlineAPIUnsupported {
 		return nil, false, nil
 		return nil, false, nil
 	}
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		logger.Debug("Failed to initialize Xray API:", err)
 		logger.Debug("Failed to initialize Xray API:", err)
 		return nil, false, err
 		return nil, false, err
 	}
 	}
@@ -881,15 +916,15 @@ func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
 	users, err := s.xrayAPI.GetOnlineUsers()
 	users, err := s.xrayAPI.GetOnlineUsers()
 	if err != nil {
 	if err != nil {
 		if xray.IsUnimplementedErr(err) {
 		if xray.IsUnimplementedErr(err) {
-			p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
+			process.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
 			logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
 			logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
 			return nil, false, nil
 			return nil, false, nil
 		}
 		}
 		logger.Debug("Failed to fetch Xray online users:", err)
 		logger.Debug("Failed to fetch Xray online users:", err)
 		return nil, false, err
 		return nil, false, err
 	}
 	}
-	if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
-		p.SetOnlineAPISupport(xray.OnlineAPISupported)
+	if process.OnlineAPISupport() == xray.OnlineAPIUnknown {
+		process.SetOnlineAPISupport(xray.OnlineAPISupported)
 		logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
 		logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
 	}
 	}
 	return users, true, nil
 	return users, true, nil
@@ -911,13 +946,14 @@ type BalancerStatus struct {
 // balancers alongside live ones.
 // balancers alongside live ones.
 func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
 func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
 	statuses := make([]BalancerStatus, 0, len(tags))
 	statuses := make([]BalancerStatus, 0, len(tags))
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		for _, tag := range tags {
 		for _, tag := range tags {
 			statuses = append(statuses, BalancerStatus{Tag: tag})
 			statuses = append(statuses, BalancerStatus{Tag: tag})
 		}
 		}
 		return statuses, nil
 		return statuses, nil
 	}
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 	defer s.xrayAPI.Close()
 	defer s.xrayAPI.Close()
@@ -944,7 +980,8 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
 // another balancer, the override resolves to the loopback outbound that
 // another balancer, the override resolves to the loopback outbound that
 // routes traffic through the target balancer via the routing rules.
 // routes traffic through the target balancer via the routing rules.
 func (s *XrayService) OverrideBalancer(tag, target string) error {
 func (s *XrayService) OverrideBalancer(tag, target string) error {
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return errors.New("xray is not running")
 		return errors.New("xray is not running")
 	}
 	}
 	if target != "" {
 	if target != "" {
@@ -956,7 +993,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
 			target = resolved
 			target = resolved
 		}
 		}
 	}
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return err
 		return err
 	}
 	}
 	defer s.xrayAPI.Close()
 	defer s.xrayAPI.Close()
@@ -1002,10 +1039,11 @@ func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
 // TestRoute asks the running core which outbound its router picks for the
 // TestRoute asks the running core which outbound its router picks for the
 // described connection.
 // described connection.
 func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
 func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return nil, errors.New("xray is not running")
 		return nil, errors.New("xray is not running")
 	}
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 	defer s.xrayAPI.Close()
 	defer s.xrayAPI.Close()
@@ -1031,23 +1069,24 @@ func (s *XrayService) RestartXray(isForce bool) error {
 		return err
 		return err
 	}
 	}
 
 
-	if s.IsXrayRunning() {
-		configUnchanged := p.GetConfig().Equals(xrayConfig)
+	process := currentXrayProcess()
+	if process != nil && process.IsRunning() {
+		configUnchanged := process.GetConfig().Equals(xrayConfig)
 		if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
 		if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
 			logger.Debug("It does not need to restart Xray")
 			logger.Debug("It does not need to restart Xray")
 			return nil
 			return nil
 		}
 		}
-		if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
+		if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
 			logger.Info("Xray config changes applied through the core API, no restart needed")
 			logger.Info("Xray config changes applied through the core API, no restart needed")
 			return nil
 			return nil
 		}
 		}
-		_ = p.Stop()
+		_ = process.Stop()
 	}
 	}
 
 
-	p = xray.NewProcess(xrayConfig)
-	result = ""
+	process = xray.NewProcess(xrayConfig)
+	xrayState.replace(process)
 	s.xrayAPI.StatsLastValues = nil
 	s.xrayAPI.StatsLastValues = nil
-	err = p.Start()
+	err = process.Start()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -1061,19 +1100,19 @@ func (s *XrayService) RestartXray(isForce bool) error {
 // instance now matches newCfg; on any failure it returns false and the
 // instance now matches newCfg; on any failure it returns false and the
 // caller falls back to a full process restart, which cleans up whatever was
 // caller falls back to a full process restart, which cleans up whatever was
 // partially applied. Callers must hold the package-level lock.
 // partially applied. Callers must hold the package-level lock.
-func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
-	oldCfg := p.GetConfig()
+func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
+	oldCfg := process.GetConfig()
 	diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
 	diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
 	if !ok {
 	if !ok {
 		logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
 		logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
 		return false
 		return false
 	}
 	}
 	if diff.Empty() {
 	if diff.Empty() {
-		p.SetConfig(newCfg)
+		process.SetConfig(newCfg)
 		return true
 		return true
 	}
 	}
 
 
-	apiPort := p.GetAPIPort()
+	apiPort := process.GetAPIPort()
 	if apiPort <= 0 {
 	if apiPort <= 0 {
 		return false
 		return false
 	}
 	}
@@ -1131,7 +1170,7 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
 		}
 		}
 	}
 	}
 
 
-	p.SetConfig(newCfg)
+	process.SetConfig(newCfg)
 	return true
 	return true
 }
 }
 
 
@@ -1192,8 +1231,9 @@ func (s *XrayService) StopXray() error {
 	defer lock.Unlock()
 	defer lock.Unlock()
 	isManuallyStopped.Store(true)
 	isManuallyStopped.Store(true)
 	logger.Debug("Attempting to stop Xray...")
 	logger.Debug("Attempting to stop Xray...")
-	if s.IsXrayRunning() {
-		return p.Stop()
+	process := currentXrayProcess()
+	if process != nil && process.IsRunning() {
+		return process.Stop()
 	}
 	}
 	return errors.New("xray is not running")
 	return errors.New("xray is not running")
 }
 }
@@ -1205,14 +1245,14 @@ func (s *XrayService) SetToNeedRestart() {
 
 
 // GetXrayAPIPort returns the port the local xray process is listening on
 // GetXrayAPIPort returns the port the local xray process is listening on
 // for its gRPC HandlerService, or 0 when xray isn't currently running.
 // for its gRPC HandlerService, or 0 when xray isn't currently running.
-// Exposed for the runtime package's LocalRuntime adapter — runtime can't
-// reach into the package-level `p` directly without a service-package
-// import cycle.
+// Exposed for the runtime package's LocalRuntime adapter without a
+// service-package import cycle.
 func (s *XrayService) GetXrayAPIPort() int {
 func (s *XrayService) GetXrayAPIPort() int {
-	if p == nil || !p.IsRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return 0
 		return 0
 	}
 	}
-	return p.GetAPIPort()
+	return process.GetAPIPort()
 }
 }
 
 
 // IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
 // IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.

+ 68 - 0
internal/web/service/xray_lifecycle_test.go

@@ -0,0 +1,68 @@
+package service
+
+import (
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
+	state := xrayLifecycle{}
+	first := xray.NewProcess(&xray.Config{})
+	second := xray.NewProcess(&xray.Config{})
+
+	state.replace(first)
+	state.storeResult(first, "first result")
+	process, result := state.snapshot()
+	if process != first || result != "first result" {
+		t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
+	}
+	state.replace(second)
+	state.storeResult(first, "old result")
+
+	process, result = state.snapshot()
+	if process != second {
+		t.Fatal("snapshot returned the replaced process")
+	}
+	if result != "" {
+		t.Fatalf("snapshot result = %q, want empty", result)
+	}
+}
+
+func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
+	previousProcess, previousResult := xrayState.snapshot()
+	t.Cleanup(func() {
+		xrayState.mu.Lock()
+		xrayState.process = previousProcess
+		xrayState.result = previousResult
+		xrayState.mu.Unlock()
+	})
+
+	first := xray.NewProcess(&xray.Config{})
+	second := xray.NewProcess(&xray.Config{})
+	service := XrayService{}
+	var wg sync.WaitGroup
+
+	wg.Go(func() {
+		for range 200 {
+			xrayState.replace(first)
+			xrayState.replace(second)
+		}
+	})
+
+	for range 4 {
+		wg.Go(func() {
+			for range 200 {
+				_ = service.IsXrayRunning()
+				_ = service.GetXrayResult()
+				_, _, err := service.GetXrayTraffic()
+				if err == nil || err.Error() != "xray is not running" {
+					t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
+				}
+			}
+		})
+	}
+
+	wg.Wait()
+}

+ 2 - 2
internal/web/service/xray_setting.go

@@ -54,8 +54,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 			return common.NewError("xray template config invalid: outbounds is not an array:", err)
 			return common.NewError("xray template config invalid: outbounds is not an array:", err)
 		}
 		}
 		coreVersion := "Unknown"
 		coreVersion := "Unknown"
-		if p != nil {
-			coreVersion = p.GetXrayVersion()
+		if process := currentXrayProcess(); process != nil {
+			coreVersion = process.GetXrayVersion()
 		}
 		}
 		for _, outbound := range outbounds {
 		for _, outbound := range outbounds {
 			if err := xray.ValidateOutboundConfig(outbound); err != nil {
 			if err := xray.ValidateOutboundConfig(outbound); err != nil {

+ 12 - 8
internal/web/translation/ar-EG.json

@@ -106,7 +106,6 @@
     "routing": "التوجيه",
     "routing": "التوجيه",
     "outbounds": "الصادرات",
     "outbounds": "الصادرات",
     "apiDocs": "توثيق API",
     "apiDocs": "توثيق API",
-    "link": "إدارة",
     "donate": "تبرع",
     "donate": "تبرع",
     "hosts": "المضيفات",
     "hosts": "المضيفات",
     "docs": "التوثيق",
     "docs": "التوثيق",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "المعالج",
       "cpu": "المعالج",
-      "logicalProcessors": "المعالجات المنطقية",
-      "frequency": "التردد",
       "swap": "التبديل",
       "swap": "التبديل",
       "storage": "تخزين",
       "storage": "تخزين",
       "memory": "الذاكرة",
       "memory": "الذاكرة",
-      "threads": "خيوط",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "إيقاف",
       "stopXray": "إيقاف",
       "restartXray": "إعادة تشغيل",
       "restartXray": "إعادة تشغيل",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "شغالة",
       "xrayStatusRunning": "شغالة",
       "xrayStatusStop": "متوقفة",
       "xrayStatusStop": "متوقفة",
       "xrayStatusError": "خطأ",
       "xrayStatusError": "خطأ",
-      "operationHours": "مدة التشغيل",
       "systemHistoryTitle": "تاريخ النظام",
       "systemHistoryTitle": "تاريخ النظام",
       "historyTitleCpu": "استخدام المعالج",
       "historyTitleCpu": "استخدام المعالج",
       "historyTitleMem": "استخدام الذاكرة",
       "historyTitleMem": "استخدام الذاكرة",
@@ -171,7 +166,6 @@
       "historyTabLoad": "الحِمل",
       "historyTabLoad": "الحِمل",
       "historyTabConnections": "الاتصالات",
       "historyTabConnections": "الاتصالات",
       "historyTabDiskUsage": "استخدام القرص",
       "historyTabDiskUsage": "استخدام القرص",
-      "charts": "الرسوم البيانية",
       "xrayMetricsTitle": "مقاييس Xray",
       "xrayMetricsTitle": "مقاييس Xray",
       "xrayTitleHeap": "ذاكرة الكومة المخصصة",
       "xrayTitleHeap": "ذاكرة الكومة المخصصة",
       "xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
       "xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
@@ -200,7 +194,6 @@
       "overallSpeed": "السرعة الكلية",
       "overallSpeed": "السرعة الكلية",
       "upload": "رفع",
       "upload": "رفع",
       "download": "تنزيل",
       "download": "تنزيل",
-      "totalData": "إجمالي البيانات",
       "sent": "مرسل",
       "sent": "مرسل",
       "received": "مستقبل",
       "received": "مستقبل",
       "xraySwitchVersionDialog": "هل تريد حقًا تغيير إصدار Xray؟",
       "xraySwitchVersionDialog": "هل تريد حقًا تغيير إصدار Xray؟",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
       "exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
       "importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
       "importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
       "migrationDownload": "تنزيل ملف الترحيل",
       "migrationDownload": "تنزيل ملف الترحيل",
-      "migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
+      "migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
+      "avg": "متوسط",
+      "peak": "ذروة",
+      "free": "متاح",
+      "openSockets": "مقابس مفتوحة",
+      "throughputSub": "إجمالي الواجهة",
+      "avgWindow": "متوسط الفترة",
+      "healthWarm": "{list} — مرتفع قليلاً",
+      "healthCritical": "{list} — حرج",
+      "panel": "اللوحة",
+      "threads": "الخيوط",
+      "uptime": "مدة التشغيل"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "إجمالي المرسل/المستقبل",
       "totalDownUp": "إجمالي المرسل/المستقبل",

+ 12 - 8
internal/web/translation/en-US.json

@@ -107,7 +107,6 @@
     "routing": "Routing",
     "routing": "Routing",
     "outbounds": "Outbounds",
     "outbounds": "Outbounds",
     "apiDocs": "API Docs",
     "apiDocs": "API Docs",
-    "link": "Manage",
     "donate": "Donate",
     "donate": "Donate",
     "docs": "Documentation",
     "docs": "Documentation",
     "openMenu": "Open menu"
     "openMenu": "Open menu"
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Logical Processors",
-      "frequency": "Frequency",
       "swap": "Swap",
       "swap": "Swap",
       "storage": "Storage",
       "storage": "Storage",
       "memory": "RAM",
       "memory": "RAM",
-      "threads": "Threads",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Stop",
       "stopXray": "Stop",
       "restartXray": "Restart",
       "restartXray": "Restart",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Running",
       "xrayStatusRunning": "Running",
       "xrayStatusStop": "Stopped",
       "xrayStatusStop": "Stopped",
       "xrayStatusError": "Error",
       "xrayStatusError": "Error",
-      "operationHours": "Uptime",
       "systemHistoryTitle": "System History",
       "systemHistoryTitle": "System History",
       "historyTitleCpu": "CPU Usage",
       "historyTitleCpu": "CPU Usage",
       "historyTitleMem": "Memory Usage",
       "historyTitleMem": "Memory Usage",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Load",
       "historyTabLoad": "Load",
       "historyTabConnections": "Connections",
       "historyTabConnections": "Connections",
       "historyTabDiskUsage": "Disk Usage",
       "historyTabDiskUsage": "Disk Usage",
-      "charts": "Charts",
       "xrayMetricsTitle": "Xray Metrics",
       "xrayMetricsTitle": "Xray Metrics",
       "xrayTitleHeap": "Allocated Heap Memory",
       "xrayTitleHeap": "Allocated Heap Memory",
       "xrayTitleSys": "Memory Reserved from OS",
       "xrayTitleSys": "Memory Reserved from OS",
@@ -200,7 +194,6 @@
       "overallSpeed": "Overall Speed",
       "overallSpeed": "Overall Speed",
       "upload": "Upload",
       "upload": "Upload",
       "download": "Download",
       "download": "Download",
-      "totalData": "Total Data",
       "sent": "Sent",
       "sent": "Sent",
       "received": "Received",
       "received": "Received",
       "xraySwitchVersionDialog": "Do you really want to change the Xray version?",
       "xraySwitchVersionDialog": "Do you really want to change the Xray version?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
       "exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
       "importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
       "importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
       "migrationDownload": "Download Migration",
       "migrationDownload": "Download Migration",
-      "migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
+      "migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
+      "avg": "avg",
+      "peak": "peak",
+      "free": "free",
+      "openSockets": "open sockets",
+      "throughputSub": "Interface total",
+      "avgWindow": "Avg over window",
+      "healthWarm": "{list} — running warm",
+      "healthCritical": "{list} — critical",
+      "panel": "Panel",
+      "threads": "Threads",
+      "uptime": "Uptime"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Total Sent/Received",
       "totalDownUp": "Total Sent/Received",

+ 12 - 8
internal/web/translation/es-ES.json

@@ -106,7 +106,6 @@
     "routing": "Enrutamiento",
     "routing": "Enrutamiento",
     "outbounds": "Salidas",
     "outbounds": "Salidas",
     "apiDocs": "Documentación de la API",
     "apiDocs": "Documentación de la API",
-    "link": "Gestionar",
     "donate": "Donar",
     "donate": "Donar",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Documentación",
     "docs": "Documentación",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Procesadores lógicos",
-      "frequency": "Frecuencia",
       "swap": "Swap",
       "swap": "Swap",
       "storage": "Almacenamiento",
       "storage": "Almacenamiento",
       "memory": "Memoria",
       "memory": "Memoria",
-      "threads": "Hilos",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Detener",
       "stopXray": "Detener",
       "restartXray": "Reiniciar",
       "restartXray": "Reiniciar",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "En ejecución",
       "xrayStatusRunning": "En ejecución",
       "xrayStatusStop": "Detenido",
       "xrayStatusStop": "Detenido",
       "xrayStatusError": "Error",
       "xrayStatusError": "Error",
-      "operationHours": "Tiempo de Funcionamiento",
       "systemHistoryTitle": "Historial del Sistema",
       "systemHistoryTitle": "Historial del Sistema",
       "historyTitleCpu": "Uso de CPU",
       "historyTitleCpu": "Uso de CPU",
       "historyTitleMem": "Uso de Memoria",
       "historyTitleMem": "Uso de Memoria",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Carga",
       "historyTabLoad": "Carga",
       "historyTabConnections": "Conexiones",
       "historyTabConnections": "Conexiones",
       "historyTabDiskUsage": "Uso de Disco",
       "historyTabDiskUsage": "Uso de Disco",
-      "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas de Xray",
       "xrayMetricsTitle": "Métricas de Xray",
       "xrayTitleHeap": "Memoria Heap Asignada",
       "xrayTitleHeap": "Memoria Heap Asignada",
       "xrayTitleSys": "Memoria Reservada del SO",
       "xrayTitleSys": "Memoria Reservada del SO",
@@ -200,7 +194,6 @@
       "overallSpeed": "Velocidad general",
       "overallSpeed": "Velocidad general",
       "upload": "Subida",
       "upload": "Subida",
       "download": "Descargar",
       "download": "Descargar",
-      "totalData": "Datos totales",
       "sent": "Enviado",
       "sent": "Enviado",
       "received": "Recibido",
       "received": "Recibido",
       "xraySwitchVersionDialog": "¿Realmente deseas cambiar la versión de Xray?",
       "xraySwitchVersionDialog": "¿Realmente deseas cambiar la versión de Xray?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
       "exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
       "importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
       "importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
       "migrationDownload": "Descargar migración",
       "migrationDownload": "Descargar migración",
-      "migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
+      "migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
+      "avg": "media",
+      "peak": "pico",
+      "free": "libre",
+      "openSockets": "sockets abiertos",
+      "throughputSub": "Total de la interfaz",
+      "avgWindow": "Media del periodo",
+      "healthWarm": "{list} — algo elevado",
+      "healthCritical": "{list} — crítico",
+      "panel": "Panel",
+      "threads": "Hilos",
+      "uptime": "Tiempo activo"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Subidas/Descargas Totales",
       "totalDownUp": "Subidas/Descargas Totales",

+ 12 - 8
internal/web/translation/fa-IR.json

@@ -106,7 +106,6 @@
     "routing": "مسیریابی",
     "routing": "مسیریابی",
     "outbounds": "خروجی‌ها",
     "outbounds": "خروجی‌ها",
     "apiDocs": "مستندات API",
     "apiDocs": "مستندات API",
-    "link": "مدیریت",
     "donate": "حمایت مالی",
     "donate": "حمایت مالی",
     "hosts": "میزبان‌ها",
     "hosts": "میزبان‌ها",
     "docs": "مستندات",
     "docs": "مستندات",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "پردازنده",
       "cpu": "پردازنده",
-      "logicalProcessors": "پردازنده‌های منطقی",
-      "frequency": "فرکانس",
       "swap": "سواپ",
       "swap": "سواپ",
       "storage": "ذخیره‌سازی",
       "storage": "ذخیره‌سازی",
       "memory": "حافظه",
       "memory": "حافظه",
-      "threads": "نخ‌ها",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "توقف",
       "stopXray": "توقف",
       "restartXray": "راه‌اندازی مجدد",
       "restartXray": "راه‌اندازی مجدد",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "در حال اجرا",
       "xrayStatusRunning": "در حال اجرا",
       "xrayStatusStop": "متوقف",
       "xrayStatusStop": "متوقف",
       "xrayStatusError": "خطا",
       "xrayStatusError": "خطا",
-      "operationHours": "مدت‌کارکرد",
       "systemHistoryTitle": "تاریخچه سیستم",
       "systemHistoryTitle": "تاریخچه سیستم",
       "historyTitleCpu": "مصرف پردازنده",
       "historyTitleCpu": "مصرف پردازنده",
       "historyTitleMem": "مصرف حافظه",
       "historyTitleMem": "مصرف حافظه",
@@ -171,7 +166,6 @@
       "historyTabLoad": "بار",
       "historyTabLoad": "بار",
       "historyTabConnections": "اتصالات",
       "historyTabConnections": "اتصالات",
       "historyTabDiskUsage": "مصرف دیسک",
       "historyTabDiskUsage": "مصرف دیسک",
-      "charts": "نمودارها",
       "xrayMetricsTitle": "متریک‌های Xray",
       "xrayMetricsTitle": "متریک‌های Xray",
       "xrayTitleHeap": "حافظه‌ی Heap تخصیص‌یافته",
       "xrayTitleHeap": "حافظه‌ی Heap تخصیص‌یافته",
       "xrayTitleSys": "حافظه‌ی رزروشده از سیستم‌عامل",
       "xrayTitleSys": "حافظه‌ی رزروشده از سیستم‌عامل",
@@ -200,7 +194,6 @@
       "overallSpeed": "سرعت کلی",
       "overallSpeed": "سرعت کلی",
       "upload": "آپلود",
       "upload": "آپلود",
       "download": "دانلود",
       "download": "دانلود",
-      "totalData": "داده‌های کل",
       "sent": "ارسال شده",
       "sent": "ارسال شده",
       "received": "دریافت شده",
       "received": "دریافت شده",
       "xraySwitchVersionDialog": "آیا واقعاً می‌خواهید نسخه Xray را تغییر دهید؟",
       "xraySwitchVersionDialog": "آیا واقعاً می‌خواهید نسخه Xray را تغییر دهید؟",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
       "exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
       "importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاه‌دادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
       "importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاه‌دادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
       "migrationDownload": "دانلود فایل مهاجرت",
       "migrationDownload": "دانلود فایل مهاجرت",
-      "migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
+      "migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
+      "avg": "میانگین",
+      "peak": "اوج",
+      "free": "آزاد",
+      "openSockets": "اتصال باز",
+      "throughputSub": "کل اینترفیس",
+      "avgWindow": "میانگین بازه",
+      "healthWarm": "{list} — رو به گرم شدن",
+      "healthCritical": "{list} — بحرانی",
+      "panel": "پنل",
+      "threads": "نخ‌ها",
+      "uptime": "مدت کارکرد"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "دریافت/ارسال کل",
       "totalDownUp": "دریافت/ارسال کل",

+ 12 - 8
internal/web/translation/id-ID.json

@@ -106,7 +106,6 @@
     "routing": "Pengalihan",
     "routing": "Pengalihan",
     "outbounds": "Outbound",
     "outbounds": "Outbound",
     "apiDocs": "Dokumentasi API",
     "apiDocs": "Dokumentasi API",
-    "link": "Kelola",
     "donate": "Donasi",
     "donate": "Donasi",
     "hosts": "Host",
     "hosts": "Host",
     "docs": "Dokumentasi",
     "docs": "Dokumentasi",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Prosesor logis",
-      "frequency": "Frekuensi",
       "swap": "Swap",
       "swap": "Swap",
       "storage": "Penyimpanan",
       "storage": "Penyimpanan",
       "memory": "Memori",
       "memory": "Memori",
-      "threads": "Thread",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Hentikan",
       "stopXray": "Hentikan",
       "restartXray": "Mulai ulang",
       "restartXray": "Mulai ulang",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Berjalan",
       "xrayStatusRunning": "Berjalan",
       "xrayStatusStop": "Berhenti",
       "xrayStatusStop": "Berhenti",
       "xrayStatusError": "Error",
       "xrayStatusError": "Error",
-      "operationHours": "Waktu Aktif",
       "systemHistoryTitle": "Riwayat Sistem",
       "systemHistoryTitle": "Riwayat Sistem",
       "historyTitleCpu": "Penggunaan CPU",
       "historyTitleCpu": "Penggunaan CPU",
       "historyTitleMem": "Penggunaan Memori",
       "historyTitleMem": "Penggunaan Memori",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Beban",
       "historyTabLoad": "Beban",
       "historyTabConnections": "Koneksi",
       "historyTabConnections": "Koneksi",
       "historyTabDiskUsage": "Penggunaan Disk",
       "historyTabDiskUsage": "Penggunaan Disk",
-      "charts": "Grafik",
       "xrayMetricsTitle": "Metrik Xray",
       "xrayMetricsTitle": "Metrik Xray",
       "xrayTitleHeap": "Memori Heap Teralokasi",
       "xrayTitleHeap": "Memori Heap Teralokasi",
       "xrayTitleSys": "Memori Dicadangkan dari OS",
       "xrayTitleSys": "Memori Dicadangkan dari OS",
@@ -200,7 +194,6 @@
       "overallSpeed": "Kecepatan keseluruhan",
       "overallSpeed": "Kecepatan keseluruhan",
       "upload": "Unggah",
       "upload": "Unggah",
       "download": "Unduh",
       "download": "Unduh",
-      "totalData": "Total data",
       "sent": "Dikirim",
       "sent": "Dikirim",
       "received": "Diterima",
       "received": "Diterima",
       "xraySwitchVersionDialog": "Apakah Anda yakin ingin mengubah versi Xray?",
       "xraySwitchVersionDialog": "Apakah Anda yakin ingin mengubah versi Xray?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
       "exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
       "importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
       "importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
       "migrationDownload": "Unduh migrasi",
       "migrationDownload": "Unduh migrasi",
-      "migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
+      "migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
+      "avg": "rerata",
+      "peak": "puncak",
+      "free": "tersisa",
+      "openSockets": "soket terbuka",
+      "throughputSub": "Total antarmuka",
+      "avgWindow": "Rerata periode",
+      "healthWarm": "{list} — mulai tinggi",
+      "healthCritical": "{list} — kritis",
+      "panel": "Panel",
+      "threads": "Thread",
+      "uptime": "Waktu aktif"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Total Terkirim/Diterima",
       "totalDownUp": "Total Terkirim/Diterima",

+ 12 - 8
internal/web/translation/ja-JP.json

@@ -106,7 +106,6 @@
     "routing": "ルーティング",
     "routing": "ルーティング",
     "outbounds": "アウトバウンド",
     "outbounds": "アウトバウンド",
     "apiDocs": "API ドキュメント",
     "apiDocs": "API ドキュメント",
-    "link": "リンク管理",
     "donate": "寄付",
     "donate": "寄付",
     "hosts": "ホスト",
     "hosts": "ホスト",
     "docs": "ドキュメント",
     "docs": "ドキュメント",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "論理プロセッサ",
-      "frequency": "周波数",
       "swap": "スワップ",
       "swap": "スワップ",
       "storage": "ストレージ",
       "storage": "ストレージ",
       "memory": "メモリ",
       "memory": "メモリ",
-      "threads": "スレッド",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "停止",
       "stopXray": "停止",
       "restartXray": "再起動",
       "restartXray": "再起動",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "実行中",
       "xrayStatusRunning": "実行中",
       "xrayStatusStop": "停止",
       "xrayStatusStop": "停止",
       "xrayStatusError": "エラー",
       "xrayStatusError": "エラー",
-      "operationHours": "システム稼働時間",
       "systemHistoryTitle": "システム履歴",
       "systemHistoryTitle": "システム履歴",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleMem": "メモリ使用率",
       "historyTitleMem": "メモリ使用率",
@@ -171,7 +166,6 @@
       "historyTabLoad": "負荷",
       "historyTabLoad": "負荷",
       "historyTabConnections": "接続数",
       "historyTabConnections": "接続数",
       "historyTabDiskUsage": "ディスク使用量",
       "historyTabDiskUsage": "ディスク使用量",
-      "charts": "チャート",
       "xrayMetricsTitle": "Xray メトリクス",
       "xrayMetricsTitle": "Xray メトリクス",
       "xrayTitleHeap": "割り当て済みヒープメモリ",
       "xrayTitleHeap": "割り当て済みヒープメモリ",
       "xrayTitleSys": "OS から確保したメモリ",
       "xrayTitleSys": "OS から確保したメモリ",
@@ -200,7 +194,6 @@
       "overallSpeed": "全体の速度",
       "overallSpeed": "全体の速度",
       "upload": "アップロード",
       "upload": "アップロード",
       "download": "ダウンロード",
       "download": "ダウンロード",
-      "totalData": "総データ量",
       "sent": "送信",
       "sent": "送信",
       "received": "受信",
       "received": "受信",
       "xraySwitchVersionDialog": "Xrayのバージョンを本当に変更しますか?",
       "xraySwitchVersionDialog": "Xrayのバージョンを本当に変更しますか?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
       "exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
       "importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
       "importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
       "migrationDownload": "移行ファイルをダウンロード",
       "migrationDownload": "移行ファイルをダウンロード",
-      "migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
+      "migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
+      "avg": "平均",
+      "peak": "ピーク",
+      "free": "空き",
+      "openSockets": "オープンソケット",
+      "throughputSub": "インターフェース合計",
+      "avgWindow": "期間平均",
+      "healthWarm": "{list} — 高めです",
+      "healthCritical": "{list} — 危険水準",
+      "panel": "パネル",
+      "threads": "スレッド",
+      "uptime": "稼働時間"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "総アップロード / ダウンロード",
       "totalDownUp": "総アップロード / ダウンロード",

+ 12 - 8
internal/web/translation/pt-BR.json

@@ -106,7 +106,6 @@
     "routing": "Roteamento",
     "routing": "Roteamento",
     "outbounds": "Saídas",
     "outbounds": "Saídas",
     "apiDocs": "Documentação da API",
     "apiDocs": "Documentação da API",
-    "link": "Gerenciar",
     "donate": "Doar",
     "donate": "Doar",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Documentação",
     "docs": "Documentação",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Processadores lógicos",
-      "frequency": "Frequência",
       "swap": "Swap",
       "swap": "Swap",
       "storage": "Armazenamento",
       "storage": "Armazenamento",
       "memory": "Memória",
       "memory": "Memória",
-      "threads": "Threads",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Parar",
       "stopXray": "Parar",
       "restartXray": "Reiniciar",
       "restartXray": "Reiniciar",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Em execução",
       "xrayStatusRunning": "Em execução",
       "xrayStatusStop": "Parado",
       "xrayStatusStop": "Parado",
       "xrayStatusError": "Erro",
       "xrayStatusError": "Erro",
-      "operationHours": "Tempo de Atividade",
       "systemHistoryTitle": "Histórico do Sistema",
       "systemHistoryTitle": "Histórico do Sistema",
       "historyTitleCpu": "Uso da CPU",
       "historyTitleCpu": "Uso da CPU",
       "historyTitleMem": "Uso de Memória",
       "historyTitleMem": "Uso de Memória",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Carga",
       "historyTabLoad": "Carga",
       "historyTabConnections": "Conexões",
       "historyTabConnections": "Conexões",
       "historyTabDiskUsage": "Uso de Disco",
       "historyTabDiskUsage": "Uso de Disco",
-      "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas do Xray",
       "xrayMetricsTitle": "Métricas do Xray",
       "xrayTitleHeap": "Memória Heap Alocada",
       "xrayTitleHeap": "Memória Heap Alocada",
       "xrayTitleSys": "Memória Reservada do SO",
       "xrayTitleSys": "Memória Reservada do SO",
@@ -200,7 +194,6 @@
       "overallSpeed": "Velocidade geral",
       "overallSpeed": "Velocidade geral",
       "upload": "Upload",
       "upload": "Upload",
       "download": "Download",
       "download": "Download",
-      "totalData": "Dados totais",
       "sent": "Enviado",
       "sent": "Enviado",
       "received": "Recebido",
       "received": "Recebido",
       "xraySwitchVersionDialog": "Você realmente deseja alterar a versão do Xray?",
       "xraySwitchVersionDialog": "Você realmente deseja alterar a versão do Xray?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
       "exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
       "importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
       "importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
       "migrationDownload": "Baixar migração",
       "migrationDownload": "Baixar migração",
-      "migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
+      "migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
+      "avg": "média",
+      "peak": "pico",
+      "free": "livre",
+      "openSockets": "sockets abertos",
+      "throughputSub": "Total da interface",
+      "avgWindow": "Média do período",
+      "healthWarm": "{list} — um pouco alto",
+      "healthCritical": "{list} — crítico",
+      "panel": "Painel",
+      "threads": "Threads",
+      "uptime": "Tempo ativo"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Total Enviado/Recebido",
       "totalDownUp": "Total Enviado/Recebido",

+ 12 - 8
internal/web/translation/ru-RU.json

@@ -106,7 +106,6 @@
     "routing": "Маршрутизация",
     "routing": "Маршрутизация",
     "outbounds": "Исходящие",
     "outbounds": "Исходящие",
     "apiDocs": "Документация API",
     "apiDocs": "Документация API",
-    "link": "Управление",
     "donate": "Поддержать",
     "donate": "Поддержать",
     "hosts": "Хосты",
     "hosts": "Хосты",
     "docs": "Документация",
     "docs": "Документация",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "ЦП",
       "cpu": "ЦП",
-      "logicalProcessors": "Логические процессоры",
-      "frequency": "Частота",
       "swap": "Подкачка",
       "swap": "Подкачка",
       "storage": "Диск",
       "storage": "Диск",
       "memory": "Память",
       "memory": "Память",
-      "threads": "Потоки",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Стоп",
       "stopXray": "Стоп",
       "restartXray": "Перезапуск",
       "restartXray": "Перезапуск",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Запущен",
       "xrayStatusRunning": "Запущен",
       "xrayStatusStop": "Остановлен",
       "xrayStatusStop": "Остановлен",
       "xrayStatusError": "Ошибка",
       "xrayStatusError": "Ошибка",
-      "operationHours": "Время работы системы",
       "systemHistoryTitle": "История системы",
       "systemHistoryTitle": "История системы",
       "historyTitleCpu": "Загрузка ЦП",
       "historyTitleCpu": "Загрузка ЦП",
       "historyTitleMem": "Использование памяти",
       "historyTitleMem": "Использование памяти",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Нагрузка",
       "historyTabLoad": "Нагрузка",
       "historyTabConnections": "Соединения",
       "historyTabConnections": "Соединения",
       "historyTabDiskUsage": "Использование диска",
       "historyTabDiskUsage": "Использование диска",
-      "charts": "Графики",
       "xrayMetricsTitle": "Метрики Xray",
       "xrayMetricsTitle": "Метрики Xray",
       "xrayTitleHeap": "Выделенная память кучи",
       "xrayTitleHeap": "Выделенная память кучи",
       "xrayTitleSys": "Память, зарезервированная у ОС",
       "xrayTitleSys": "Память, зарезервированная у ОС",
@@ -200,7 +194,6 @@
       "overallSpeed": "Общая скорость передачи трафика",
       "overallSpeed": "Общая скорость передачи трафика",
       "upload": "Загрузка",
       "upload": "Загрузка",
       "download": "Скачать",
       "download": "Скачать",
-      "totalData": "Общий объем трафика",
       "sent": "Отправлено",
       "sent": "Отправлено",
       "received": "Получено",
       "received": "Получено",
       "xraySwitchVersionDialog": "Переключить версию Xray",
       "xraySwitchVersionDialog": "Переключить версию Xray",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
       "exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
       "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
       "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
       "migrationDownload": "Скачать файл миграции",
       "migrationDownload": "Скачать файл миграции",
-      "migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
+      "migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite.",
+      "avg": "среднее",
+      "peak": "пик",
+      "free": "свободно",
+      "openSockets": "открытых сокетов",
+      "throughputSub": "Всего по интерфейсу",
+      "avgWindow": "Среднее за период",
+      "healthWarm": "{list} — повышенная нагрузка",
+      "healthCritical": "{list} — критический уровень",
+      "panel": "Панель",
+      "threads": "Потоки",
+      "uptime": "Время работы"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Отправлено/получено",
       "totalDownUp": "Отправлено/получено",

+ 12 - 8
internal/web/translation/tr-TR.json

@@ -106,7 +106,6 @@
     "routing": "Yönlendirme",
     "routing": "Yönlendirme",
     "outbounds": "Giden Bağlantılar",
     "outbounds": "Giden Bağlantılar",
     "apiDocs": "API Belgeleri",
     "apiDocs": "API Belgeleri",
-    "link": "Yönet",
     "donate": "Bağış Yap",
     "donate": "Bağış Yap",
     "hosts": "Host'lar",
     "hosts": "Host'lar",
     "docs": "Belgeler",
     "docs": "Belgeler",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Mantıksal İşlemciler",
-      "frequency": "Frekans",
       "swap": "Takas",
       "swap": "Takas",
       "storage": "Depolama",
       "storage": "Depolama",
       "memory": "RAM",
       "memory": "RAM",
-      "threads": "İş Parçacığı",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Durdur",
       "stopXray": "Durdur",
       "restartXray": "Yeniden Başlat",
       "restartXray": "Yeniden Başlat",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Çalışıyor",
       "xrayStatusRunning": "Çalışıyor",
       "xrayStatusStop": "Durduruldu",
       "xrayStatusStop": "Durduruldu",
       "xrayStatusError": "Hata",
       "xrayStatusError": "Hata",
-      "operationHours": "Çalışma Süresi",
       "systemHistoryTitle": "Sistem Geçmişi",
       "systemHistoryTitle": "Sistem Geçmişi",
       "historyTitleCpu": "CPU Kullanımı",
       "historyTitleCpu": "CPU Kullanımı",
       "historyTitleMem": "Bellek Kullanımı",
       "historyTitleMem": "Bellek Kullanımı",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Yük",
       "historyTabLoad": "Yük",
       "historyTabConnections": "Bağlantılar",
       "historyTabConnections": "Bağlantılar",
       "historyTabDiskUsage": "Disk Kullanımı",
       "historyTabDiskUsage": "Disk Kullanımı",
-      "charts": "Grafikler",
       "xrayMetricsTitle": "Xray Metrikleri",
       "xrayMetricsTitle": "Xray Metrikleri",
       "xrayTitleHeap": "Ayrılan Yığın Belleği",
       "xrayTitleHeap": "Ayrılan Yığın Belleği",
       "xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
       "xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
@@ -200,7 +194,6 @@
       "overallSpeed": "Genel Hız",
       "overallSpeed": "Genel Hız",
       "upload": "Yükleme",
       "upload": "Yükleme",
       "download": "İndirme",
       "download": "İndirme",
-      "totalData": "Toplam Veri",
       "sent": "Gönderilen",
       "sent": "Gönderilen",
       "received": "Alınan",
       "received": "Alınan",
       "xraySwitchVersionDialog": "Xray sürümünü gerçekten değiştirmek istiyor musunuz?",
       "xraySwitchVersionDialog": "Xray sürümünü gerçekten değiştirmek istiyor musunuz?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
       "exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
       "importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
       "importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
       "migrationDownload": "Geçiş Dosyasını İndir",
       "migrationDownload": "Geçiş Dosyasını İndir",
-      "migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
+      "migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın.",
+      "avg": "ort.",
+      "peak": "tepe",
+      "free": "boş",
+      "openSockets": "açık soket",
+      "throughputSub": "Arayüz toplamı",
+      "avgWindow": "Dönem ortalaması",
+      "healthWarm": "{list} — yükseliyor",
+      "healthCritical": "{list} — kritik",
+      "panel": "Panel",
+      "threads": "İş parçacıkları",
+      "uptime": "Çalışma süresi"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Toplam Gönderilen/Alınan",
       "totalDownUp": "Toplam Gönderilen/Alınan",

+ 12 - 8
internal/web/translation/uk-UA.json

@@ -106,7 +106,6 @@
     "routing": "Маршрутизація",
     "routing": "Маршрутизація",
     "outbounds": "Вихідні",
     "outbounds": "Вихідні",
     "apiDocs": "Документація API",
     "apiDocs": "Документація API",
-    "link": "Керувати",
     "donate": "Підтримати",
     "donate": "Підтримати",
     "hosts": "Хости",
     "hosts": "Хости",
     "docs": "Документація",
     "docs": "Документація",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "ЦП",
       "cpu": "ЦП",
-      "logicalProcessors": "Логічні процесори",
-      "frequency": "Частота",
       "swap": "Підкачка",
       "swap": "Підкачка",
       "storage": "Сховище",
       "storage": "Сховище",
       "memory": "Пам’ять",
       "memory": "Пам’ять",
-      "threads": "Потоки",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Стоп",
       "stopXray": "Стоп",
       "restartXray": "Перезапуск",
       "restartXray": "Перезапуск",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Запущено",
       "xrayStatusRunning": "Запущено",
       "xrayStatusStop": "Зупинено",
       "xrayStatusStop": "Зупинено",
       "xrayStatusError": "Помилка",
       "xrayStatusError": "Помилка",
-      "operationHours": "Час роботи",
       "systemHistoryTitle": "Історія системи",
       "systemHistoryTitle": "Історія системи",
       "historyTitleCpu": "Завантаження ЦП",
       "historyTitleCpu": "Завантаження ЦП",
       "historyTitleMem": "Використання пам’яті",
       "historyTitleMem": "Використання пам’яті",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Навантаження",
       "historyTabLoad": "Навантаження",
       "historyTabConnections": "З’єднання",
       "historyTabConnections": "З’єднання",
       "historyTabDiskUsage": "Використання диска",
       "historyTabDiskUsage": "Використання диска",
-      "charts": "Графіки",
       "xrayMetricsTitle": "Метрики Xray",
       "xrayMetricsTitle": "Метрики Xray",
       "xrayTitleHeap": "Виділена пам’ять купи",
       "xrayTitleHeap": "Виділена пам’ять купи",
       "xrayTitleSys": "Пам’ять, зарезервована в ОС",
       "xrayTitleSys": "Пам’ять, зарезервована в ОС",
@@ -200,7 +194,6 @@
       "overallSpeed": "Загальна швидкість",
       "overallSpeed": "Загальна швидкість",
       "upload": "Завантаження",
       "upload": "Завантаження",
       "download": "Завантажити",
       "download": "Завантажити",
-      "totalData": "Загальний обсяг даних",
       "sent": "Відправлено",
       "sent": "Відправлено",
       "received": "Отримано",
       "received": "Отримано",
       "xraySwitchVersionDialog": "Ви дійсно хочете змінити версію Xray?",
       "xraySwitchVersionDialog": "Ви дійсно хочете змінити версію Xray?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
       "exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
       "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
       "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
       "migrationDownload": "Завантажити файл міграції",
       "migrationDownload": "Завантажити файл міграції",
-      "migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
+      "migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite.",
+      "avg": "середнє",
+      "peak": "пік",
+      "free": "вільно",
+      "openSockets": "відкритих сокетів",
+      "throughputSub": "Разом за інтерфейсом",
+      "avgWindow": "Середнє за період",
+      "healthWarm": "{list} — підвищене навантаження",
+      "healthCritical": "{list} — критичний рівень",
+      "panel": "Панель",
+      "threads": "Потоки",
+      "uptime": "Час роботи"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Всього надісланих/отриманих",
       "totalDownUp": "Всього надісланих/отриманих",

+ 12 - 8
internal/web/translation/vi-VN.json

@@ -106,7 +106,6 @@
     "routing": "Định tuyến",
     "routing": "Định tuyến",
     "outbounds": "Outbound",
     "outbounds": "Outbound",
     "apiDocs": "Tài liệu API",
     "apiDocs": "Tài liệu API",
-    "link": "Quản lý",
     "donate": "Quyên góp",
     "donate": "Quyên góp",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Tài liệu",
     "docs": "Tài liệu",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "Bộ xử lý logic",
-      "frequency": "Tần số",
       "swap": "Swap",
       "swap": "Swap",
       "storage": "Lưu trữ",
       "storage": "Lưu trữ",
       "memory": "Bộ nhớ",
       "memory": "Bộ nhớ",
-      "threads": "Luồng",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "Dừng",
       "stopXray": "Dừng",
       "restartXray": "Khởi động lại",
       "restartXray": "Khởi động lại",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Đang chạy",
       "xrayStatusRunning": "Đang chạy",
       "xrayStatusStop": "Dừng",
       "xrayStatusStop": "Dừng",
       "xrayStatusError": "Lỗi",
       "xrayStatusError": "Lỗi",
-      "operationHours": "Thời gian hoạt động",
       "systemHistoryTitle": "Lịch sử hệ thống",
       "systemHistoryTitle": "Lịch sử hệ thống",
       "historyTitleCpu": "Mức sử dụng CPU",
       "historyTitleCpu": "Mức sử dụng CPU",
       "historyTitleMem": "Mức sử dụng bộ nhớ",
       "historyTitleMem": "Mức sử dụng bộ nhớ",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Tải",
       "historyTabLoad": "Tải",
       "historyTabConnections": "Kết nối",
       "historyTabConnections": "Kết nối",
       "historyTabDiskUsage": "Sử dụng đĩa",
       "historyTabDiskUsage": "Sử dụng đĩa",
-      "charts": "Biểu đồ",
       "xrayMetricsTitle": "Chỉ số Xray",
       "xrayMetricsTitle": "Chỉ số Xray",
       "xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
       "xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
       "xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
       "xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
@@ -200,7 +194,6 @@
       "overallSpeed": "Tốc độ tổng thể",
       "overallSpeed": "Tốc độ tổng thể",
       "upload": "Tải lên",
       "upload": "Tải lên",
       "download": "Tải xuống",
       "download": "Tải xuống",
-      "totalData": "Tổng dữ liệu",
       "sent": "Đã gửi",
       "sent": "Đã gửi",
       "received": "Đã nhận",
       "received": "Đã nhận",
       "xraySwitchVersionDialog": "Bạn có chắc chắn muốn thay đổi phiên bản Xray không?",
       "xraySwitchVersionDialog": "Bạn có chắc chắn muốn thay đổi phiên bản Xray không?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
       "exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
       "importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
       "importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
       "migrationDownload": "Tải tệp di trú",
       "migrationDownload": "Tải tệp di trú",
-      "migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
+      "migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite.",
+      "avg": "TB",
+      "peak": "đỉnh",
+      "free": "trống",
+      "openSockets": "socket đang mở",
+      "throughputSub": "Tổng theo giao diện mạng",
+      "avgWindow": "Trung bình trong khoảng",
+      "healthWarm": "{list} — đang cao",
+      "healthCritical": "{list} — nguy cấp",
+      "panel": "Panel",
+      "threads": "Luồng",
+      "uptime": "Thời gian chạy"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "Tổng tải lên/tải xuống",
       "totalDownUp": "Tổng tải lên/tải xuống",

+ 12 - 8
internal/web/translation/zh-CN.json

@@ -106,7 +106,6 @@
     "routing": "路由",
     "routing": "路由",
     "outbounds": "出站",
     "outbounds": "出站",
     "apiDocs": "API 文档",
     "apiDocs": "API 文档",
-    "link": "管理",
     "donate": "捐赠",
     "donate": "捐赠",
     "hosts": "主机",
     "hosts": "主机",
     "docs": "文档",
     "docs": "文档",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "逻辑处理器",
-      "frequency": "频率",
       "swap": "交换空间",
       "swap": "交换空间",
       "storage": "存储",
       "storage": "存储",
       "memory": "内存",
       "memory": "内存",
-      "threads": "线程",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "停止",
       "stopXray": "停止",
       "restartXray": "重启",
       "restartXray": "重启",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "运行中",
       "xrayStatusRunning": "运行中",
       "xrayStatusStop": "停止",
       "xrayStatusStop": "停止",
       "xrayStatusError": "错误",
       "xrayStatusError": "错误",
-      "operationHours": "系统正常运行时间",
       "systemHistoryTitle": "系统历史",
       "systemHistoryTitle": "系统历史",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleMem": "内存使用率",
       "historyTitleMem": "内存使用率",
@@ -171,7 +166,6 @@
       "historyTabLoad": "负载",
       "historyTabLoad": "负载",
       "historyTabConnections": "连接数",
       "historyTabConnections": "连接数",
       "historyTabDiskUsage": "磁盘使用量",
       "historyTabDiskUsage": "磁盘使用量",
-      "charts": "图表",
       "xrayMetricsTitle": "Xray 指标",
       "xrayMetricsTitle": "Xray 指标",
       "xrayTitleHeap": "已分配的堆内存",
       "xrayTitleHeap": "已分配的堆内存",
       "xrayTitleSys": "向操作系统保留的内存",
       "xrayTitleSys": "向操作系统保留的内存",
@@ -200,7 +194,6 @@
       "overallSpeed": "整体速度",
       "overallSpeed": "整体速度",
       "upload": "上传",
       "upload": "上传",
       "download": "下载",
       "download": "下载",
-      "totalData": "总数据",
       "sent": "已发送",
       "sent": "已发送",
       "received": "已接收",
       "received": "已接收",
       "xraySwitchVersionDialog": "您确定要更改 Xray 版本吗?",
       "xraySwitchVersionDialog": "您确定要更改 Xray 版本吗?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
       "exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
       "importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
       "importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
       "migrationDownload": "下载迁移文件",
       "migrationDownload": "下载迁移文件",
-      "migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
+      "migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。",
+      "avg": "均值",
+      "peak": "峰值",
+      "free": "剩余",
+      "openSockets": "打开的套接字",
+      "throughputSub": "网卡总计",
+      "avgWindow": "窗口均值",
+      "healthWarm": "{list} — 偏高",
+      "healthCritical": "{list} — 危险",
+      "panel": "面板",
+      "threads": "线程",
+      "uptime": "运行时间"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "总上传 / 下载",
       "totalDownUp": "总上传 / 下载",

+ 12 - 8
internal/web/translation/zh-TW.json

@@ -106,7 +106,6 @@
     "routing": "路由",
     "routing": "路由",
     "outbounds": "出站",
     "outbounds": "出站",
     "apiDocs": "API 文件",
     "apiDocs": "API 文件",
-    "link": "管理",
     "donate": "捐贈",
     "donate": "捐贈",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "文件",
     "docs": "文件",
@@ -127,12 +126,9 @@
     },
     },
     "index": {
     "index": {
       "cpu": "CPU",
       "cpu": "CPU",
-      "logicalProcessors": "邏輯處理器",
-      "frequency": "頻率",
       "swap": "交換空間",
       "swap": "交換空間",
       "storage": "儲存",
       "storage": "儲存",
       "memory": "記憶體",
       "memory": "記憶體",
-      "threads": "執行緒",
       "xrayStatus": "Xray",
       "xrayStatus": "Xray",
       "stopXray": "停止",
       "stopXray": "停止",
       "restartXray": "重新啟動",
       "restartXray": "重新啟動",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "運行中",
       "xrayStatusRunning": "運行中",
       "xrayStatusStop": "停止",
       "xrayStatusStop": "停止",
       "xrayStatusError": "錯誤",
       "xrayStatusError": "錯誤",
-      "operationHours": "系統正常執行時間",
       "systemHistoryTitle": "系統歷史",
       "systemHistoryTitle": "系統歷史",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleCpu": "CPU 使用率",
       "historyTitleMem": "記憶體使用率",
       "historyTitleMem": "記憶體使用率",
@@ -171,7 +166,6 @@
       "historyTabLoad": "負載",
       "historyTabLoad": "負載",
       "historyTabConnections": "連線數",
       "historyTabConnections": "連線數",
       "historyTabDiskUsage": "磁碟使用量",
       "historyTabDiskUsage": "磁碟使用量",
-      "charts": "圖表",
       "xrayMetricsTitle": "Xray 指標",
       "xrayMetricsTitle": "Xray 指標",
       "xrayTitleHeap": "已配置的堆積記憶體",
       "xrayTitleHeap": "已配置的堆積記憶體",
       "xrayTitleSys": "向作業系統保留的記憶體",
       "xrayTitleSys": "向作業系統保留的記憶體",
@@ -200,7 +194,6 @@
       "overallSpeed": "整體速度",
       "overallSpeed": "整體速度",
       "upload": "上傳",
       "upload": "上傳",
       "download": "下載",
       "download": "下載",
-      "totalData": "總數據",
       "sent": "已發送",
       "sent": "已發送",
       "received": "已接收",
       "received": "已接收",
       "xraySwitchVersionDialog": "您確定要變更 Xray 版本嗎?",
       "xraySwitchVersionDialog": "您確定要變更 Xray 版本嗎?",
@@ -250,7 +243,18 @@
       "exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
       "exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
       "importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
       "importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
       "migrationDownload": "下載遷移檔案",
       "migrationDownload": "下載遷移檔案",
-      "migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
+      "migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。",
+      "avg": "均值",
+      "peak": "峰值",
+      "free": "剩餘",
+      "openSockets": "開啟的通訊端",
+      "throughputSub": "網路介面總計",
+      "avgWindow": "視窗均值",
+      "healthWarm": "{list} — 偏高",
+      "healthCritical": "{list} — 危險",
+      "panel": "面板",
+      "threads": "執行緒",
+      "uptime": "執行時間"
     },
     },
     "inbounds": {
     "inbounds": {
       "totalDownUp": "總上傳 / 下載",
       "totalDownUp": "總上傳 / 下載",

+ 11 - 3
internal/xray/process.go

@@ -126,8 +126,9 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
 }
 }
 
 
 type process struct {
 type process struct {
-	// mu guards the process lifecycle fields (cmd, done, exitErr) plus version and
-	// apiPort, which are written by Start/startCommand/refreshVersion/refreshAPIPort
+	// mu guards the process lifecycle fields (cmd, done, exitErr) plus version,
+	// apiPort, and config, which are written by Start/startCommand/refreshVersion/
+	// refreshAPIPort/SetConfig
 	// while being read concurrently by IsRunning/GetErr/GetResult/GetXrayVersion/
 	// while being read concurrently by IsRunning/GetErr/GetResult/GetXrayVersion/
 	// GetAPIPort/Stop from other goroutines (status endpoint, check-xray-running
 	// GetAPIPort/Stop from other goroutines (status endpoint, check-xray-running
 	// and traffic jobs). Snapshot under the lock, then do any blocking syscall
 	// and traffic jobs). Snapshot under the lock, then do any blocking syscall
@@ -219,6 +220,7 @@ func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
 var (
 var (
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second
+	xrayVersionTimeout      = 5 * time.Second
 	// OnCrash is called when xray crashes unexpectedly. Set from web layer.
 	// OnCrash is called when xray crashes unexpectedly. Set from web layer.
 	OnCrash func(err error)
 	OnCrash func(err error)
 )
 )
@@ -296,6 +298,8 @@ func (p *Process) GetAPIPort() int {
 
 
 // GetConfig returns the configuration used by the Xray process.
 // GetConfig returns the configuration used by the Xray process.
 func (p *Process) GetConfig() *Config {
 func (p *Process) GetConfig() *Config {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
 	return p.config
 	return p.config
 }
 }
 
 
@@ -303,6 +307,8 @@ func (p *Process) GetConfig() *Config {
 // process has been reconciled with it through the gRPC API (hot apply), so
 // process has been reconciled with it through the gRPC API (hot apply), so
 // later change detection compares against what is actually running.
 // later change detection compares against what is actually running.
 func (p *Process) SetConfig(config *Config) {
 func (p *Process) SetConfig(config *Config) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
 	p.config = config
 	p.config = config
 }
 }
 
 
@@ -494,7 +500,9 @@ func (p *process) refreshAPIPort() {
 // refreshVersion updates the version string by running the Xray binary with -version.
 // refreshVersion updates the version string by running the Xray binary with -version.
 func (p *process) refreshVersion() {
 func (p *process) refreshVersion() {
 	version := "Unknown"
 	version := "Unknown"
-	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "-version")
+	ctx, cancel := context.WithTimeout(context.Background(), xrayVersionTimeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, GetBinaryPath(), "-version")
 	if data, err := cmd.Output(); err == nil {
 	if data, err := cmd.Output(); err == nil {
 		if datas := bytes.Split(data, []byte(" ")); len(datas) > 1 {
 		if datas := bytes.Split(data, []byte(" ")); len(datas) > 1 {
 			version = string(datas[1])
 			version = string(datas[1])

+ 65 - 0
internal/xray/process_version_test.go

@@ -0,0 +1,65 @@
+package xray
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestRefreshVersionTimesOut(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("shell fixture is Unix-only")
+	}
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	binaryPath := filepath.Join(dir, GetBinaryName())
+	if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexec sleep 1\n"), 0o700); err != nil {
+		t.Fatalf("write xray fixture: %v", err)
+	}
+
+	previousTimeout := xrayVersionTimeout
+	xrayVersionTimeout = 20 * time.Millisecond
+	t.Cleanup(func() { xrayVersionTimeout = previousTimeout })
+
+	p := newProcess(&Config{})
+	started := time.Now()
+	p.refreshVersion()
+	elapsed := time.Since(started)
+	if elapsed < xrayVersionTimeout {
+		t.Fatalf("refreshVersion duration = %s, want at least %s", elapsed, xrayVersionTimeout)
+	}
+	if elapsed > 500*time.Millisecond {
+		t.Fatalf("refreshVersion duration = %s, want under 500ms", elapsed)
+	}
+	if got := p.GetXrayVersion(); got != "Unknown" {
+		t.Fatalf("version = %q, want Unknown", got)
+	}
+}
+
+func TestProcessConfigSnapshotsAreRaceSafe(t *testing.T) {
+	p := NewProcess(&Config{})
+	first := &Config{}
+	second := &Config{}
+	var wg sync.WaitGroup
+
+	wg.Go(func() {
+		for range 1000 {
+			p.SetConfig(first)
+			p.SetConfig(second)
+		}
+	})
+	for range 4 {
+		wg.Go(func() {
+			for range 1000 {
+				if p.GetConfig() == nil {
+					t.Error("config = nil")
+				}
+			}
+		})
+	}
+	wg.Wait()
+}