2 Achegas 15faec6258 ... 8ee79cf447

Autor SHA1 Mensaxe Data
  MHSanaei 8ee79cf447 refactor(frontend): replace recharts with uPlot for charts hai 10 horas
  MHSanaei bc309ed9f8 refactor(frontend): replace axios with the native Fetch API hai 11 horas

+ 2 - 2
CONTRIBUTING.md

@@ -151,7 +151,7 @@ Panel navigation happens client-side through React Router, and per-route code is
 - **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
 - **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder).
 - **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas.
-- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`.
+- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin `fetch` wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The `fetch` setup itself (base path, CSRF, 401/403 handling) lives in `src/api/http-init.ts`.
 
 ### i18n
 
@@ -210,7 +210,7 @@ frontend/
     ├── pages/             — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
     ├── components/        — cross-page React components
     ├── hooks/             — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
-    ├── api/               — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
+    ├── api/               — fetch client + CSRF handling, TanStack Query provider/keys, WebSocket client
     ├── i18n/              — react-i18next bootstrap (JSON lives in internal/web/translation/)
     ├── lib/xray/          — pure xray logic: link generation, defaults, form ⇄ wire adapters
     ├── schemas/           — Zod source of truth for the xray config model

+ 4 - 4
docs/architecture.md

@@ -62,8 +62,8 @@ Two key ideas that explain most of the complexity:
 
 **Frontend (`frontend/`):**
 - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
-- Data layer: **TanStack Query** (`@tanstack/react-query`) over **axios**; **Zod 4** schemas.
-- Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**.
+- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
+- Router: **react-router-dom 7**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
 - **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
   embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
   `login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
