Explorar o código

feat(ui): redesign the overview page as a trend-first command deck

Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.

The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.

Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
Sanaei hai 13 horas
pai
achega
c3fa73d5a0
Modificáronse 30 ficheiros con 1461 adicións e 759 borrados
  1. 17 9
      frontend/src/components/viz/Sparkline.tsx
  2. 27 19
      frontend/src/layouts/AppSidebar.css
  3. 38 31
      frontend/src/layouts/AppSidebar.tsx
  4. 8 3
      frontend/src/models/status.ts
  5. 74 0
      frontend/src/pages/index/ConnectionsCard.tsx
  6. 443 24
      frontend/src/pages/index/IndexPage.css
  7. 123 308
      frontend/src/pages/index/IndexPage.tsx
  8. 163 0
      frontend/src/pages/index/OverviewActionBar.tsx
  9. 0 9
      frontend/src/pages/index/StatusCard.css
  10. 0 115
      frontend/src/pages/index/StatusCard.tsx
  11. 97 0
      frontend/src/pages/index/SystemStrip.tsx
  12. 97 0
      frontend/src/pages/index/ThroughputCard.tsx
  13. 75 0
      frontend/src/pages/index/VitalTile.tsx
  14. 0 14
      frontend/src/pages/index/XrayStatusCard.css
  15. 0 123
      frontend/src/pages/index/XrayStatusCard.tsx
  16. 135 0
      frontend/src/pages/index/useOverviewHistory.ts
  17. 8 0
      frontend/src/utils/index.ts
  18. 12 8
      internal/web/translation/ar-EG.json
  19. 12 8
      internal/web/translation/en-US.json
  20. 12 8
      internal/web/translation/es-ES.json
  21. 12 8
      internal/web/translation/fa-IR.json
  22. 12 8
      internal/web/translation/id-ID.json
  23. 12 8
      internal/web/translation/ja-JP.json
  24. 12 8
      internal/web/translation/pt-BR.json
  25. 12 8
      internal/web/translation/ru-RU.json
  26. 12 8
      internal/web/translation/tr-TR.json
  27. 12 8
      internal/web/translation/uk-UA.json
  28. 12 8
      internal/web/translation/vi-VN.json
  29. 12 8
      internal/web/translation/zh-CN.json
  30. 12 8
      internal/web/translation/zh-TW.json

+ 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>

+ 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]);
+}

+ 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';

+ 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": "總上傳 / 下載",