1
0

7 Commity 87ebcc7a6f ... af5a8e5d40

Autor SHA1 Správa Dátum
  PathGao af5a8e5d40 fix(database): create SQLite backup snapshots online (#6137) 12 hodín pred
  PathGao ad288a7ecc fix(sub): honor trustedProxyCIDRs before forwarded URLs (#6135) 12 hodín pred
  PathGao ad5f2a28cb fix(xray): synchronize lifecycle state (#6138) 12 hodín pred
  PathGao e467b25f03 fix(sub): coalesce external subscription refreshes (#6139) 12 hodín pred
  PathGao 03cc80bb9e fix(mtproto): synchronize child-process lifecycle (#6141) 12 hodín pred
  PathGao 863473783d fix(frontend): preserve cancellation and reject invalid query data (#6143) 12 hodín pred
  Sanaei c3fa73d5a0 feat(ui): redesign the overview page as a trend-first command deck 12 hodín pred
67 zmenil súbory, kde vykonal 2934 pridanie a 965 odobranie
  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 `/…/`).        |
 | `webCertFile` / `webKeyFile` | _(none)_       | TLS certificate + key. When both are set, the panel serves **HTTPS**.   |
 | `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. |
 
 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ی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود).        |
 | `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_   | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود.   |
 | `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 با نام مشخص. |
 
 پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت

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

@@ -19,7 +19,7 @@ icon: SlidersHorizontal
 | `webBasePath`       | `/`                     | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
 | `webCertFile` / `webKeyFile` | _(нет)_        | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
 | `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. |
 
 После изменения порта или базового пути URL панели становится

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

@@ -15,7 +15,7 @@ icon: SlidersHorizontal
 | `webBasePath`       | `/`                     | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。               |
 | `webCertFile` / `webKeyFile` | _(无)_       | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。           |
 | `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、地理/订阅拉取)。 |
 
 更改端口或基础路径后,面板 URL 将变为

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

@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
   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(
   method: string,
   url: string,
@@ -121,8 +143,8 @@ async function performFetch(
   }
 
   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 });
 }

+ 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;
 }
 
+body.light .config-block-text {
+  color: #595959;
+}
+
 .config-block .ant-collapse-extra {
   display: flex;
   align-items: center;

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

@@ -48,6 +48,7 @@ interface SparklineProps {
   yTickStep?: number;
   tickCountX?: number;
   showTooltip?: boolean;
+  showLegend?: boolean;
   valueMin?: number;
   valueMax?: number | null;
   yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
   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.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);
-  if (Number.isNaN(int)) return hex;
+  if (Number.isNaN(int)) return trimmed;
   const r = (int >> 16) & 255;
   const g = (int >> 8) & 255;
   const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
     yTickStep = 25,
     tickCountX = 4,
     showTooltip = false,
+    showLegend = true,
     valueMin = 0,
     valueMax = 100,
     yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
     );
   }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
 
-  useEffect(() => {
-    plotRef.current?.redraw(false);
-  });
-
   useEffect(() => {
     const redraw = () => plotRef.current?.redraw(false);
     const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
           </span>
         </div>
       )}
-      {legendItems.length > 0 && (
+      {showLegend && legendItems.length > 0 && (
         <div className="sparkline-legend" aria-hidden="true">
           {legendItems.map((s) => (
             <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 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');
-  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');
   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 {
   position: sticky;
   top: 0;
@@ -5,6 +12,16 @@
   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,
 .drawer-brand {
   font-weight: 600;
@@ -18,16 +35,12 @@
   align-items: center;
   justify-content: space-between;
   gap: 8px;
-  padding: 14px 16px 14px 24px;
+  height: 58px;
+  padding: 0 16px 0 24px;
   border-bottom: 1px solid var(--ant-color-border-secondary);
   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 {
@@ -37,10 +50,6 @@
   line-height: 1.1;
 }
 
-.sider-brand-collapsed .brand-block {
-  flex: 0 0 auto;
-}
-
 .brand-actions {
   display: inline-flex;
   align-items: center;
@@ -246,11 +255,6 @@
   outline: none;
 }
 
-.sider-version.is-collapsed {
-  justify-content: center;
-  padding: 8px 0;
-}
-
 .drawer-footer {
   flex: 0 0 auto;
   padding: 8px 8px 12px;
@@ -261,8 +265,7 @@
     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;
   }
 
@@ -272,6 +275,11 @@
     min-width: 0 !important;
     width: 0 !important;
   }
+
+  .ant-sidebar {
+    flex: 0 0 0;
+    width: 0;
+  }
 }
 
 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 { useTranslation } from 'react-i18next';
 import { Drawer, Layout, Menu } from 'antd';
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import './AppSidebar.css';
 
-const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const DOCS_URL = 'https://docs.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 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';
 
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
   routing: SwapOutlined,
 };
 
-function readCollapsed(): boolean {
-  try {
-    return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
-  } catch {
-    return false;
-  }
-}
-
 function DonateButton({ ariaLabel }: { ariaLabel: string }) {
   return (
     <a
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
       href={REPO_URL}
       target="_blank"
       rel="noopener noreferrer"
-      className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
+      className="sider-version"
       aria-label={`GitHub ${label}`}
       title={label}
     >
@@ -148,8 +143,23 @@ export default function AppSidebar() {
   const { allSetting } = useAllSettings();
   const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
 
-  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
+  const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
   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 panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +228,7 @@ export default function AppSidebar() {
       if (tab.key === '/xray') {
         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]);
 
@@ -235,13 +245,6 @@ export default function AppSidebar() {
     openLink(String(key));
   }, [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) => {
     pauseAnimationsUntilLeave(id);
     if (!isDark) {
@@ -256,20 +259,24 @@ export default function AppSidebar() {
   }, [isDark, isUltra, toggleTheme, toggleUltra]);
 
   return (
-    <div className="ant-sidebar">
+    <div
+      ref={rootRef}
+      className="ant-sidebar"
+      style={railStyle}
+      onMouseEnter={() => updateHovered(true)}
+      onMouseLeave={() => updateHovered(false)}
+    >
       <Layout.Sider
         theme={currentTheme}
         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">
-            <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
+            <span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
           </div>
-          {!collapsed && (
+          {!railCollapsed && (
             <div className="brand-actions">
               <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -287,7 +294,7 @@ export default function AppSidebar() {
           theme={currentTheme}
           mode="inline"
           selectedKeys={[selectedKey]}
-          openKeys={collapsed ? undefined : openKeys}
+          openKeys={railCollapsed ? undefined : openKeys}
           onOpenChange={(keys) => setOpenKeys(keys as string[])}
           className="sider-nav"
           items={toMenuItems(navItems)}
@@ -302,7 +309,7 @@ export default function AppSidebar() {
           onClick={onMenuClick}
         />
         <div className="sider-footer">
-          <VersionBadge version={panelVersion} collapsed={collapsed} />
+          <VersionBadge version={panelVersion} collapsed={railCollapsed} />
         </div>
       </Layout.Sider>
 

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

@@ -1,5 +1,10 @@
 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 {
   current: number;
   total: number;
@@ -16,9 +21,9 @@ export class CurTotal {
 
   get color(): string {
     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) {
   .index-page .content-area {
     padding: 12px;
     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;
-  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;
-  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;
+  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;
-  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;
 }
 
-.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 { useTranslation } from 'react-i18next';
+import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
 import {
-  Button,
-  Card,
-  Col,
-  ConfigProvider,
-  Layout,
-  message,
-  Modal,
-  Result,
-  Row,
-  Space,
-  Spin,
-  Statistic,
-  Tag,
-  Tooltip,
-} from 'antd';
-import {
-  BarsOutlined,
-  ControlOutlined,
-  CloudServerOutlined,
+  CopyOutlined,
   CloudDownloadOutlined,
-  CloudUploadOutlined,
-  ArrowUpOutlined,
-  ArrowDownOutlined,
-  AreaChartOutlined,
-  GlobalOutlined,
-  SwapOutlined,
-  EyeOutlined,
-  EyeInvisibleOutlined,
-  ThunderboltOutlined,
-  DesktopOutlined,
+  DashboardOutlined,
   DatabaseOutlined,
-  ForkOutlined,
-  CopyOutlined,
-  TelegramFilled,
+  HddOutlined,
+  SwapOutlined,
 } 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 { useStatusQuery } from '@/api/queries/useStatusQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import AppSidebar from '@/layouts/AppSidebar';
 import { LazyMount } from '@/components/utility';
 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';
 const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
 const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
   const [loading, setLoading] = useState(false);
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
+  const history = useOverviewHistory(status, fetched && !fetchError);
+
   useEffect(() => {
     HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
       '/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
     await refresh();
   }, [refresh]);
 
-  function openPanelVersion() {
-    setPanelUpdateOpen(true);
-  }
-
   async function handleChannelChange(dev: boolean) {
     const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
     if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
     if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
   }
 
-  function openTelegram() {
-    window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
-  }
-
   async function openConfig() {
     setLoading(true);
     try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
   }
 
   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 (
     <ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
                   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}
-                      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>
           </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);
   });
+
+  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 {
+  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 {
     if (second < 60) return second.toFixed(0) + 's';
     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 { Msg } from '@/utils';
 
+interface ParseMsgOptions {
+  strict?: boolean;
+}
+
 export function parseMsg<T extends z.ZodType>(
   msg: Msg<unknown>,
   schema: T,
   context: string,
+  options: ParseMsgOptions = {},
 ): Msg<z.infer<T>> {
   if (!msg.success || msg.obj == null) {
     return msg as Msg<z.infer<T>>;
@@ -12,6 +17,7 @@ export function parseMsg<T extends z.ZodType>(
   const result = schema.safeParse(msg.obj);
   if (!result.success) {
     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 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/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
+	github.com/mattn/go-sqlite3 v1.14.48
 	github.com/mymmrac/telego v1.11.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	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/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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 (
 	"bytes"
 	"context"
+	"database/sql"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -12,6 +13,7 @@ import (
 	"os"
 	"os/exec"
 	"path"
+	"path/filepath"
 	"runtime"
 	"slices"
 	"strconv"
@@ -24,6 +26,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
+	"github.com/mattn/go-sqlite3"
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
@@ -32,6 +35,8 @@ import (
 
 var db *gorm.DB
 
+var backupSQLiteTimeout = 2 * time.Minute
+
 const (
 	DialectSQLite   = "sqlite"
 	DialectPostgres = "postgres"
@@ -52,8 +57,9 @@ func Dialect() string {
 }
 
 const (
-	defaultUsername = "admin"
-	defaultPassword = "admin"
+	defaultUsername       = "admin"
+	defaultPassword       = "admin"
+	sqliteBackupDirPrefix = ".x-ui-backup-"
 )
 
 func allModels() []any {
@@ -1944,6 +1950,9 @@ func InitDB(dbPath string) error {
 		if err = os.MkdirAll(dir, 0o755); err != nil {
 			return err
 		}
+		if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
+			log.Printf("clean SQLite backup directories: %v", err)
+		}
 
 		sync := sqliteSynchronous()
 		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 {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	case "OFF":
@@ -2099,11 +2133,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
 	return bytes.Equal(buf, signature), nil
 }
 
-func Checkpoint() error {
+func BackupSQLite(dstPath string) (err error) {
 	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 {

+ 0 - 41
internal/database/journal_mode_test.go

@@ -1,12 +1,8 @@
 package database
 
 import (
-	"bytes"
-	"os"
 	"path/filepath"
 	"testing"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func journalModeOf(t *testing.T) string {
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
 		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())
 			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 {}
 	}
 	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.
 type Process struct {
+	mu              sync.RWMutex
 	cmd             *exec.Cmd
 	done            chan struct{}
 	configPath      string
@@ -126,20 +127,20 @@ func newProcess(configPath, label string) *Process {
 
 // IsRunning reports whether the mtg process is currently running.
 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
 	}
-	if p.done != nil {
+	if done != nil {
 		select {
-		case <-p.done:
+		case <-done:
 			return false
 		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.
@@ -147,8 +148,11 @@ func (p *Process) GetResult() string {
 	if line := p.logWriter.LastLine(); 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 ""
 }
@@ -161,22 +165,27 @@ func (p *Process) Start() error {
 	cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
 	cmd.Stdout = p.logWriter
 	cmd.Stderr = p.logWriter
+	done := make(chan struct{})
+	p.mu.Lock()
 	p.cmd = cmd
-	p.done = make(chan struct{})
+	p.done = done
 	p.exitErr = nil
+	p.mu.Unlock()
 	p.intentionalStop.Store(false)
 	if err := cmd.Start(); err != nil {
-		close(p.done)
+		close(done)
+		p.mu.Lock()
 		p.cmd = nil
+		p.mu.Unlock()
 		return err
 	}
 	attachChildLifetime(cmd)
-	go p.wait(cmd)
+	go p.wait(cmd, done)
 	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()
 	p.logWriter.Flush()
 	if err == nil || p.intentionalStop.Load() {
@@ -184,12 +193,18 @@ func (p *Process) wait(cmd *exec.Cmd) {
 	}
 	if runtime.GOOS == "windows" {
 		if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
-			p.exitErr = err
+			p.setExitErr(err)
 			return
 		}
 	}
 	logger.Errorf("mtproto: mtg process exited: %v", err)
+	p.setExitErr(err)
+}
+
+func (p *Process) setExitErr(err error) {
+	p.mu.Lock()
 	p.exitErr = err
+	p.mu.Unlock()
 }
 
 // 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")
 	}
 	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 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 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) {
-			return p.waitForExit(forceStopTimeout)
+			return waitForExit(done, forceStopTimeout)
 		}
 		return err
 	}
 
-	if err := p.waitForExit(gracefulStopTimeout); err == nil {
+	if err := waitForExit(done, gracefulStopTimeout); err == nil {
 		return nil
 	}
 
 	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 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
 	}
 	timer := time.NewTimer(timeout)
 	defer timer.Stop()
 	select {
-	case <-p.done:
+	case <-done:
 		return nil
 	case <-timer.C:
 		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.
 
 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}
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
 	fetchedAt time.Time
 }
 
+type subscriptionFetch struct {
+	done  chan struct{}
+	links []string
+}
+
 var subscriptionCache = struct {
 	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
 // subscription URL, using a short-lived cache. On any failure it returns the
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
 
 	subscriptionCache.Lock()
 	cached, ok := subscriptionCache.m[rawURL]
-	subscriptionCache.Unlock()
 	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
+		subscriptionCache.Unlock()
 		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)
 	if err != nil {
-		// Serve stale on error rather than dropping the client's configs.
 		if ok {
-			return cached.links
+			fetch.links = cached.links
 		}
-		return nil
+		return fetch.links
 	}
 
 	subscriptionCache.Lock()
 	subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
+	trimSubscriptionCacheLocked(rawURL)
 	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) {

+ 140 - 0
internal/sub/external_subscription_test.go

@@ -4,10 +4,150 @@ import (
 	"errors"
 	"net/http"
 	"net/http/httptest"
+	"strconv"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"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) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		_, _ = 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, host, and header information from an HTTP request.
 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 = "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"
 	}
 
 	// 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
 	}
 	if host == "" {
-		host = c.GetHeader("X-Real-IP")
+		host = forwarded("X-Real-IP")
 	}
 	if host == "" {
 		var err error
@@ -2508,7 +2519,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 	}
 
 	// host:port for URLs
-	hostWithPort = c.GetHeader("X-Forwarded-Host")
+	hostWithPort = forwarded("X-Forwarded-Host")
 	if hostWithPort == "" {
 		hostWithPort = c.Request.Host
 	}
@@ -2517,9 +2528,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 	}
 
 	// header display host
-	hostHeader = c.GetHeader("X-Forwarded-Host")
+	hostHeader = forwarded("X-Forwarded-Host")
 	if hostHeader == "" {
-		hostHeader = c.GetHeader("X-Real-IP")
+		hostHeader = forwarded("X-Real-IP")
 	}
 	if hostHeader == "" {
 		hostHeader = host

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

@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
 }
 
 func trustedProxyCIDRs() (trusted string) {
-	trusted = "127.0.0.1/32,::1/128"
+	trusted = service.DefaultTrustedProxyCIDRs
 	defer func() {
 		_ = 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
 	needRestart := false
 
-	if p != nil {
+	if process := currentXrayProcess(); process != nil {
 		var tags []string
 		err := tx.Table("inbounds").
 			Select("inbounds.tag").
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 		if err != nil {
 			return false, 0, err
 		}
-		_ = s.xrayApi.Init(p.GetAPIPort())
+		_ = s.xrayApi.Init(process.GetAPIPort())
 		for _, tag := range tags {
 			err1 := s.xrayApi.DelInbound(tag)
 			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 {
 			err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
 			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
 		switch {
 		case len(tree) == 0 && len(snap.OnlineEmails) > 0:
@@ -1058,17 +1058,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				tree = remapped
 			}
 		}
-		p.SetNodeOnlineTree(nodeID, tree)
+		process.SetNodeOnlineTree(nodeID, tree)
 	}
 
 	return structuralChange, nil
 }
 
 func (s *InboundService) GetOnlineClients() []string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return []string{}
 	}
-	return p.GetOnlineClients()
+	return process.GetOnlineClients()
 }
 
 // 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,
 // not the intermediate one it was synced through.
 func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		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 != "" {
 			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
 // gate" for that node's inbounds.
 func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return map[string][]string{}
 	}
-	active := p.GetLocalActiveInbounds()
+	active := process.GetLocalActiveInbounds()
 	if len(active) == 0 {
 		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) {
-	if p != nil {
-		p.SetNodeOnlineTree(nodeID, tree)
+	if process := currentXrayProcess(); process != nil {
+		process.SetNodeOnlineTree(nodeID, tree)
 	}
 }
 
 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
 // last_online column.
 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 {
 		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 {
 			return true, int64(len(traffics)), nil
 		}

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

@@ -30,8 +30,8 @@ import (
 // CheckXrayConfig's version gate.
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
 	coreVersion := "Unknown"
-	if p != nil {
-		coreVersion = p.GetXrayVersion()
+	if process := currentXrayProcess(); process != nil {
+		coreVersion = process.GetXrayVersion()
 	}
 	kept := make([]any, 0, len(outbounds))
 	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.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 {
 		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("udpCount", t, float64(status.UdpCount))
 	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))
 	if len(status.Loads) >= 3 {
@@ -1303,25 +1303,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
 	if database.IsPostgres() {
 		return s.exportPostgresDB()
 	}
-	// Update by manually trigger a checkpoint operation
-	err := database.Checkpoint()
+	backupPath, cleanup, err := s.backupSQLite()
 	if err != nil {
 		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 {
-		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
@@ -1421,11 +1422,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
 		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
 	}
-	data, err := database.DumpSQLiteToBytes(config.GetDBPath())
+	defer cleanup()
+	data, err := database.DumpSQLiteToBytes(backupPath)
 	if err != nil {
 		return nil, "", err
 	}

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

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

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

@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
 	if !t.IsRunning() {
 		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 {
-		t.sendBackup(adminId)
+		t.sendBackupData(adminId, dbData, dbFilename)
 		// Add delay between sends to avoid Telegram rate limits
 		if i < len(adminIds)-1 {
 			time.Sleep(1 * time.Second)
@@ -105,7 +110,10 @@ func (t *Tgbot) prepareServerUsageInfo() string {
 		t.lastStatus = t.serverService.GetStatus(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.version", "Version=="+config.GetPanelVersion())
@@ -361,11 +369,12 @@ func (t *Tgbot) notifyExhausted() {
 
 // onlineClients retrieves and sends information about online clients.
 func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
-	if !service.XrayProcess().IsRunning() {
+	process := service.XrayProcess()
+	if process == nil || !process.IsRunning() {
 		return
 	}
 
-	onlines := service.XrayProcess().GetOnlineClients()
+	onlines := process.GetOnlineClients()
 	onlinesCount := len(onlines)
 	output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
 	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.
 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.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
 	t.SendMsgToTgbot(chatId, output)
 
 	// 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)
 		document := tu.Document(
 			tu.ID(chatId),
 			tu.FileFromBytes(dbData, dbFilename),
 		)
-		_, err = bot.SendDocument(ctx, document)
+		_, err := bot.SendDocument(ctx, document)
 		cancel()
 		if err != nil {
 			logger.Error("Error in uploading backup: ", err)
 		}
-	} else {
-		logger.Error("Error in getting db backup: ", err)
 	}
 
 	// Small delay between file sends

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

@@ -20,13 +20,44 @@ import (
 )
 
 var (
-	p                 *xray.Process
 	lock              sync.Mutex
 	isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
 	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.
 // It handles starting, stopping, restarting Xray, and managing its configuration.
 type XrayService struct {
@@ -38,23 +69,25 @@ type XrayService struct {
 
 // IsXrayRunning checks if the Xray process is currently running.
 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
-// 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 {
-	return p
+	return currentXrayProcess()
 }
 
 // GetXrayErr returns the error from the Xray process, if any.
 func (s *XrayService) GetXrayErr() error {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return nil
 	}
 
-	err := p.GetErr()
+	err := process.GetErr()
 	if err == nil {
 		return nil
 	}
@@ -70,17 +103,15 @@ func (s *XrayService) GetXrayErr() error {
 
 // GetXrayResult returns the result string from the Xray process.
 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 ""
 	}
 
-	result = p.GetResult()
+	result := process.GetResult()
 
 	if runtime.GOOS == "windows" && result == "exit status 1" {
 		// exit status 1 on Windows means that Xray process was killed
@@ -88,15 +119,17 @@ func (s *XrayService) GetXrayResult() string {
 		return ""
 	}
 
+	xrayState.storeResult(process, result)
 	return result
 }
 
 // GetXrayVersion returns the version of the running Xray process.
 func (s *XrayService) GetXrayVersion() string {
-	if p == nil {
+	process := currentXrayProcess()
+	if process == nil {
 		return "Unknown"
 	}
-	return p.GetXrayVersion()
+	return process.GetXrayVersion()
 }
 
 // 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.
 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")
 		logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
 		return nil, nil, err
 	}
-	apiPort := p.GetAPIPort()
+	apiPort := process.GetAPIPort()
 	if err := s.xrayAPI.Init(apiPort); err != nil {
 		logger.Debug("Failed to initialize Xray API:", 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
 // capability undecided so a flaky poll can't lock in legacy mode.
 func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return nil, false, nil
 	}
-	if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
+	if process.OnlineAPISupport() == xray.OnlineAPIUnsupported {
 		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)
 		return nil, false, err
 	}
@@ -881,15 +916,15 @@ func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
 	users, err := s.xrayAPI.GetOnlineUsers()
 	if err != nil {
 		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")
 			return nil, false, nil
 		}
 		logger.Debug("Failed to fetch Xray online users:", 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")
 	}
 	return users, true, nil
@@ -911,13 +946,14 @@ type BalancerStatus struct {
 // balancers alongside live ones.
 func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
 	statuses := make([]BalancerStatus, 0, len(tags))
-	if !s.IsXrayRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		for _, tag := range tags {
 			statuses = append(statuses, BalancerStatus{Tag: tag})
 		}
 		return statuses, nil
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return nil, err
 	}
 	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
 // routes traffic through the target balancer via the routing rules.
 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")
 	}
 	if target != "" {
@@ -956,7 +993,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
 			target = resolved
 		}
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return err
 	}
 	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
 // described connection.
 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")
 	}
-	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+	if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
 		return nil, err
 	}
 	defer s.xrayAPI.Close()
@@ -1031,23 +1069,24 @@ func (s *XrayService) RestartXray(isForce bool) error {
 		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() {
 			logger.Debug("It does not need to restart Xray")
 			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")
 			return nil
 		}
-		_ = p.Stop()
+		_ = process.Stop()
 	}
 
-	p = xray.NewProcess(xrayConfig)
-	result = ""
+	process = xray.NewProcess(xrayConfig)
+	xrayState.replace(process)
 	s.xrayAPI.StatsLastValues = nil
-	err = p.Start()
+	err = process.Start()
 	if err != nil {
 		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
 // caller falls back to a full process restart, which cleans up whatever was
 // 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)
 	if !ok {
 		logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
 		return false
 	}
 	if diff.Empty() {
-		p.SetConfig(newCfg)
+		process.SetConfig(newCfg)
 		return true
 	}
 
-	apiPort := p.GetAPIPort()
+	apiPort := process.GetAPIPort()
 	if apiPort <= 0 {
 		return false
 	}
@@ -1131,7 +1170,7 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
 		}
 	}
 
-	p.SetConfig(newCfg)
+	process.SetConfig(newCfg)
 	return true
 }
 
@@ -1192,8 +1231,9 @@ func (s *XrayService) StopXray() error {
 	defer lock.Unlock()
 	isManuallyStopped.Store(true)
 	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")
 }
@@ -1205,14 +1245,14 @@ func (s *XrayService) SetToNeedRestart() {
 
 // GetXrayAPIPort returns the port the local xray process is listening on
 // 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 {
-	if p == nil || !p.IsRunning() {
+	process := currentXrayProcess()
+	if process == nil || !process.IsRunning() {
 		return 0
 	}
-	return p.GetAPIPort()
+	return process.GetAPIPort()
 }
 
 // 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)
 		}
 		coreVersion := "Unknown"
-		if p != nil {
-			coreVersion = p.GetXrayVersion()
+		if process := currentXrayProcess(); process != nil {
+			coreVersion = process.GetXrayVersion()
 		}
 		for _, outbound := range outbounds {
 			if err := xray.ValidateOutboundConfig(outbound); err != nil {

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

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

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

@@ -107,7 +107,6 @@
     "routing": "Routing",
     "outbounds": "Outbounds",
     "apiDocs": "API Docs",
-    "link": "Manage",
     "donate": "Donate",
     "docs": "Documentation",
     "openMenu": "Open menu"
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Logical Processors",
-      "frequency": "Frequency",
       "swap": "Swap",
       "storage": "Storage",
       "memory": "RAM",
-      "threads": "Threads",
       "xrayStatus": "Xray",
       "stopXray": "Stop",
       "restartXray": "Restart",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Running",
       "xrayStatusStop": "Stopped",
       "xrayStatusError": "Error",
-      "operationHours": "Uptime",
       "systemHistoryTitle": "System History",
       "historyTitleCpu": "CPU Usage",
       "historyTitleMem": "Memory Usage",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Load",
       "historyTabConnections": "Connections",
       "historyTabDiskUsage": "Disk Usage",
-      "charts": "Charts",
       "xrayMetricsTitle": "Xray Metrics",
       "xrayTitleHeap": "Allocated Heap Memory",
       "xrayTitleSys": "Memory Reserved from OS",
@@ -200,7 +194,6 @@
       "overallSpeed": "Overall Speed",
       "upload": "Upload",
       "download": "Download",
-      "totalData": "Total Data",
       "sent": "Sent",
       "received": "Received",
       "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.",
       "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",
-      "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": {
       "totalDownUp": "Total Sent/Received",

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

@@ -106,7 +106,6 @@
     "routing": "Enrutamiento",
     "outbounds": "Salidas",
     "apiDocs": "Documentación de la API",
-    "link": "Gestionar",
     "donate": "Donar",
     "hosts": "Hosts",
     "docs": "Documentación",
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Procesadores lógicos",
-      "frequency": "Frecuencia",
       "swap": "Swap",
       "storage": "Almacenamiento",
       "memory": "Memoria",
-      "threads": "Hilos",
       "xrayStatus": "Xray",
       "stopXray": "Detener",
       "restartXray": "Reiniciar",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "En ejecución",
       "xrayStatusStop": "Detenido",
       "xrayStatusError": "Error",
-      "operationHours": "Tiempo de Funcionamiento",
       "systemHistoryTitle": "Historial del Sistema",
       "historyTitleCpu": "Uso de CPU",
       "historyTitleMem": "Uso de Memoria",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Carga",
       "historyTabConnections": "Conexiones",
       "historyTabDiskUsage": "Uso de Disco",
-      "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas de Xray",
       "xrayTitleHeap": "Memoria Heap Asignada",
       "xrayTitleSys": "Memoria Reservada del SO",
@@ -200,7 +194,6 @@
       "overallSpeed": "Velocidad general",
       "upload": "Subida",
       "download": "Descargar",
-      "totalData": "Datos totales",
       "sent": "Enviado",
       "received": "Recibido",
       "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.",
       "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",
-      "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": {
       "totalDownUp": "Subidas/Descargas Totales",

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

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

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

@@ -106,7 +106,6 @@
     "routing": "Pengalihan",
     "outbounds": "Outbound",
     "apiDocs": "Dokumentasi API",
-    "link": "Kelola",
     "donate": "Donasi",
     "hosts": "Host",
     "docs": "Dokumentasi",
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Prosesor logis",
-      "frequency": "Frekuensi",
       "swap": "Swap",
       "storage": "Penyimpanan",
       "memory": "Memori",
-      "threads": "Thread",
       "xrayStatus": "Xray",
       "stopXray": "Hentikan",
       "restartXray": "Mulai ulang",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Berjalan",
       "xrayStatusStop": "Berhenti",
       "xrayStatusError": "Error",
-      "operationHours": "Waktu Aktif",
       "systemHistoryTitle": "Riwayat Sistem",
       "historyTitleCpu": "Penggunaan CPU",
       "historyTitleMem": "Penggunaan Memori",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Beban",
       "historyTabConnections": "Koneksi",
       "historyTabDiskUsage": "Penggunaan Disk",
-      "charts": "Grafik",
       "xrayMetricsTitle": "Metrik Xray",
       "xrayTitleHeap": "Memori Heap Teralokasi",
       "xrayTitleSys": "Memori Dicadangkan dari OS",
@@ -200,7 +194,6 @@
       "overallSpeed": "Kecepatan keseluruhan",
       "upload": "Unggah",
       "download": "Unduh",
-      "totalData": "Total data",
       "sent": "Dikirim",
       "received": "Diterima",
       "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.",
       "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",
-      "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": {
       "totalDownUp": "Total Terkirim/Diterima",

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

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

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

@@ -106,7 +106,6 @@
     "routing": "Roteamento",
     "outbounds": "Saídas",
     "apiDocs": "Documentação da API",
-    "link": "Gerenciar",
     "donate": "Doar",
     "hosts": "Hosts",
     "docs": "Documentação",
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Processadores lógicos",
-      "frequency": "Frequência",
       "swap": "Swap",
       "storage": "Armazenamento",
       "memory": "Memória",
-      "threads": "Threads",
       "xrayStatus": "Xray",
       "stopXray": "Parar",
       "restartXray": "Reiniciar",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Em execução",
       "xrayStatusStop": "Parado",
       "xrayStatusError": "Erro",
-      "operationHours": "Tempo de Atividade",
       "systemHistoryTitle": "Histórico do Sistema",
       "historyTitleCpu": "Uso da CPU",
       "historyTitleMem": "Uso de Memória",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Carga",
       "historyTabConnections": "Conexões",
       "historyTabDiskUsage": "Uso de Disco",
-      "charts": "Gráficos",
       "xrayMetricsTitle": "Métricas do Xray",
       "xrayTitleHeap": "Memória Heap Alocada",
       "xrayTitleSys": "Memória Reservada do SO",
@@ -200,7 +194,6 @@
       "overallSpeed": "Velocidade geral",
       "upload": "Upload",
       "download": "Download",
-      "totalData": "Dados totais",
       "sent": "Enviado",
       "received": "Recebido",
       "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.",
       "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",
-      "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": {
       "totalDownUp": "Total Enviado/Recebido",

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

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

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

@@ -106,7 +106,6 @@
     "routing": "Yönlendirme",
     "outbounds": "Giden Bağlantılar",
     "apiDocs": "API Belgeleri",
-    "link": "Yönet",
     "donate": "Bağış Yap",
     "hosts": "Host'lar",
     "docs": "Belgeler",
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Mantıksal İşlemciler",
-      "frequency": "Frekans",
       "swap": "Takas",
       "storage": "Depolama",
       "memory": "RAM",
-      "threads": "İş Parçacığı",
       "xrayStatus": "Xray",
       "stopXray": "Durdur",
       "restartXray": "Yeniden Başlat",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Çalışıyor",
       "xrayStatusStop": "Durduruldu",
       "xrayStatusError": "Hata",
-      "operationHours": "Çalışma Süresi",
       "systemHistoryTitle": "Sistem Geçmişi",
       "historyTitleCpu": "CPU Kullanımı",
       "historyTitleMem": "Bellek Kullanımı",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Yük",
       "historyTabConnections": "Bağlantılar",
       "historyTabDiskUsage": "Disk Kullanımı",
-      "charts": "Grafikler",
       "xrayMetricsTitle": "Xray Metrikleri",
       "xrayTitleHeap": "Ayrılan Yığın Belleği",
       "xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
@@ -200,7 +194,6 @@
       "overallSpeed": "Genel Hız",
       "upload": "Yükleme",
       "download": "İndirme",
-      "totalData": "Toplam Veri",
       "sent": "Gönderilen",
       "received": "Alınan",
       "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.",
       "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",
-      "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": {
       "totalDownUp": "Toplam Gönderilen/Alınan",

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

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

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

@@ -106,7 +106,6 @@
     "routing": "Định tuyến",
     "outbounds": "Outbound",
     "apiDocs": "Tài liệu API",
-    "link": "Quản lý",
     "donate": "Quyên góp",
     "hosts": "Hosts",
     "docs": "Tài liệu",
@@ -127,12 +126,9 @@
     },
     "index": {
       "cpu": "CPU",
-      "logicalProcessors": "Bộ xử lý logic",
-      "frequency": "Tần số",
       "swap": "Swap",
       "storage": "Lưu trữ",
       "memory": "Bộ nhớ",
-      "threads": "Luồng",
       "xrayStatus": "Xray",
       "stopXray": "Dừng",
       "restartXray": "Khởi động lại",
@@ -153,7 +149,6 @@
       "xrayStatusRunning": "Đang chạy",
       "xrayStatusStop": "Dừng",
       "xrayStatusError": "Lỗi",
-      "operationHours": "Thời gian hoạt động",
       "systemHistoryTitle": "Lịch sử hệ thống",
       "historyTitleCpu": "Mức sử dụng CPU",
       "historyTitleMem": "Mức sử dụng bộ nhớ",
@@ -171,7 +166,6 @@
       "historyTabLoad": "Tải",
       "historyTabConnections": "Kết nối",
       "historyTabDiskUsage": "Sử dụng đĩa",
-      "charts": "Biểu đồ",
       "xrayMetricsTitle": "Chỉ số Xray",
       "xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
       "xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
@@ -200,7 +194,6 @@
       "overallSpeed": "Tốc độ tổng thể",
       "upload": "Tải lên",
       "download": "Tải xuống",
-      "totalData": "Tổng dữ liệu",
       "sent": "Đã gửi",
       "received": "Đã nhận",
       "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.",
       "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ú",
-      "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": {
       "totalDownUp": "Tổng tải lên/tải xuống",

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

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

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

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

+ 11 - 3
internal/xray/process.go

@@ -126,8 +126,9 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
 }
 
 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/
 	// GetAPIPort/Stop from other goroutines (status endpoint, check-xray-running
 	// and traffic jobs). Snapshot under the lock, then do any blocking syscall
@@ -219,6 +220,7 @@ func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
 var (
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second
+	xrayVersionTimeout      = 5 * time.Second
 	// OnCrash is called when xray crashes unexpectedly. Set from web layer.
 	OnCrash func(err error)
 )
@@ -296,6 +298,8 @@ func (p *Process) GetAPIPort() int {
 
 // GetConfig returns the configuration used by the Xray process.
 func (p *Process) GetConfig() *Config {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
 	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
 // later change detection compares against what is actually running.
 func (p *Process) SetConfig(config *Config) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
 	p.config = config
 }
 
@@ -494,7 +500,9 @@ func (p *process) refreshAPIPort() {
 // refreshVersion updates the version string by running the Xray binary with -version.
 func (p *process) refreshVersion() {
 	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 datas := bytes.Split(data, []byte(" ")); len(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()
+}