@@ -79,7 +79,7 @@ from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/we
 ### 3.1 Admin API request (e.g. "add a client")
 
 ```
-Browser (React, axios)
+Browser (React, fetch)
   → POST {basePath}/panel/api/...
     → Gin engine (internal/web/web.go: initRouter)
       → middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt)
@@ -261,7 +261,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │       │   ├── xray/         #   raw Xray config UI (routing, dns, outbounds, balancers, overrides)
 │       │   ├── index/        #   dashboard/home
 │       │   └── settings/, groups/, sub/, login/, api-docs/
-│       ├── api/              # ⭐ Data layer: axios-init, QueryProvider, queryKeys, websocket bridge
+│       ├── api/              # ⭐ Data layer: http-init, QueryProvider, queryKeys, websocket bridge
 │       │   └── queries/      #   TanStack Query hooks (useNodesQuery, useStatusQuery, …)
 │       ├── schemas/          # Zod schemas: protocols, forms, api, primitives
 │       ├── generated/        # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts

+ 1 - 1
frontend/README.md

@@ -91,7 +91,7 @@ frontend/
     ├── layouts/         # AdminLayout (sidebar + header + outlet)
     ├── components/      # Cross-page React components
     ├── hooks/           # useClients, useTheme, useWebSocket, …
-    ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
+    ├── api/             # fetch client + CSRF handling, TanStack Query bridge,
     │                    #   WebSocket client + queryClient.ts
     ├── i18n/            # react-i18next init (locales in internal/web/translation/)
     ├── lib/xray/        # Pure functions: link generation, defaults,

+ 13 - 350
frontend/package-lock.json

@@ -15,19 +15,17 @@
         "@tanstack/react-query": "^5.101.2",
         "@tanstack/react-query-devtools": "^5.101.2",
         "antd": "^6.5.0",
-        "axios": "^1.18.1",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
         "i18next": "^26.3.4",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
-        "qs": "^6.15.3",
         "react": "^19.2.7",
         "react-dom": "^19.2.7",
         "react-i18next": "^17.0.8",
         "react-router-dom": "^7.18.1",
-        "recharts": "^3.9.1",
         "swagger-ui-react": "^5.32.8",
+        "uplot": "^1.6.32",
         "zod": "^4.4.3"
       },
       "devDependencies": {
@@ -1818,32 +1816,6 @@
         "react-dom": ">=18.0.0"
       }
     },
-    "node_modules/@reduxjs/toolkit": {
-      "version": "2.12.0",
-      "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
-      "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
-      "license": "MIT",
-      "dependencies": {
-        "@standard-schema/spec": "^1.0.0",
-        "@standard-schema/utils": "^0.3.0",
-        "immer": "^11.0.0",
-        "redux": "^5.0.1",
-        "redux-thunk": "^3.1.0",
-        "reselect": "^5.1.0"
-      },
-      "peerDependencies": {
-        "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
-        "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
-      },
-      "peerDependenciesMeta": {
-        "react": {
-          "optional": true
-        },
-        "react-redux": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@rolldown/binding-android-arm64": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -2137,12 +2109,7 @@
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
       "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
-      "license": "MIT"
-    },
-    "node_modules/@standard-schema/utils": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
-      "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@swagger-api/apidom-ast": {
@@ -2934,69 +2901,6 @@
         "assertion-error": "^2.0.1"
       }
     },
-    "node_modules/@types/d3-array": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
-      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-color": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
-      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-ease": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
-      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-interpolate": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
-      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-color": "*"
-      }
-    },
-    "node_modules/@types/d3-path": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
-      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-scale": {
-      "version": "4.0.9",
-      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
-      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-time": "*"
-      }
-    },
-    "node_modules/@types/d3-shape": {
-      "version": "3.1.8",
-      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
-      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-path": "*"
-      }
-    },
-    "node_modules/@types/d3-time": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
-      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-timer": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
-      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
-      "license": "MIT"
-    },
     "node_modules/@types/deep-eql": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -4261,127 +4165,6 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
-    "node_modules/d3-array": {
-      "version": "3.2.4",
-      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
-      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
-      "license": "ISC",
-      "dependencies": {
-        "internmap": "1 - 2"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-color": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
-      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-ease": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
-      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-format": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
-      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-interpolate": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
-      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-color": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-path": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
-      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-scale": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
-      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-array": "2.10.0 - 3",
-        "d3-format": "1 - 3",
-        "d3-interpolate": "1.2.0 - 3",
-        "d3-time": "2.1.1 - 3",
-        "d3-time-format": "2 - 4"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-shape": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
-      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-path": "^3.1.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-time": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
-      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-array": "2 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-time-format": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
-      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-time": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-timer": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
-      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/damerau-levenshtein": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -4487,12 +4270,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/decimal.js-light": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
-      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
-      "license": "MIT"
-    },
     "node_modules/decode-named-character-reference": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -4835,16 +4612,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/es-toolkit": {
-      "version": "1.49.0",
-      "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
-      "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
-      "license": "MIT",
-      "workspaces": [
-        "docs",
-        "benchmarks"
-      ]
-    },
     "node_modules/escalade": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5124,12 +4891,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/eventemitter3": {
-      "version": "5.0.4",
-      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
-      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
-      "license": "MIT"
-    },
     "node_modules/expect-type": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -5748,16 +5509,6 @@
         "node": ">= 4"
       }
     },
-    "node_modules/immer": {
-      "version": "11.1.9",
-      "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
-      "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
-      "license": "MIT",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/immer"
-      }
-    },
     "node_modules/immutable": {
       "version": "3.8.3",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz",
@@ -5798,15 +5549,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/internmap": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
-      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/invariant": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -7060,6 +6802,7 @@
       "version": "1.13.4",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
       "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">= 0.4"
@@ -7474,22 +7217,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/qs": {
-      "version": "6.15.3",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
-      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "es-define-property": "^1.0.1",
-        "side-channel": "^1.1.1"
-      },
-      "engines": {
-        "node": ">=0.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/querystringify": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -7650,13 +7377,6 @@
         "react": "^18.0.0 || ^19.0.0"
       }
     },
-    "node_modules/react-is": {
-      "version": "19.2.7",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
-      "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
-      "license": "MIT",
-      "peer": true
-    },
     "node_modules/react-redux": {
       "version": "9.3.0",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
@@ -7738,36 +7458,6 @@
         "react": ">= 0.14.0"
       }
     },
-    "node_modules/recharts": {
-      "version": "3.9.1",
-      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz",
-      "integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==",
-      "license": "MIT",
-      "workspaces": [
-        "www"
-      ],
-      "dependencies": {
-        "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
-        "clsx": "^2.1.1",
-        "decimal.js-light": "^2.5.1",
-        "es-toolkit": "^1.39.3",
-        "eventemitter3": "^5.0.1",
-        "immer": "^11.1.8",
-        "react-redux": "8.x.x || 9.x.x",
-        "reselect": "5.2.0",
-        "tiny-invariant": "^1.3.3",
-        "use-sync-external-store": "^1.2.2",
-        "victory-vendor": "^37.0.2"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "peerDependencies": {
-        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
-        "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-      }
-    },
     "node_modules/redux": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -7783,15 +7473,6 @@
         "immutable": "^3.8.1 || ^4.0.0-rc.1"
       }
     },
-    "node_modules/redux-thunk": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
-      "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
-      "license": "MIT",
-      "peerDependencies": {
-        "redux": "^5.0.0"
-      }
-    },
     "node_modules/reflect.getprototypeof": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -8190,6 +7871,7 @@
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
       "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -8209,6 +7891,7 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
       "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -8225,6 +7908,7 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
       "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "call-bound": "^1.0.2",
@@ -8243,6 +7927,7 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
       "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "call-bound": "^1.0.2",
@@ -8516,12 +8201,6 @@
         "node": ">=12.22"
       }
     },
-    "node_modules/tiny-invariant": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
-      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
-      "license": "MIT"
-    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -8910,6 +8589,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/uplot": {
+      "version": "1.6.32",
+      "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
+      "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
+      "license": "MIT"
+    },
     "node_modules/uri-js": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -8939,28 +8624,6 @@
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
-    "node_modules/victory-vendor": {
-      "version": "37.3.6",
-      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
-      "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
-      "license": "MIT AND ISC",
-      "dependencies": {
-        "@types/d3-array": "^3.0.3",
-        "@types/d3-ease": "^3.0.0",
-        "@types/d3-interpolate": "^3.0.1",
-        "@types/d3-scale": "^4.0.2",
-        "@types/d3-shape": "^3.1.0",
-        "@types/d3-time": "^3.0.0",
-        "@types/d3-timer": "^3.0.0",
-        "d3-array": "^3.1.6",
-        "d3-ease": "^3.0.1",
-        "d3-interpolate": "^3.0.1",
-        "d3-scale": "^4.0.2",
-        "d3-shape": "^3.1.0",
-        "d3-time": "^3.0.0",
-        "d3-timer": "^3.0.1"
-      }
-    },
     "node_modules/vite": {
       "version": "8.1.3",
       "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",

+ 1 - 3
frontend/package.json

@@ -28,19 +28,17 @@
     "@tanstack/react-query": "^5.101.2",
     "@tanstack/react-query-devtools": "^5.101.2",
     "antd": "^6.5.0",
-    "axios": "^1.18.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
     "i18next": "^26.3.4",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
-    "qs": "^6.15.3",
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",
     "react-router-dom": "^7.18.1",
-    "recharts": "^3.9.1",
     "swagger-ui-react": "^5.32.8",
+    "uplot": "^1.6.32",
     "zod": "^4.4.3"
   },
   "devDependencies": {

+ 1 - 1
frontend/public/openapi.json

@@ -10124,7 +10124,7 @@
         "tags": [
           "Xray Settings"
         ],
-        "summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).",
+        "summary": "Delete an outbound subscription by id (POST alias of DELETE for clients that cannot send DELETE).",
         "operationId": "post_panel_api_xray_outbound_subs_id_del",
         "parameters": [
           {

+ 0 - 123
frontend/src/api/axios-init.ts

@@ -1,123 +0,0 @@
-import axios from 'axios';
-import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
-import qs from 'qs';
-
-const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
-const CSRF_TOKEN_PATH = '/csrf-token';
-
-let csrfToken: string | null = null;
-let csrfFetchPromise: Promise<string | null> | null = null;
-let sessionExpired = false;
-
-type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
-
-function readMetaToken(): string | null {
-  return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
-}
-
-async function fetchCsrfToken(): Promise<string | null> {
-  try {
-    const basePath = window.X_UI_BASE_PATH;
-    const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
-      ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
-      : CSRF_TOKEN_PATH);
-    const res = await fetch(url, {
-      method: 'GET',
-      credentials: 'same-origin',
-      headers: { 'X-Requested-With': 'XMLHttpRequest' },
-    });
-    if (!res.ok) return null;
-    const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
-    return json?.success && typeof json.obj === 'string' ? json.obj : null;
-  } catch {
-    return null;
-  }
-}
-
-async function ensureCsrfToken(): Promise<string | null> {
-  if (csrfToken) return csrfToken;
-  const meta = readMetaToken();
-  if (meta) {
-    csrfToken = meta;
-    return csrfToken;
-  }
-  if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
-  const fetched = await csrfFetchPromise;
-  csrfFetchPromise = null;
-  if (fetched) csrfToken = fetched;
-  return csrfToken;
-}
-
-export function setupAxios(): void {
-  axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
-  axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
-
-  let basePath: string | null | undefined = window.X_UI_BASE_PATH;
-  if (!basePath) {
-    const metaTag = document.querySelector('meta[name="base-path"]');
-    basePath = metaTag ? metaTag.getAttribute('content') : null;
-  }
-  if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
-    axios.defaults.baseURL = basePath;
-  }
-
-  csrfToken = readMetaToken();
-
-  axios.interceptors.request.use(
-    async (config: InternalAxiosRequestConfig) => {
-      const method = (config.method || 'get').toUpperCase();
-      if (!SAFE_METHODS.has(method)) {
-        const token = await ensureCsrfToken();
-        if (token) config.headers.set('X-CSRF-Token', token);
-      }
-      if (config.data instanceof FormData) {
-        config.headers.set('Content-Type', 'multipart/form-data');
-      } else {
-        const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || '');
-        if (declaredType.toLowerCase().startsWith('application/json')) {
-          if (config.data !== undefined && typeof config.data !== 'string') {
-            config.data = JSON.stringify(config.data);
-          }
-        } else {
-          config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
-        }
-      }
-      return config;
-    },
-    (error: unknown) => Promise.reject(error),
-  );
-
-  axios.interceptors.response.use(
-    (response: AxiosResponse) => response,
-    async (error: AxiosError) => {
-      const status = error.response?.status;
-      if (status === 401) {
-        if (!sessionExpired) {
-          sessionExpired = true;
-          const basePath = window.X_UI_BASE_PATH || '/';
-          window.location.replace(basePath);
-        }
-        return new Promise(() => {});
-      }
-      const cfg = error.config as CsrfAwareConfig | undefined;
-      if (status === 403 && cfg && !cfg.__csrfRetried) {
-        csrfToken = null;
-        cfg.__csrfRetried = true;
-        const token = await ensureCsrfToken();
-        if (token) {
-          cfg.headers.set('X-CSRF-Token', token);
-          const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
-          if (typeof cfg.data === 'string') {
-            if (declaredType.toLowerCase().startsWith('application/json')) {
-              try { cfg.data = JSON.parse(cfg.data); } catch {}
-            } else {
-              cfg.data = qs.parse(cfg.data);
-            }
-          }
-          return axios(cfg);
-        }
-      }
-      return Promise.reject(error);
-    },
-  );
-}

+ 184 - 0
frontend/src/api/http-init.ts

@@ -0,0 +1,184 @@
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
+const CSRF_TOKEN_PATH = '/csrf-token';
+
+let csrfToken: string | null = null;
+let csrfFetchPromise: Promise<string | null> | null = null;
+let sessionExpired = false;
+let basePathPrefix = '';
+
+export interface HttpResponse {
+  ok: boolean;
+  status: number;
+  statusText: string;
+  data: unknown;
+}
+
+export class HttpError extends Error {
+  status: number;
+  response: { status: number; statusText: string; data: unknown };
+
+  constructor(status: number, statusText: string, data: unknown) {
+    super(`Request failed with status ${status}`);
+    this.name = 'HttpError';
+    this.status = status;
+    this.response = { status, statusText, data };
+  }
+}
+
+export interface HttpRequestOptions {
+  headers?: Record<string, string> | Headers;
+  params?: unknown;
+  timeout?: number;
+  signal?: AbortSignal;
+}
+
+function readMetaToken(): string | null {
+  return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
+}
+
+async function fetchCsrfToken(): Promise<string | null> {
+  try {
+    const res = await fetch(basePathPrefix + CSRF_TOKEN_PATH, {
+      method: 'GET',
+      credentials: 'same-origin',
+      headers: { 'X-Requested-With': 'XMLHttpRequest' },
+    });
+    if (!res.ok) return null;
+    const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
+    return json?.success && typeof json.obj === 'string' ? json.obj : null;
+  } catch {
+    return null;
+  }
+}
+
+async function ensureCsrfToken(): Promise<string | null> {
+  if (csrfToken) return csrfToken;
+  const meta = readMetaToken();
+  if (meta) {
+    csrfToken = meta;
+    return csrfToken;
+  }
+  if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
+  const fetched = await csrfFetchPromise;
+  csrfFetchPromise = null;
+  if (fetched) csrfToken = fetched;
+  return csrfToken;
+}
+
+function encodeForm(data: unknown): string {
+  if (data == null || typeof data !== 'object') return '';
+  const parts: string[] = [];
+  const append = (key: string, value: unknown): void => {
+    if (value === undefined) return;
+    if (value === null) {
+      parts.push(`${encodeURIComponent(key)}=`);
+      return;
+    }
+    if (Array.isArray(value)) {
+      value.forEach((item) => append(key, item));
+      return;
+    }
+    if (typeof value === 'object') {
+      Object.entries(value as Record<string, unknown>).forEach(([k, v]) => append(`${key}[${k}]`, v));
+      return;
+    }
+    parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+  };
+  Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
+  return parts.join('&');
+}
+
+async function performFetch(
+  method: string,
+  url: string,
+  data: unknown,
+  options: HttpRequestOptions,
+  csrfOverride?: string,
+): Promise<Response> {
+  const upper = method.toUpperCase();
+  const headers = new Headers(options.headers);
+  headers.set('X-Requested-With', 'XMLHttpRequest');
+
+  let body: BodyInit | undefined;
+  if (data instanceof FormData) {
+    body = data;
+    headers.delete('Content-Type');
+  } else if (!SAFE_METHODS.has(upper)) {
+    const declaredType = (headers.get('Content-Type') || '').toLowerCase();
+    if (declaredType.startsWith('application/json')) {
+      if (data !== undefined) {
+        body = typeof data === 'string' ? data : JSON.stringify(data);
+      }
+    } else {
+      headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
+      body = encodeForm(data);
+    }
+  }
+
+  if (!SAFE_METHODS.has(upper)) {
+    const token = csrfOverride ?? (await ensureCsrfToken());
+    if (token) headers.set('X-CSRF-Token', token);
+  }
+
+  const query = encodeForm(options.params);
+  const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
+  const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
+
+  return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
+}
+
+async function parseBody(res: Response): Promise<unknown> {
+  if (res.status === 204 || res.status === 205) return '';
+  const text = await res.text();
+  if (text === '') return '';
+  const contentType = (res.headers.get('content-type') || '').toLowerCase();
+  if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') {
+    try {
+      return JSON.parse(text);
+    } catch {
+      return text;
+    }
+  }
+  return text;
+}
+
+export async function httpRequest(
+  method: string,
+  url: string,
+  data?: unknown,
+  options: HttpRequestOptions = {},
+): Promise<HttpResponse> {
+  let res = await performFetch(method, url, data, options);
+
+  if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
+    csrfToken = null;
+    const fresh = await ensureCsrfToken();
+    if (fresh) res = await performFetch(method, url, data, options, fresh);
+  }
+
+  if (res.status === 401) {
+    if (!sessionExpired) {
+      sessionExpired = true;
+      window.location.replace(window.X_UI_BASE_PATH || basePathPrefix || '/');
+    }
+    return new Promise<HttpResponse>(() => {});
+  }
+
+  const parsed = await parseBody(res);
+  if (!res.ok) throw new HttpError(res.status, res.statusText, parsed);
+  return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
+}
+
+export function setupHttp(): void {
+  let basePath: string | null | undefined = window.X_UI_BASE_PATH;
+  if (!basePath) {
+    const metaTag = document.querySelector('meta[name="base-path"]');
+    basePath = metaTag ? metaTag.getAttribute('content') : null;
+  }
+  basePathPrefix =
+    typeof basePath === 'string' && basePath !== '' && basePath !== '/'
+      ? basePath.replace(/\/$/, '')
+      : '';
+
+  csrfToken = readMetaToken();
+}

+ 55 - 1
frontend/src/components/viz/Sparkline.css

@@ -1,13 +1,67 @@
-.sparkline-svg {
+.sparkline-plot {
   display: block;
   width: 100%;
 }
 
+.sparkline-plot .uplot,
+.sparkline-plot .u-wrap {
+  width: 100%;
+}
+
+.sparkline-plot .u-cursor-x {
+  border-right: 1px dashed var(--ant-color-border);
+}
+
 .sparkline-container {
   position: relative;
   width: 100%;
 }
 
+.sparkline-tooltip {
+  position: absolute;
+  top: 0;
+  left: 0;
+  pointer-events: none;
+  white-space: nowrap;
+  z-index: 2;
+  padding: 6px 10px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 6px;
+  background: var(--ant-color-bg-elevated);
+  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
+  color: var(--ant-color-text);
+  font-size: 12px;
+}
+
+.sparkline-tooltip .spk-tt-label {
+  margin-bottom: 4px;
+  color: var(--ant-color-text-tertiary);
+  font-size: 11px;
+}
+
+.sparkline-tooltip .spk-tt-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-weight: 500;
+  line-height: 16px;
+}
+
+.sparkline-tooltip .spk-tt-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex: none;
+}
+
+.sparkline-tooltip .spk-tt-name {
+  color: var(--ant-color-text-tertiary);
+}
+
+.sparkline-tooltip .spk-tt-val {
+  margin-left: auto;
+}
+
 .sparkline-extrema {
   position: absolute;
   top: 2px;

+ 420 - 196
frontend/src/components/viz/Sparkline.tsx

@@ -1,15 +1,6 @@
-import { useId, useMemo } from 'react';
-import {
-  Area,
-  AreaChart,
-  CartesianGrid,
-  ReferenceDot,
-  ReferenceLine,
-  ResponsiveContainer,
-  Tooltip,
-  XAxis,
-  YAxis,
-} from 'recharts';
+import { useEffect, useMemo, useRef } from 'react';
+import uPlot from 'uplot';
+import 'uplot/dist/uPlot.min.css';
 import './Sparkline.css';
 
 export interface SparklineReferenceLine {
@@ -26,8 +17,14 @@ export interface SparklineExtrema {
   maxColor?: string;
 }
 
+const DEFAULT_STROKE = '#008771';
+const DEFAULT_STROKE2 = '#722ed1';
+const DEFAULT_STROKE3 = '#a0d911';
 const DEFAULT_MIN_COLOR = '#52c41a';
 const DEFAULT_MAX_COLOR = '#fa541c';
+const GRID_COLOR = 'rgba(128, 128, 140, 0.35)';
+const AXIS_FONT = '10px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
+const LABEL_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
 
 interface SparklineProps {
   data: number[];
@@ -68,41 +65,79 @@ interface ChartPoint {
   label: string;
 }
 
-export default function Sparkline({
-  data,
-  data2 = [],
-  data3 = [],
-  stroke2 = '#722ed1',
-  stroke3 = '#a0d911',
-  name1,
-  name2,
-  name3,
-  labels = [],
-  height = 80,
-  stroke = '#008771',
-  strokeWidth = 2,
-  maxPoints = 120,
-  showGrid = true,
-  fillOpacity = 0.22,
-  showMarker = true,
-  markerRadius = 3,
-  showAxes = false,
-  yTickStep = 25,
-  tickCountX = 4,
-  showTooltip = false,
-  valueMin = 0,
-  valueMax = 100,
-  yFormatter = (v: number) => `${Math.round(v)}%`,
-  tooltipFormatter = null,
-  tooltipLabelFormatter = null,
-  referenceLines,
-  extrema,
-}: SparklineProps) {
-  const reactId = useId();
-  const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
-  const gradId = `spkGrad-${safeId}`;
-  const gradId2 = `spkGrad2-${safeId}`;
-  const gradId3 = `spkGrad3-${safeId}`;
+interface ExtremaResult {
+  min: ChartPoint;
+  max: ChartPoint;
+  minIdx: number;
+  maxIdx: number;
+}
+
+interface SparklineView {
+  points: ChartPoint[];
+  yDomain: [number, number];
+  yTicks: number[] | undefined;
+  xTickIndexes: number[] | undefined;
+  extremaPoints: ExtremaResult | null;
+}
+
+function hexToRgba(hex: string, alpha: number): string {
+  let h = hex.trim();
+  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;
+  const int = Number.parseInt(h, 16);
+  if (Number.isNaN(int)) return hex;
+  const r = (int >> 16) & 255;
+  const g = (int >> 8) & 255;
+  const b = int & 255;
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+function cssVar(el: HTMLElement, name: string, fallback: string): string {
+  const v = getComputedStyle(el).getPropertyValue(name).trim();
+  return v || fallback;
+}
+
+function parseDash(dash: string, dpr: number): number[] {
+  return dash.trim().split(/\s+/).map((n) => (Number(n) || 0) * dpr);
+}
+
+function dprOf(u: uPlot): number {
+  return u.width > 0 ? u.ctx.canvas.width / u.width : (uPlot.pxRatio || 1);
+}
+
+export default function Sparkline(props: SparklineProps) {
+  const {
+    data,
+    data2 = [],
+    data3 = [],
+    stroke = DEFAULT_STROKE,
+    stroke2 = DEFAULT_STROKE2,
+    stroke3 = DEFAULT_STROKE3,
+    name1,
+    name2,
+    name3,
+    labels = [],
+    height = 80,
+    strokeWidth = 2,
+    maxPoints = 120,
+    showGrid = true,
+    fillOpacity = 0.22,
+    showMarker = true,
+    markerRadius = 3,
+    showAxes = false,
+    yTickStep = 25,
+    tickCountX = 4,
+    showTooltip = false,
+    valueMin = 0,
+    valueMax = 100,
+    yFormatter = (v: number) => `${Math.round(v)}%`,
+    tooltipFormatter = null,
+    tooltipLabelFormatter = null,
+    referenceLines,
+    extrema,
+  } = props;
+
   const hasSeries2 = data2.length > 0;
   const hasSeries3 = data3.length > 0;
   const multiSeries = hasSeries2 || hasSeries3;
@@ -135,7 +170,7 @@ export default function Sparkline({
     return [valueMin, max * 1.1];
   }, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
 
-  const yTicks = useMemo(() => {
+  const yTicks = useMemo<number[] | undefined>(() => {
     if (!showAxes) return undefined;
     const [min, max] = yDomain;
     if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
@@ -147,15 +182,13 @@ export default function Sparkline({
     return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
   }, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
 
-  const xTickIndexes = useMemo(() => {
+  const xTickIndexes = useMemo<number[] | undefined>(() => {
     if (!showAxes || points.length === 0) return undefined;
     const m = Math.max(2, tickCountX);
     return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
   }, [showAxes, tickCountX, points.length]);
 
-  const fmtTooltip = tooltipFormatter ?? yFormatter;
-
-  const extremaPoints = useMemo(() => {
+  const extremaPoints = useMemo<ExtremaResult | null>(() => {
     if (!extrema?.show || multiSeries || points.length < 2) return null;
     let minIdx = 0;
     let maxIdx = 0;
@@ -191,6 +224,340 @@ export default function Sparkline({
     return parts.join(', ');
   }, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
 
+  const cfg = {
+    stroke,
+    stroke2,
+    stroke3,
+    strokeWidth,
+    fillOpacity,
+    markerRadius,
+    showGrid,
+    showMarker,
+    showAxes,
+    showTooltip,
+    height,
+    name1,
+    name2,
+    name3,
+    yFormatter,
+    tooltipFormatter,
+    tooltipLabelFormatter,
+    referenceLines,
+    extrema,
+  };
+  const cfgRef = useRef(cfg);
+  cfgRef.current = cfg;
+  const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
+  viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
+
+  const containerRef = useRef<HTMLDivElement>(null);
+  const plotRef = useRef<uPlot | null>(null);
+
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+
+    let tooltipEl: HTMLDivElement | null = null;
+
+    const seriesColor = (idx: number): string => {
+      const p = cfgRef.current;
+      if (idx <= 1) return p.stroke ?? DEFAULT_STROKE;
+      if (idx === 2) return p.stroke2 ?? DEFAULT_STROKE2;
+      return p.stroke3 ?? DEFAULT_STROKE3;
+    };
+
+    const gridTicks = (): number[] => {
+      const yt = viewRef.current.yTicks;
+      if (yt && yt.length) return yt;
+      const [mn, mx] = viewRef.current.yDomain;
+      const n = 4;
+      return Array.from({ length: n + 1 }, (_, i) => mn + ((mx - mn) * i) / n);
+    };
+
+    const buildData = (): uPlot.AlignedData => {
+      const v = viewRef.current;
+      const xs = v.points.map((_, i) => i);
+      const series: number[][] = [v.points.map((p) => p.value)];
+      if (hasSeries2) series.push(v.points.map((p) => p.value2));
+      if (hasSeries3) series.push(v.points.map((p) => p.value3));
+      return [xs, ...series];
+    };
+
+    const makeSeries = (): uPlot.Series => ({
+      stroke: (_u, sidx) => seriesColor(sidx),
+      width: cfgRef.current.strokeWidth ?? 2,
+      fill: (u, sidx) => {
+        const { ctx, bbox } = u;
+        const color = seriesColor(sidx);
+        const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height);
+        grad.addColorStop(0, hexToRgba(color, cfgRef.current.fillOpacity ?? 0.22));
+        grad.addColorStop(1, hexToRgba(color, 0));
+        return grad;
+      },
+      paths: uPlot.paths.spline?.(),
+      points: { show: false },
+      spanGaps: true,
+    });
+
+    const series: uPlot.Series[] = [{}, makeSeries()];
+    if (hasSeries2) series.push(makeSeries());
+    if (hasSeries3) series.push(makeSeries());
+
+    const axisStroke = (u: uPlot) => cssVar(u.root, '--ant-color-text-tertiary', '#8c8c8c');
+
+    const axes: uPlot.Axis[] = [
+      {
+        show: showAxes,
+        stroke: axisStroke,
+        grid: { show: false },
+        ticks: { show: false },
+        font: AXIS_FONT,
+        gap: 6,
+        size: 28,
+        splits: () => viewRef.current.xTickIndexes ?? [],
+        values: (_u, splits) => splits.map((i) => viewRef.current.points[i]?.label ?? ''),
+      },
+      {
+        show: showAxes,
+        scale: 'y',
+        side: 3,
+        stroke: axisStroke,
+        grid: { show: false },
+        ticks: { show: false },
+        font: AXIS_FONT,
+        gap: 4,
+        size: 56,
+        splits: () => viewRef.current.yTicks ?? [],
+        values: (_u, splits) =>
+          splits.map((v) => (cfgRef.current.yFormatter ? cfgRef.current.yFormatter(v) : String(v))),
+      },
+    ];
+
+    const drawGrid = (u: uPlot) => {
+      if (cfgRef.current.showGrid === false) return;
+      const { ctx, bbox } = u;
+      const dpr = dprOf(u);
+      ctx.save();
+      ctx.strokeStyle = GRID_COLOR;
+      ctx.lineWidth = dpr;
+      ctx.setLineDash([3 * dpr, 4 * dpr]);
+      ctx.beginPath();
+      for (const ty of gridTicks()) {
+        const py = Math.round(u.valToPos(ty, 'y', true)) + 0.5;
+        ctx.moveTo(bbox.left, py);
+        ctx.lineTo(bbox.left + bbox.width, py);
+      }
+      ctx.stroke();
+      ctx.restore();
+    };
+
+    const drawOverlay = (u: uPlot) => {
+      const p = cfgRef.current;
+      const v = viewRef.current;
+      const { ctx, bbox } = u;
+      const dpr = dprOf(u);
+      const right = bbox.left + bbox.width;
+
+      if (p.referenceLines?.length) {
+        for (const rl of p.referenceLines) {
+          const color = rl.color || p.stroke || DEFAULT_STROKE;
+          const py = Math.round(u.valToPos(rl.y, 'y', true)) + 0.5;
+          ctx.save();
+          ctx.strokeStyle = color;
+          ctx.lineWidth = 1.4 * dpr;
+          ctx.setLineDash(parseDash(rl.dash ?? '5 4', dpr));
+          ctx.beginPath();
+          ctx.moveTo(bbox.left, py);
+          ctx.lineTo(right, py);
+          ctx.stroke();
+          ctx.restore();
+          if (rl.label) {
+            ctx.save();
+            ctx.fillStyle = color;
+            ctx.font = `600 ${10 * dpr}px ${LABEL_FONT}`;
+            ctx.textAlign = 'right';
+            ctx.textBaseline = 'bottom';
+            ctx.fillText(rl.label, right - 4 * dpr, py - 3 * dpr);
+            ctx.restore();
+          }
+        }
+      }
+
+      const ex = v.extremaPoints;
+      if (p.extrema?.show && ex) {
+        const ringColor = cssVar(u.root, '--ant-color-bg-elevated', '#ffffff');
+        const dot = (value: number, idx: number, color: string) => {
+          const px = u.valToPos(idx, 'x', true);
+          const py = u.valToPos(value, 'y', true);
+          ctx.save();
+          ctx.beginPath();
+          ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2);
+          ctx.fillStyle = color;
+          ctx.fill();
+          ctx.lineWidth = 2 * dpr;
+          ctx.strokeStyle = ringColor;
+          ctx.stroke();
+          ctx.restore();
+        };
+        dot(ex.max.value, ex.maxIdx, p.extrema.maxColor ?? DEFAULT_MAX_COLOR);
+        dot(ex.min.value, ex.minIdx, p.extrema.minColor ?? DEFAULT_MIN_COLOR);
+      }
+    };
+
+    const updateTooltip = (u: uPlot) => {
+      if (!tooltipEl) return;
+      const idx = u.cursor.idx;
+      const v = viewRef.current;
+      const p = cfgRef.current;
+      if (idx == null || idx < 0 || idx >= v.points.length) {
+        tooltipEl.style.display = 'none';
+        return;
+      }
+      const pt = v.points[idx];
+      const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x));
+      const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label);
+      const multi = hasSeries2 || hasSeries3;
+
+      tooltipEl.textContent = '';
+      const labelDiv = document.createElement('div');
+      labelDiv.className = 'spk-tt-label';
+      labelDiv.textContent = label;
+      tooltipEl.appendChild(labelDiv);
+
+      const rows = [
+        { name: p.name1, color: p.stroke ?? DEFAULT_STROKE, val: pt.value, on: true },
+        { name: p.name2, color: p.stroke2 ?? DEFAULT_STROKE2, val: pt.value2, on: hasSeries2 },
+        { name: p.name3, color: p.stroke3 ?? DEFAULT_STROKE3, val: pt.value3, on: hasSeries3 },
+      ];
+      for (const r of rows) {
+        if (!r.on) continue;
+        const row = document.createElement('div');
+        row.className = 'spk-tt-row';
+        if (multi) {
+          const marker = document.createElement('span');
+          marker.className = 'spk-tt-dot';
+          marker.style.background = r.color;
+          row.appendChild(marker);
+          const nm = document.createElement('span');
+          nm.className = 'spk-tt-name';
+          nm.textContent = r.name ?? '';
+          row.appendChild(nm);
+        }
+        const val = document.createElement('span');
+        val.className = 'spk-tt-val';
+        val.textContent = fmt(r.val);
+        row.appendChild(val);
+        tooltipEl.appendChild(row);
+      }
+
+      tooltipEl.style.display = '';
+      const overW = u.over.clientWidth;
+      const overH = u.over.clientHeight;
+      const cx = u.cursor.left ?? 0;
+      const cy = u.cursor.top ?? 0;
+      const w = tooltipEl.offsetWidth;
+      const h = tooltipEl.offsetHeight;
+      let x = cx + 12;
+      if (x + w + 8 > overW) x = cx - w - 12;
+      if (x < 0) x = 4;
+      let y = cy - h - 12;
+      if (y < 0) y = Math.min(cy + 12, overH - h - 4);
+      tooltipEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
+    };
+
+    const opts: uPlot.Options = {
+      width: container.clientWidth || 600,
+      height,
+      padding: [8, 8, showAxes ? 0 : 2, showAxes ? 0 : 2],
+      legend: { show: false },
+      cursor: {
+        show: showTooltip,
+        x: showTooltip,
+        y: false,
+        drag: { x: false, y: false, setScale: false },
+        points: {
+          show: showMarker,
+          size: () => (cfgRef.current.markerRadius ?? 3) * 2,
+          width: 0,
+          stroke: (_u, sidx) => seriesColor(sidx),
+          fill: (_u, sidx) => seriesColor(sidx),
+        },
+      },
+      scales: {
+        x: {
+          time: false,
+          range: (_u, dmin, dmax) => (dmin === dmax ? [dmin - 0.5, dmax + 0.5] : [dmin, dmax]),
+        },
+        y: {
+          range: () => {
+            const [mn, mx] = viewRef.current.yDomain;
+            return [mn, mx];
+          },
+        },
+      },
+      series,
+      axes,
+      hooks: {
+        init: [
+          (u) => {
+            if (!cfgRef.current.showTooltip) return;
+            tooltipEl = document.createElement('div');
+            tooltipEl.className = 'sparkline-tooltip';
+            tooltipEl.style.display = 'none';
+            u.over.appendChild(tooltipEl);
+          },
+        ],
+        drawClear: [drawGrid],
+        draw: [drawOverlay],
+        setCursor: [updateTooltip],
+      },
+    };
+
+    const u = new uPlot(opts, buildData(), container);
+    plotRef.current = u;
+
+    const ro = new ResizeObserver(() => {
+      const w = container.clientWidth;
+      if (w > 0) u.setSize({ width: w, height });
+    });
+    ro.observe(container);
+
+    return () => {
+      ro.disconnect();
+      u.destroy();
+      plotRef.current = null;
+      tooltipEl = null;
+    };
+  }, [hasSeries2, hasSeries3, showAxes, showTooltip, showMarker, height]);
+
+  useEffect(() => {
+    plotRef.current?.setData(
+      (() => {
+        const xs = points.map((_, i) => i);
+        const s: number[][] = [points.map((p) => p.value)];
+        if (hasSeries2) s.push(points.map((p) => p.value2));
+        if (hasSeries3) s.push(points.map((p) => p.value3));
+        return [xs, ...s] as uPlot.AlignedData;
+      })(),
+    );
+  }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
+
+  useEffect(() => {
+    plotRef.current?.redraw(false);
+  });
+
+  useEffect(() => {
+    const redraw = () => plotRef.current?.redraw(false);
+    const moBody = new MutationObserver(redraw);
+    moBody.observe(document.body, { attributes: true, attributeFilter: ['class'] });
+    const moRoot = new MutationObserver(redraw);
+    moRoot.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+    return () => {
+      moBody.disconnect();
+      moRoot.disconnect();
+    };
+  }, []);
+
   return (
     <div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
       {extremaPoints && (
@@ -210,150 +577,7 @@ export default function Sparkline({
           ))}
         </div>
       )}
-      <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
-        <AreaChart
-          data={points}
-          margin={{
-            top: showAxes ? 14 : 6,
-            right: showAxes ? 12 : 6,
-            bottom: showAxes ? 26 : 4,
-            left: 4,
-          }}
-        >
-          <defs>
-            <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
-              <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
-              <stop offset="100%" stopColor={stroke} stopOpacity={0} />
-            </linearGradient>
-            <linearGradient id={gradId2} x1="0" y1="0" x2="0" y2="1">
-              <stop offset="0%" stopColor={stroke2} stopOpacity={fillOpacity} />
-              <stop offset="100%" stopColor={stroke2} stopOpacity={0} />
-            </linearGradient>
-            <linearGradient id={gradId3} x1="0" y1="0" x2="0" y2="1">
-              <stop offset="0%" stopColor={stroke3} stopOpacity={fillOpacity} />
-              <stop offset="100%" stopColor={stroke3} stopOpacity={0} />
-            </linearGradient>
-          </defs>
-          {showGrid && (
-            <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
-          )}
-          <XAxis
-            dataKey="label"
-            hide={!showAxes}
-            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
-            axisLine={false}
-            tickLine={false}
-            tickMargin={14}
-            interval={0}
-            ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
-          />
-          <YAxis
-            domain={yDomain}
-            hide={!showAxes}
-            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
-            axisLine={false}
-            tickLine={false}
-            tickMargin={8}
-            tickFormatter={yFormatter}
-            ticks={yTicks}
-            width={56}
-          />
-          {showTooltip && (
-            <Tooltip
-              cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
-              contentStyle={{
-                background: 'var(--ant-color-bg-elevated)',
-                border: '1px solid var(--ant-color-border-secondary)',
-                borderRadius: 6,
-                fontSize: 12,
-                padding: '6px 10px',
-                boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
-              }}
-              labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
-              itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
-              formatter={(v, name) => [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']}
-              labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
-              separator={multiSeries ? ': ' : ''}
-            />
-          )}
-          {referenceLines?.map((rl, idx) => (
-            <ReferenceLine
-              key={`ref-${idx}-${rl.y}`}
-              y={rl.y}
-              stroke={rl.color || stroke}
-              strokeDasharray={rl.dash || '5 4'}
-              strokeWidth={1.4}
-              label={rl.label ? {
-                value: rl.label,
-                position: 'insideTopRight',
-                fill: rl.color || stroke,
-                fontSize: 10,
-                fontWeight: 600,
-              } : undefined}
-              ifOverflow="extendDomain"
-            />
-          ))}
-          {extremaPoints && (
-            <>
-              <ReferenceDot
-                x={extremaPoints.max.label}
-                y={extremaPoints.max.value}
-                r={4.5}
-                fill={maxColor}
-                stroke="var(--ant-color-bg-elevated)"
-                strokeWidth={2}
-                ifOverflow="extendDomain"
-              />
-              <ReferenceDot
-                x={extremaPoints.min.label}
-                y={extremaPoints.min.value}
-                r={4.5}
-                fill={minColor}
-                stroke="var(--ant-color-bg-elevated)"
-                strokeWidth={2}
-                ifOverflow="extendDomain"
-              />
-            </>
-          )}
-          <Area
-            type="monotone"
-            dataKey="value"
-            name={multiSeries ? name1 : undefined}
-            stroke={stroke}
-            strokeWidth={strokeWidth}
-            fill={`url(#${gradId})`}
-            dot={false}
-            activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
-            isAnimationActive={false}
-          />
-          {hasSeries2 && (
-            <Area
-              type="monotone"
-              dataKey="value2"
-              name={name2}
-              stroke={stroke2}
-              strokeWidth={strokeWidth}
-              fill={`url(#${gradId2})`}
-              dot={false}
-              activeDot={showMarker ? { r: markerRadius, fill: stroke2, strokeWidth: 0 } : false}
-              isAnimationActive={false}
-            />
-          )}
-          {hasSeries3 && (
-            <Area
-              type="monotone"
-              dataKey="value3"
-              name={name3}
-              stroke={stroke3}
-              strokeWidth={strokeWidth}
-              fill={`url(#${gradId3})`}
-              dot={false}
-              activeDot={showMarker ? { r: markerRadius, fill: stroke3, strokeWidth: 0 } : false}
-              isAnimationActive={false}
-            />
-          )}
-        </AreaChart>
-      </ResponsiveContainer>
+      <div ref={containerRef} className="sparkline-plot" style={{ height }} />
     </div>
   );
 }

+ 2 - 2
frontend/src/entries/login.tsx

@@ -2,14 +2,14 @@ import { createRoot } from 'react-dom/client';
 import { message } from 'antd';
 import 'antd/dist/reset.css';
 
-import { setupAxios } from '@/api/axios-init';
+import { setupHttp } from '@/api/http-init';
 import { applyDocumentTitle } from '@/utils';
 import { readyI18n } from '@/i18n/react';
 import { ThemeProvider } from '@/hooks/useTheme';
 import { QueryProvider } from '@/api/QueryProvider';
 import LoginPage from '@/pages/login/LoginPage';
 
-setupAxios();
+setupHttp();
 applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');

+ 0 - 22
frontend/src/env.d.ts

@@ -31,28 +31,6 @@ interface Window {
   __SUB_PAGE_DATA__?: SubPageData;
 }
 
-declare module 'qs' {
-  interface StringifyOptions {
-    arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma';
-    encode?: boolean;
-    encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string;
-    allowDots?: boolean;
-    skipNulls?: boolean;
-    addQueryPrefix?: boolean;
-  }
-  interface ParseOptions {
-    depth?: number;
-    arrayLimit?: number;
-    allowDots?: boolean;
-    parseArrays?: boolean;
-    ignoreQueryPrefix?: boolean;
-  }
-  export function stringify(obj: unknown, options?: StringifyOptions): string;
-  export function parse(str: string, options?: ParseOptions): Record<string, unknown>;
-  const qs: { stringify: typeof stringify; parse: typeof parse };
-  export default qs;
-}
-
 declare module 'persian-calendar-suite' {
   import type { ComponentType, ReactNode } from 'react';
 

+ 2 - 2
frontend/src/main.tsx

@@ -6,13 +6,13 @@ import '@/styles/utils.css';
 import '@/styles/page-shell.css';
 import '@/styles/page-cards.css';
 
-import { setupAxios } from '@/api/axios-init';
+import { setupHttp } from '@/api/http-init';
 import { readyI18n } from '@/i18n/react';
 import { ThemeProvider } from '@/hooks/useTheme';
 import { QueryProvider } from '@/api/QueryProvider';
 import { router } from '@/routes';
 
-setupAxios();
+setupHttp();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

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

@@ -1392,7 +1392,7 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/xray/outbound-subs/:id/del',
-        summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
+        summary: 'Delete an outbound subscription by id (POST alias of DELETE for clients that cannot send DELETE).',
         params: [
           { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
         ],

+ 6 - 3
frontend/src/pages/index/PanelUpdateModal.tsx

@@ -2,7 +2,6 @@ import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Modal, Switch, Tag } from 'antd';
 import { CloudDownloadOutlined } from '@ant-design/icons';
-import axios from 'axios';
 
 import { HttpUtil, PromiseUtil } from '@/utils';
 import { formatPanelVersion } from '@/lib/panel-version';
@@ -53,8 +52,12 @@ export default function PanelUpdateModal({
     const deadline = Date.now() + 90_000;
     while (Date.now() < deadline) {
       try {
-        const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 });
-        const status = r?.data?.obj as PanelUpdateStatus | undefined;
+        const msg = await HttpUtil.get<PanelUpdateStatus>(
+          '/panel/api/server/getUpdateStatus',
+          undefined,
+          { silent: true, timeout: 2000 },
+        );
+        const status = msg?.obj ?? undefined;
         if (status?.runId === expectedRunId) {
           if (status.state === 'success') return 'success';
           if (status.state === 'failed') return 'failed';

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

@@ -0,0 +1,196 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+type HttpModule = typeof import('@/api/http-init');
+
+const okEnvelope = (obj: unknown = {}): Response =>
+  new Response(JSON.stringify({ success: true, msg: '', obj }), {
+    status: 200,
+    headers: { 'content-type': 'application/json' },
+  });
+
+const csrfResponse = (token: string): Response =>
+  new Response(JSON.stringify({ success: true, obj: token }), {
+    status: 200,
+    headers: { 'content-type': 'application/json' },
+  });
+
+describe('http-init fetch wrapper', () => {
+  let http: HttpModule;
+  let fetchMock: ReturnType<typeof vi.fn>;
+  let replaceMock: ReturnType<typeof vi.fn>;
+
+  const initOf = (call = 0): RequestInit => fetchMock.mock.calls[call][1] as RequestInit;
+  const urlOf = (call = 0): string => fetchMock.mock.calls[call][0] as string;
+  const headersOf = (call = 0): Headers => initOf(call).headers as Headers;
+
+  beforeEach(async () => {
+    vi.resetModules();
+    document.head.innerHTML = '';
+    delete (window as { X_UI_BASE_PATH?: string }).X_UI_BASE_PATH;
+    fetchMock = vi.fn();
+    vi.stubGlobal('fetch', fetchMock);
+    replaceMock = vi.fn();
+    Object.defineProperty(window, 'location', {
+      configurable: true,
+      value: { replace: replaceMock, href: 'http://localhost/', origin: 'http://localhost', pathname: '/' },
+    });
+    http = await import('@/api/http-init');
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('form-encodes bodies and repeats array keys', async () => {
+    document.head.innerHTML = '<meta name="csrf-token" content="tok">';
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    await http.httpRequest('POST', '/panel/x', { a: 1, b: ['x', 'y'] });
+
+    expect(initOf().body).toBe('a=1&b=x&b=y');
+    expect(headersOf().get('content-type')).toBe('application/x-www-form-urlencoded; charset=UTF-8');
+  });
+
+  it('JSON-encodes bodies when the caller declares application/json', async () => {
+    document.head.innerHTML = '<meta name="csrf-token" content="tok">';
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    await http.httpRequest('POST', '/panel/x', { a: 1 }, { headers: { 'Content-Type': 'application/json' } });
+
+    expect(initOf().body).toBe(JSON.stringify({ a: 1 }));
+    expect(headersOf().get('content-type')).toBe('application/json');
+  });
+
+  it('passes FormData through without a Content-Type header', async () => {
+    document.head.innerHTML = '<meta name="csrf-token" content="tok">';
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    const fd = new FormData();
+    fd.append('db', 'contents');
+    await http.httpRequest('POST', '/panel/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
+
+    expect(initOf().body).toBe(fd);
+    expect(headersOf().has('content-type')).toBe(false);
+  });
+
+  it('attaches the CSRF token on POST and omits it on GET', async () => {
+    document.head.innerHTML = '<meta name="csrf-token" content="tok">';
+    http.setupHttp();
+    fetchMock.mockImplementation(() => Promise.resolve(okEnvelope()));
+
+    await http.httpRequest('POST', '/p', { a: 1 });
+    expect(headersOf().get('X-CSRF-Token')).toBe('tok');
+    expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
+
+    fetchMock.mockClear();
+    await http.httpRequest('GET', '/g');
+    expect(headersOf().get('X-CSRF-Token')).toBeNull();
+    expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest');
+  });
+
+  it('prepends the base path to request and csrf-token URLs', async () => {
+    window.X_UI_BASE_PATH = '/xui';
+    http.setupHttp();
+    fetchMock.mockImplementation((url: string) =>
+      Promise.resolve(url.endsWith('/csrf-token') ? csrfResponse('fresh') : okEnvelope()),
+    );
+
+    await http.httpRequest('POST', '/panel/api/x', { a: 1 });
+
+    expect(urlOf(0)).toBe('/xui/csrf-token');
+    expect(urlOf(1)).toBe('/xui/panel/api/x');
+  });
+
+  it('refreshes the token and retries once on 403', async () => {
+    http.setupHttp();
+    let dataCalls = 0;
+    fetchMock.mockImplementation((url: string) => {
+      if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse(`tok${dataCalls}`));
+      dataCalls += 1;
+      return Promise.resolve(
+        dataCalls === 1
+          ? new Response('', { status: 403 })
+          : okEnvelope(),
+      );
+    });
+
+    const resp = await http.httpRequest('POST', '/panel/api/x', { a: 1 });
+
+    expect(resp.ok).toBe(true);
+    expect(dataCalls).toBe(2);
+    expect(headersOf(3).get('X-CSRF-Token')).toBe('tok1');
+  });
+
+  it('throws HttpError when the retried request is still 403', async () => {
+    http.setupHttp();
+    let dataCalls = 0;
+    fetchMock.mockImplementation((url: string) => {
+      if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse('tok'));
+      dataCalls += 1;
+      return Promise.resolve(new Response('', { status: 403 }));
+    });
+
+    await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(http.HttpError);
+    expect(dataCalls).toBe(2);
+  });
+
+  it('redirects once on 401 and never settles', async () => {
+    window.X_UI_BASE_PATH = '/xui';
+    document.head.innerHTML = '<meta name="csrf-token" content="tok">';
+    http.setupHttp();
+    fetchMock.mockResolvedValue(new Response('', { status: 401 }));
+
+    const pending = Symbol('pending');
+    const first = await Promise.race([
+      http.httpRequest('POST', '/p', { a: 1 }),
+      new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
+    ]);
+    expect(first).toBe(pending);
+    expect(replaceMock).toHaveBeenCalledTimes(1);
+    expect(replaceMock).toHaveBeenCalledWith('/xui');
+
+    const second = await Promise.race([
+      http.httpRequest('POST', '/p', { a: 1 }),
+      new Promise((resolve) => setTimeout(() => resolve(pending), 20)),
+    ]);
+    expect(second).toBe(pending);
+    expect(replaceMock).toHaveBeenCalledTimes(1);
+  });
+
+  it('parses empty, 204, non-JSON, and malformed bodies tolerantly', async () => {
+    http.setupHttp();
+
+    fetchMock.mockResolvedValueOnce(new Response('', { status: 200 }));
+    expect((await http.httpRequest('GET', '/a')).data).toBe('');
+
+    fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
+    expect((await http.httpRequest('GET', '/b')).data).toBe('');
+
+    fetchMock.mockResolvedValueOnce(new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } }));
+    expect((await http.httpRequest('GET', '/c')).data).toBe('hello');
+
+    fetchMock.mockResolvedValueOnce(
+      new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }),
+    );
+    expect((await http.httpRequest('GET', '/d')).data).toBe('{bad');
+  });
+
+  it('rejects when fetch fails at the network level', async () => {
+    http.setupHttp();
+    fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
+
+    await expect(http.httpRequest('GET', '/x')).rejects.toThrow('Failed to fetch');
+  });
+
+  it('passes an AbortSignal when a timeout is set', async () => {
+    http.setupHttp();
+    fetchMock.mockResolvedValue(okEnvelope());
+
+    await http.httpRequest('GET', '/x', undefined, { timeout: 50 });
+
+    expect(initOf().signal).toBeInstanceOf(AbortSignal);
+  });
+});

+ 101 - 0
frontend/src/test/httpUtil.test.ts

@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const toast = vi.hoisted(() => ({
+  success: vi.fn(),
+  error: vi.fn(),
+  warning: vi.fn(),
+  info: vi.fn(),
+  loading: vi.fn(),
+}));
+
+vi.mock('@/api/http-init', () => ({
+  httpRequest: vi.fn(),
+  HttpError: class HttpError extends Error {
+    status: number;
+    response: { status: number; statusText: string; data: unknown };
+    constructor(status: number, statusText: string, data: unknown) {
+      super(`Request failed with status ${status}`);
+      this.status = status;
+      this.response = { status, statusText, data };
+    }
+  },
+}));
+
+vi.mock('@/utils/messageBus', () => ({
+  getMessage: () => toast,
+}));
+
+import { HttpUtil } from '@/utils';
+import { HttpError, httpRequest } from '@/api/http-init';
+import type { HttpResponse } from '@/api/http-init';
+
+const mockRequest = vi.mocked(httpRequest);
+const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, statusText: 'OK', data });
+
+describe('HttpUtil', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('unwraps a success envelope and shows a success toast', async () => {
+    mockRequest.mockResolvedValue(envelope({ success: true, msg: 'done', obj: { id: 1 } }));
+
+    const msg = await HttpUtil.post('/x', { a: 1 });
+
+    expect(msg.success).toBe(true);
+    expect(msg.obj).toEqual({ id: 1 });
+    expect(toast.success).toHaveBeenCalledWith('done');
+  });
+
+  it('suppresses the success toast with silentSuccess but still warns on nodePending', async () => {
+    mockRequest.mockResolvedValue(envelope({ success: true, msg: 'saved', obj: { nodePending: true } }));
+
+    await HttpUtil.post('/x', { a: 1 }, { silentSuccess: true });
+
+    expect(toast.success).not.toHaveBeenCalled();
+    expect(toast.warning).toHaveBeenCalled();
+  });
+
+  it('shows an error toast for a failure envelope', async () => {
+    mockRequest.mockResolvedValue(envelope({ success: false, msg: 'nope', obj: null }));
+
+    const msg = await HttpUtil.post('/x');
+
+    expect(msg.success).toBe(false);
+    expect(toast.error).toHaveBeenCalledWith('nope');
+  });
+
+  it('suppresses all toasts with silent', async () => {
+    mockRequest.mockResolvedValue(envelope({ success: false, msg: 'nope', obj: null }));
+
+    await HttpUtil.post('/x', undefined, { silent: true });
+
+    expect(toast.error).not.toHaveBeenCalled();
+  });
+
+  it('maps a thrown HttpError to a failure Msg via response.data.message', async () => {
+    mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { message: 'bad input' }));
+
+    const msg = await HttpUtil.post('/x', undefined, { silent: true });
+
+    expect(msg.success).toBe(false);
+    expect(msg.msg).toBe('bad input');
+  });
+
+  it('maps a thrown native error to a failure Msg via its message', async () => {
+    mockRequest.mockRejectedValue(new Error('Network down'));
+
+    const msg = await HttpUtil.get('/x', undefined, { silent: true });
+
+    expect(msg.msg).toBe('Network down');
+  });
+
+  it('returns "No response data" for an empty body', async () => {
+    mockRequest.mockResolvedValue(envelope(''));
+
+    const msg = await HttpUtil.get('/x', undefined, { silent: true });
+
+    expect(msg.success).toBe(false);
+    expect(msg.msg).toBe('No response data');
+  });
+});

+ 0 - 9
frontend/src/test/setup.components.ts

@@ -77,15 +77,6 @@ afterEach(async () => {
 
 import { HttpUtil } from '@/utils';
 
-vi.mock('axios', () => {
-  return {
-    default: {
-      get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
-      post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
-    }
-  };
-});
-
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
 // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 14 - 10
frontend/src/utils/index.ts

@@ -1,6 +1,6 @@
-import axios from 'axios';
-import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
 import i18next from 'i18next';
+import { httpRequest } from '@/api/http-init';
+import type { HttpResponse } from '@/api/http-init';
 import { getMessage } from './messageBus';
 
 type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
@@ -17,7 +17,11 @@ export class Msg<T = unknown> {
   }
 }
 
-export interface HttpOptions extends AxiosRequestConfig {
+export interface HttpOptions {
+  headers?: Record<string, string> | Headers;
+  params?: unknown;
+  timeout?: number;
+  signal?: AbortSignal;
   silent?: boolean;
   silentSuccess?: boolean;
 }
@@ -48,7 +52,7 @@ export class HttpUtil {
     getMessage().error(msg.msg);
   }
 
-  static _respToMsg(resp: AxiosResponse | undefined): Msg {
+  static _respToMsg(resp: HttpResponse | undefined): Msg {
     if (!resp || !resp.data) {
       return new Msg(false, 'No response data');
     }
@@ -64,15 +68,15 @@ export class HttpUtil {
   }
 
   static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
-    const { silent, silentSuccess, ...axiosOpts } = options;
+    const { silent, silentSuccess, ...rest } = options;
     try {
-      const resp = await axios.get(url, { params, ...axiosOpts });
+      const resp = await httpRequest('GET', url, undefined, { ...rest, params });
       const msg = this._respToMsg(resp) as Msg<T>;
       if (!silent) this._handleMsg(msg, silentSuccess);
       return msg;
     } catch (error) {
       console.error('GET request failed:', error);
-      const err = error as AxiosError<{ message?: string }>;
+      const err = error as { response?: { data?: { message?: string } }; message?: string };
       const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
       if (!silent) this._handleMsg(errorMsg);
       return errorMsg;
@@ -80,15 +84,15 @@ export class HttpUtil {
   }
 
   static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
-    const { silent, silentSuccess, ...axiosOpts } = options;
+    const { silent, silentSuccess, ...rest } = options;
     try {
-      const resp = await axios.post(url, data, axiosOpts);
+      const resp = await httpRequest('POST', url, data, rest);
       const msg = this._respToMsg(resp) as Msg<T>;
       if (!silent) this._handleMsg(msg, silentSuccess);
       return msg;
     } catch (error) {
       console.error('POST request failed:', error);
-      const err = error as AxiosError<{ message?: string }>;
+      const err = error as { response?: { data?: { message?: string } }; message?: string };
       const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
       if (!silent) this._handleMsg(errorMsg);
       return errorMsg;

+ 2 - 51
frontend/vite.config.js

@@ -77,45 +77,6 @@ function injectBasePathPlugin() {
   };
 }
 
-// es-toolkit's `./compat/*` exports map only declares a CJS condition, so deep
-// imports like `es-toolkit/compat/get` resolve to a CJS shim. That shim uses a
-// `require_X.Y` pattern that Vite's optimizer and Rolldown both mishandle
-// (TypeError: require_isUnsafeProperty is not a function). The ESM build at
-// `dist/compat/<category>/<name>.mjs` is fine but only carries a named export,
-// while consumers like recharts use default imports — so emit a virtual module
-// that re-exports the named symbol as default.
-const ES_TOOLKIT_COMPAT_DIRS = ['array', 'function', 'math', 'object', 'predicate', 'string', 'util'];
-const ES_TOOLKIT_SHIM_PREFIX = '\0es-toolkit-compat:';
-
-function findEsToolkitCompatMjs(name) {
-  for (const sub of ES_TOOLKIT_COMPAT_DIRS) {
-    const candidate = path.resolve(__dirname, 'node_modules/es-toolkit/dist/compat', sub, `${name}.mjs`);
-    if (fs.existsSync(candidate)) return candidate;
-  }
-  return null;
-}
-
-function esToolkitCompatEsmResolver() {
-  return {
-    name: 'es-toolkit-compat-esm',
-    enforce: 'pre',
-    resolveId(id) {
-      const m = id.match(/^es-toolkit\/compat\/(.+)$/);
-      if (!m) return null;
-      if (!findEsToolkitCompatMjs(m[1])) return null;
-      return ES_TOOLKIT_SHIM_PREFIX + m[1];
-    },
-    load(id) {
-      if (!id.startsWith(ES_TOOLKIT_SHIM_PREFIX)) return null;
-      const name = id.slice(ES_TOOLKIT_SHIM_PREFIX.length);
-      const target = findEsToolkitCompatMjs(name);
-      if (!target) return null;
-      const url = target.replace(/\\/g, '/');
-      return `import { ${name} } from ${JSON.stringify(url)};\nexport { ${name} };\nexport default ${name};\n`;
-    },
-  };
-}
-
 function bypassMigratedRoute(req) {
   if (req.method !== 'GET') return undefined;
   const url = req.url.split('?')[0];
@@ -179,17 +140,12 @@ function makeBackendProxy(target) {
 }
 
 export default defineConfig({
-  plugins: [esToolkitCompatEsmResolver(), react(), injectBasePathPlugin()],
+  plugins: [react(), injectBasePathPlugin()],
   resolve: {
     alias: {
       '@': path.resolve(__dirname, 'src'),
     },
   },
-  optimizeDeps: {
-    rolldownOptions: {
-      plugins: [esToolkitCompatEsmResolver()],
-    },
-  },
   experimental: {
     renderBuiltUrl(filename, { hostType }) {
       if (hostType === 'js') {
@@ -249,13 +205,8 @@ export default defineConfig({
             || id.includes('/node_modules/swagger-ui/')
             || id.includes('/node_modules/swagger-client/')
           ) return 'vendor-swagger';
-          if (
-            id.includes('/node_modules/recharts/')
-            || id.includes('/node_modules/victory-vendor/')
-            || id.includes('/node_modules/d3-')
-          ) return 'vendor-recharts';
+          if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
           if (id.includes('dayjs')) return 'vendor-dayjs';
-          if (id.includes('axios')) return 'vendor-axios';
           return 'vendor';
         },
       },

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

@@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 
 	// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
 	// so they fetch the session token via this endpoint at startup and replay it
-	// on subsequent unsafe requests through axios.
+	// on subsequent unsafe requests.
 	g.GET("/csrf-token", a.csrfToken)
 }
 

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

@@ -60,7 +60,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
 	g.POST("/outbound-subs/:id", a.updateOutboundSub)
 	g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
-	g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
+	g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // POST alias for clients that can't send DELETE
 	g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
 }
 

+ 1 - 1
internal/xray/process.go

@@ -96,7 +96,7 @@ func GetAccessLogPath() (string, error) {
 // GetErrorLogPath reads the Xray config and returns the error log file path.
 // GetErrorLogPath reads the Xray config and returns the error log file path.
 func GetErrorLogPath() (string, error) {
-    return getLogPath("error")
+	return getLogPath("error")
 }
 
 // stopProcess calls Stop on the given Process instance.