mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 16:45:03 +03:00
Compare commits
19 Commits
17e6b5a460
...
v3.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c377dca27c | |||
| c56f6447a8 | |||
| 66740b7ef4 | |||
| 8d02ae28f5 | |||
| 2c943da3e0 | |||
| f52c3c4837 | |||
| 1e2d6f6081 | |||
| af5a8e5d40 | |||
| ad288a7ecc | |||
| ad5f2a28cb | |||
| e467b25f03 | |||
| 03cc80bb9e | |||
| 863473783d | |||
| c3fa73d5a0 | |||
| 87ebcc7a6f | |||
| 55f0281692 | |||
| bcd71c9296 | |||
| 33f72f8f4a | |||
| ea35884390 |
@@ -766,7 +766,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
resolve-conflicts:
|
resolve-conflicts:
|
||||||
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner
|
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner && github.event.comment.author_association == 'OWNER'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -774,6 +774,29 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Refuse a head that moved after the request
|
||||||
|
id: freshness
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR: ${{ github.event.issue.number }}
|
||||||
|
COMMENT_AT: ${{ github.event.comment.created_at }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
|
||||||
|
HEAD_SHA=${head%% *}
|
||||||
|
HEAD_PUSHED_AT=${head#* }
|
||||||
|
if [ -z "$HEAD_PUSHED_AT" ]; then
|
||||||
|
gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so its branch cannot be verified or merged. Nothing was changed."
|
||||||
|
echo "::error::The head repository is unavailable; refusing to check it out."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
|
||||||
|
gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code that was reviewed. Nothing was changed. Ask again to act on the current head."
|
||||||
|
echo "::error::The head moved after the request; refusing to check it out."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -783,6 +806,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR: ${{ github.event.issue.number }}
|
PR: ${{ github.event.issue.number }}
|
||||||
|
PINNED_SHA: ${{ steps.freshness.outputs.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
hand_back() {
|
hand_back() {
|
||||||
@@ -801,6 +825,12 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
gh pr checkout "$PR"
|
gh pr checkout "$PR"
|
||||||
|
checked_out=$(git rev-parse HEAD)
|
||||||
|
if [ "$checked_out" != "$PINNED_SHA" ]; then
|
||||||
|
gh pr comment "$PR" --body "The head of this pull request moved from \`${PINNED_SHA}\` to \`${checked_out}\` while this run was starting, so nothing was changed."
|
||||||
|
echo "::error::The head moved from ${PINNED_SHA} to ${checked_out} during the run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
git fetch origin "$base"
|
git fetch origin "$base"
|
||||||
if git merge --no-commit --no-ff "origin/${base}"; then
|
if git merge --no-commit --no-ff "origin/${base}"; then
|
||||||
git merge --abort 2>/dev/null || true
|
git merge --abort 2>/dev/null || true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ browser in full.
|
|||||||
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
|
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
|
||||||
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
|
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
|
||||||
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
|
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. |
|
||||||
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
|
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
|
||||||
|
|
||||||
After changing the port or base path, the panel URL becomes
|
After changing the port or base path, the panel URL becomes
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه میشود (همیشه به شکل `/…/` نرمالسازی میشود). |
|
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه میشود (همیشه به شکل `/…/` نرمالسازی میشود). |
|
||||||
| `webCertFile` / `webKeyFile` | _(هیچکدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه میشود. |
|
| `webCertFile` / `webKeyFile` | _(هیچکدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه میشود. |
|
||||||
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیشفرض ۶ ساعت). |
|
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیشفرض ۶ ساعت). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشدهشان (IP واقعی کلاینت) مورد اعتماد است. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشدهشان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینکهای اشتراک را کنترل میکند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینکها `subURI` را تنظیم کنید. |
|
||||||
| `panelOutbound` | _(هیچکدام)_ | مسیریابی خروجیِ خود پنل (بررسی بهروزرسانیها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
|
| `panelOutbound` | _(هیچکدام)_ | مسیریابی خروجیِ خود پنل (بررسی بهروزرسانیها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
|
||||||
|
|
||||||
پس از تغییر پورت یا مسیر پایه، آدرس پنل بهصورت
|
پس از تغییر پورت یا مسیر پایه، آدرس پنل بهصورت
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
|
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
|
||||||
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
|
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
|
||||||
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
|
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. |
|
||||||
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
|
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
|
||||||
|
|
||||||
После изменения порта или базового пути URL панели становится
|
После изменения порта или базового пути URL панели становится
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
|
|||||||
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
|
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
|
||||||
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
|
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
|
||||||
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
|
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
|
||||||
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。 |
|
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 |
|
||||||
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
|
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
|
||||||
|
|
||||||
更改端口或基础路径后,面板 URL 将变为
|
更改端口或基础路径后,面板 URL 将变为
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useLayoutEffect } from 'react';
|
||||||
import type { Decorator, Preview } from '@storybook/react-vite';
|
import type { Decorator, Preview } from '@storybook/react-vite';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
@@ -17,11 +17,12 @@ if (!i18next.isInitialized) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTheme: Decorator = (Story, context) => {
|
export const withTheme: Decorator = (Story, context) => {
|
||||||
const dark = context.globals.theme === 'dark';
|
const dark = context.globals.theme === 'dark';
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
document.body.setAttribute('class', dark ? 'dark' : 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
document.body.classList.add(dark ? 'dark' : 'light');
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
||||||
|
|||||||
@@ -53,4 +53,37 @@ export default [
|
|||||||
'jsx-a11y/no-autofocus': 'off',
|
'jsx-a11y/no-autofocus': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The settings and xray pages write numeric InputNumber changes straight
|
||||||
|
// into state, so a null-collapsing handler (`Number(v) || N`, or the
|
||||||
|
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
|
||||||
|
// stored N — the cleared-port bug, #6121. Handlers here go through
|
||||||
|
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
|
||||||
|
// extracted into a variable and passed as onChange={handler} is not
|
||||||
|
// matched; the inline shapes below are the ones that drift in practice.
|
||||||
|
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': ['error', {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}, {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}, {
|
||||||
|
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
|
||||||
|
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The xray form modals (OutboundFormModal, BalancerFormModal,
|
||||||
|
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
|
||||||
|
// the clients/inbounds modals do, and some of their fields carry a
|
||||||
|
// deliberate clear-means-zero semantic — the direct-write rule above
|
||||||
|
// does not apply to them.
|
||||||
|
files: ['src/pages/xray/**/*Modal.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+123
-106
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.4.3",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"version": "0.4.3",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.4.3",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-query-devtools": "^5.101.4",
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-a11y": "^10.5.4",
|
"@storybook/addon-a11y": "^10.5.5",
|
||||||
"@storybook/addon-docs": "^10.5.4",
|
"@storybook/addon-docs": "^10.5.5",
|
||||||
"@storybook/addon-vitest": "^10.5.4",
|
"@storybook/addon-vitest": "^10.5.5",
|
||||||
"@storybook/react-vite": "^10.5.4",
|
"@storybook/react-vite": "^10.5.5",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
@@ -47,13 +47,13 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.8.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^30.0.1",
|
||||||
"lint-staged": "^17.2.0",
|
"lint-staged": "^17.2.0",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
@@ -165,56 +165,58 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
"@csstools/css-calc": "^3.2.1",
|
||||||
"@csstools/css-calc": "^3.2.0",
|
"@csstools/css-color-parser": "^4.1.9",
|
||||||
"@csstools/css-color-parser": "^4.1.0",
|
|
||||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^4.0.0"
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
"version": "7.1.1",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
|
||||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
|
||||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
|
||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"css-tree": "^3.2.1",
|
"css-tree": "^3.2.1",
|
||||||
"is-potential-custom-element-name": "^1.0.1"
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/generational-cache": {
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
"version": "1.0.1",
|
"version": "11.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/nwsapi": {
|
|
||||||
"version": "2.3.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
|
||||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1384,9 +1386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "5.4.3",
|
"version": "5.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz",
|
||||||
"integrity": "sha512-jtS1DEvkT1ql8ImIDQL+UmkhEnDmt3Bp7Yt4q5XQpc0JiQ8b8+I7yT/KQze4gw9Ocmi9xa9ef6zxifizPgdp3A==",
|
"integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/utils": "^0.3.0"
|
"@standard-schema/utils": "^0.3.0"
|
||||||
@@ -1413,7 +1415,7 @@
|
|||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"superstruct": ">=0.12.0",
|
"superstruct": ">=0.12.0",
|
||||||
"typanion": "^3.3.2",
|
"typanion": "^3.3.2",
|
||||||
"valibot": "^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
|
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
|
||||||
"vest": ">=3.0.0",
|
"vest": ">=3.0.0",
|
||||||
"yup": "^1.0.0",
|
"yup": "^1.0.0",
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
@@ -3631,9 +3633,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-a11y": {
|
"node_modules/@storybook/addon-a11y": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz",
|
||||||
"integrity": "sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==",
|
"integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3645,20 +3647,20 @@
|
|||||||
"url": "https://opencollective.com/storybook"
|
"url": "https://opencollective.com/storybook"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"storybook": "^10.5.4"
|
"storybook": "^10.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-docs": {
|
"node_modules/@storybook/addon-docs": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz",
|
||||||
"integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==",
|
"integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"@storybook/csf-plugin": "10.5.4",
|
"@storybook/csf-plugin": "10.5.5",
|
||||||
"@storybook/icons": "^2.0.2",
|
"@storybook/icons": "^2.0.2",
|
||||||
"@storybook/react-dom-shim": "10.5.4",
|
"@storybook/react-dom-shim": "10.5.5",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"ts-dedent": "^2.0.0"
|
"ts-dedent": "^2.0.0"
|
||||||
@@ -3669,7 +3671,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.4"
|
"storybook": "^10.5.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -3678,9 +3680,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/addon-vitest": {
|
"node_modules/@storybook/addon-vitest": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz",
|
||||||
"integrity": "sha512-5lr81sgrbK3Tjjbstieg/yYj3jdtQYP70ihpOiN1TwogvFATY2/9eMT46FE5ioKtEFcfJRDcgJ2wSlmJKE+kZw==",
|
"integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3695,7 +3697,7 @@
|
|||||||
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
||||||
"@vitest/browser-playwright": "^4.0.0",
|
"@vitest/browser-playwright": "^4.0.0",
|
||||||
"@vitest/runner": "^3.0.0 || ^4.0.0",
|
"@vitest/runner": "^3.0.0 || ^4.0.0",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"vitest": "^3.0.0 || ^4.0.0"
|
"vitest": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -3714,13 +3716,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/builder-vite": {
|
"node_modules/@storybook/builder-vite": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz",
|
||||||
"integrity": "sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==",
|
"integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@storybook/csf-plugin": "10.5.4",
|
"@storybook/csf-plugin": "10.5.5",
|
||||||
"ts-dedent": "^2.0.0"
|
"ts-dedent": "^2.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -3728,14 +3730,14 @@
|
|||||||
"url": "https://opencollective.com/storybook"
|
"url": "https://opencollective.com/storybook"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/csf-plugin": {
|
"node_modules/@storybook/csf-plugin": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz",
|
||||||
"integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==",
|
"integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3748,7 +3750,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"esbuild": "*",
|
"esbuild": "*",
|
||||||
"rollup": "*",
|
"rollup": "*",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"vite": "*",
|
"vite": "*",
|
||||||
"webpack": "*"
|
"webpack": "*"
|
||||||
},
|
},
|
||||||
@@ -3785,14 +3787,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react": {
|
"node_modules/@storybook/react": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz",
|
||||||
"integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==",
|
"integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@storybook/global": "^5.0.0",
|
"@storybook/global": "^5.0.0",
|
||||||
"@storybook/react-dom-shim": "10.5.4",
|
"@storybook/react-dom-shim": "10.5.5",
|
||||||
"react-docgen": "^8.0.2",
|
"react-docgen": "^8.0.2",
|
||||||
"react-docgen-typescript": "^2.2.2"
|
"react-docgen-typescript": "^2.2.2"
|
||||||
},
|
},
|
||||||
@@ -3805,7 +3807,7 @@
|
|||||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"typescript": ">= 4.9.x"
|
"typescript": ">= 4.9.x"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -3821,9 +3823,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react-dom-shim": {
|
"node_modules/@storybook/react-dom-shim": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz",
|
||||||
"integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==",
|
"integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -3835,7 +3837,7 @@
|
|||||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.4"
|
"storybook": "^10.5.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -3847,16 +3849,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@storybook/react-vite": {
|
"node_modules/@storybook/react-vite": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz",
|
||||||
"integrity": "sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==",
|
"integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
||||||
"@rollup/pluginutils": "^5.0.2",
|
"@rollup/pluginutils": "^5.0.2",
|
||||||
"@storybook/builder-vite": "10.5.4",
|
"@storybook/builder-vite": "10.5.5",
|
||||||
"@storybook/react": "10.5.4",
|
"@storybook/react": "10.5.5",
|
||||||
"empathic": "^2.0.0",
|
"empathic": "^2.0.0",
|
||||||
"magic-string": "^0.30.0",
|
"magic-string": "^0.30.0",
|
||||||
"react-docgen": "^8.0.2",
|
"react-docgen": "^8.0.2",
|
||||||
@@ -3870,7 +3872,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"typescript": ">= 4.9.x",
|
"typescript": ">= 4.9.x",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -7165,7 +7167,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-patch": {
|
"node_modules/fast-json-patch": {
|
||||||
@@ -7531,9 +7533,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.7.0",
|
"version": "17.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
|
||||||
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
|
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8525,39 +8527,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.1.1",
|
"version": "30.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^5.1.11",
|
"@asamuzakjp/css-color": "^6.0.5",
|
||||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||||
"@bramus/specificity": "^2.4.2",
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||||
"@exodus/bytes": "^1.15.0",
|
"@exodus/bytes": "^1.15.1",
|
||||||
"css-tree": "^3.2.1",
|
"css-tree": "^3.2.1",
|
||||||
"data-urls": "^7.0.0",
|
"data-urls": "^7.0.0",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.3.5",
|
"lru-cache": "^11.5.2",
|
||||||
"parse5": "^8.0.1",
|
"parse5": "^8.0.1",
|
||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^6.0.1",
|
"tough-cookie": "^6.0.2",
|
||||||
"undici": "^7.25.0",
|
"undici": "^8.9.0",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
"whatwg-url": "^16.0.1",
|
"whatwg-url": "^17.1.0",
|
||||||
"xml-name-validator": "^5.0.0"
|
"xml-name-validator": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"canvas": "^3.0.0"
|
"canvas": "^3.2.3"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"canvas": {
|
"canvas": {
|
||||||
@@ -8575,6 +8577,21 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||||
|
"version": "17.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||||
|
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.14.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -10420,7 +10437,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -10967,9 +10984,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/storybook": {
|
"node_modules/storybook": {
|
||||||
"version": "10.5.4",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz",
|
||||||
"integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==",
|
"integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -10989,7 +11006,7 @@
|
|||||||
"recast": "^0.23.5",
|
"recast": "^0.23.5",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"use-sync-external-store": "^1.5.0",
|
"use-sync-external-store": "^1.5.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"storybook": "dist/bin/dispatcher.js"
|
"storybook": "dist/bin/dispatcher.js"
|
||||||
@@ -11760,13 +11777,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.29.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@ant-design/icons": "^6.3.2",
|
"@ant-design/icons": "^6.3.2",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@hookform/resolvers": "^5.4.3",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-query-devtools": "^5.101.4",
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
@@ -51,10 +51,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@storybook/addon-a11y": "^10.5.4",
|
"@storybook/addon-a11y": "^10.5.5",
|
||||||
"@storybook/addon-docs": "^10.5.4",
|
"@storybook/addon-docs": "^10.5.5",
|
||||||
"@storybook/addon-vitest": "^10.5.4",
|
"@storybook/addon-vitest": "^10.5.5",
|
||||||
"@storybook/react-vite": "^10.5.4",
|
"@storybook/react-vite": "^10.5.5",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
@@ -66,13 +66,13 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.8.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^30.0.1",
|
||||||
"lint-staged": "^17.2.0",
|
"lint-staged": "^17.2.0",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"storybook": "^10.5.4",
|
"storybook": "^10.5.5",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
|
|||||||
@@ -3980,6 +3980,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
|
||||||
|
"operationId": "get_panel_api_openapi_json",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/server/status": {
|
"/panel/api/server/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5661,7 +5691,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
|
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
|
||||||
"operationId": "get_panel_api_clients_list_paged",
|
"operationId": "get_panel_api_clients_list_paged",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -5777,12 +5807,18 @@
|
|||||||
"summary": {
|
"summary": {
|
||||||
"total": 2000,
|
"total": 2000,
|
||||||
"active": 1850,
|
"active": 1850,
|
||||||
|
"onlineCount": 1,
|
||||||
|
"depletedCount": 0,
|
||||||
|
"expiringCount": 0,
|
||||||
|
"deactiveCount": 150,
|
||||||
"online": [
|
"online": [
|
||||||
"alice@example.com"
|
"alice@example.com"
|
||||||
],
|
],
|
||||||
"depleted": [],
|
"depleted": [],
|
||||||
"expiring": [],
|
"expiring": [],
|
||||||
"deactive": []
|
"deactive": [
|
||||||
|
"bob@example.com"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9739,6 +9775,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/setting/factoryDefaults": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Settings"
|
||||||
|
],
|
||||||
|
"summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.",
|
||||||
|
"operationId": "post_panel_api_setting_factoryDefaults",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/setting/update": {
|
"/panel/api/setting/update": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
|
|||||||
return parts.join('&');
|
return parts.join('&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendQuery(url: string, query: string): string {
|
||||||
|
if (query === '') return url;
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
||||||
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
||||||
|
const hasQuery = path.includes('?');
|
||||||
|
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
|
||||||
|
return `${path}${separator}${query}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
|
||||||
|
if (!options.timeout) return options.signal;
|
||||||
|
const timeout = AbortSignal.timeout(options.timeout);
|
||||||
|
if (!options.signal) return timeout;
|
||||||
|
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abort = () => controller.abort();
|
||||||
|
options.signal.addEventListener('abort', abort, { once: true });
|
||||||
|
timeout.addEventListener('abort', abort, { once: true });
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
async function performFetch(
|
async function performFetch(
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -121,8 +143,8 @@ async function performFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = encodeForm(options.params);
|
const query = encodeForm(options.params);
|
||||||
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
|
const fullUrl = basePathPrefix + appendQuery(url, query);
|
||||||
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
|
const signal = requestSignal(options);
|
||||||
|
|
||||||
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
|
|||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||||
|
|
||||||
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
||||||
|
type SettingSaveResult = {
|
||||||
|
msg: Msg<unknown>;
|
||||||
|
saved?: AllSetting;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
|||||||
|
|
||||||
export function useAllSettings() {
|
export function useAllSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
|
|
||||||
const [extraSpinning, setExtraSpinning] = useState(false);
|
const [extraSpinning, setExtraSpinning] = useState(false);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -28,41 +32,54 @@ export function useAllSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
||||||
|
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
|
||||||
useEffect(() => {
|
query.data === undefined ? undefined : server,
|
||||||
if (query.data !== undefined) {
|
(setting) => new AllSetting(setting),
|
||||||
setDraft(new AllSetting(query.data));
|
(left, right) => left.equals(right),
|
||||||
}
|
);
|
||||||
}, [query.data]);
|
const allSetting = draft ?? server;
|
||||||
|
|
||||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = new AllSetting(prev);
|
const next = new AllSetting(prev ?? server);
|
||||||
Object.assign(next, patch);
|
Object.assign(next, patch);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [server, setDraft]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
|
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
|
||||||
const payload = { ...next };
|
const next = { ...payload };
|
||||||
const body = AllSettingSchema.partial().safeParse(payload);
|
const body = AllSettingSchema.partial().safeParse(next);
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
}
|
}
|
||||||
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
|
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
|
||||||
|
return { msg, saved };
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: ({ msg, saved }) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (!msg?.success) return;
|
||||||
|
if (saved) markSaved(saved);
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
|
const saveAll = useCallback(async () => {
|
||||||
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
|
const saved = new AllSetting(allSetting);
|
||||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
|
||||||
|
}, [allSetting, saveMut]);
|
||||||
|
const savePayload = useCallback(
|
||||||
|
async (payload: SettingSavePayload) => {
|
||||||
|
const saved = new AllSetting(allSetting);
|
||||||
|
Object.assign(saved, payload);
|
||||||
|
return (await saveMut.mutateAsync({ payload, saved })).msg;
|
||||||
|
},
|
||||||
|
[allSetting, saveMut],
|
||||||
|
);
|
||||||
|
const saveDisabled = !isDirty;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allSetting: draft,
|
allSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
fetched: query.data !== undefined,
|
fetched: query.data !== undefined,
|
||||||
spinning: extraSpinning || saveMut.isPending,
|
spinning: extraSpinning || saveMut.isPending,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
|
||||||
|
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
|
||||||
|
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
|
||||||
|
return parsed.success ? parsed.data : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFactoryDefaults() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.settings.factoryDefaults(),
|
||||||
|
queryFn: fetchFactoryDefaults,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export const keys = {
|
|||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
defaults: () => ['settings', 'defaults'] as const,
|
defaults: () => ['settings', 'defaults'] as const,
|
||||||
|
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
|
||||||
},
|
},
|
||||||
inbounds: {
|
inbounds: {
|
||||||
root: () => ['inbounds'] as const,
|
root: () => ['inbounds'] as const,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Popover, Progress } from 'antd';
|
import { Popover, Progress } from 'antd';
|
||||||
|
|
||||||
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClientTrafficCell({
|
// Every prop is a primitive and the component is pure, so the memo bails out
|
||||||
|
// whenever a client's counters did not move — which is most of them on most
|
||||||
|
// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
|
||||||
|
// a useTranslation subscription and a theme context read, times up to 200 rows.
|
||||||
|
const ClientTrafficCell = memo(function ClientTrafficCell({
|
||||||
up = 0,
|
up = 0,
|
||||||
down = 0,
|
down = 0,
|
||||||
total = 0,
|
total = 0,
|
||||||
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
|
|||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ClientTrafficCell;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
|||||||
color: #874d00;
|
color: #874d00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light .config-block-text {
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
.config-block .ant-collapse-extra {
|
.config-block .ant-collapse-extra {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Tag } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value semantics on purpose: the tag answers "does this equal the shipped
|
||||||
|
* default?", not "has the user ever saved this key?" — a stored 2096 and a
|
||||||
|
* fallback 2096 behave identically, so they read identically.
|
||||||
|
*/
|
||||||
|
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
|
||||||
|
if (factoryDefault === undefined) return false;
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
const parsed = Number(factoryDefault);
|
||||||
|
return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
|
||||||
|
}
|
||||||
|
if (typeof current === 'boolean') {
|
||||||
|
if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
|
||||||
|
return (factoryDefault === 'true') === current;
|
||||||
|
}
|
||||||
|
if (typeof current === 'string') return factoryDefault === current;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DefaultSettingTagProps {
|
||||||
|
settingKey: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const defaults = useFactoryDefaults();
|
||||||
|
|
||||||
|
if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
|
||||||
|
|
||||||
|
return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import './SettingListItem.css';
|
|||||||
interface SettingListItemProps {
|
interface SettingListItemProps {
|
||||||
paddings?: 'small' | 'default';
|
paddings?: 'small' | 'default';
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
|
badge?: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
control?: ReactNode;
|
control?: ReactNode;
|
||||||
@@ -13,6 +14,7 @@ interface SettingListItemProps {
|
|||||||
export default function SettingListItem({
|
export default function SettingListItem({
|
||||||
paddings = 'default',
|
paddings = 'default',
|
||||||
title,
|
title,
|
||||||
|
badge,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
@@ -28,7 +30,12 @@ export default function SettingListItem({
|
|||||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<div className="setting-list-meta">
|
<div className="setting-list-meta">
|
||||||
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
|
{title && (
|
||||||
|
<div className="setting-list-title">
|
||||||
|
<span id={titleId}>{title}</span>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{description && <div className="setting-list-description">{description}</div>}
|
{description && <div className="setting-list-description">{description}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as InputAddon } from './InputAddon';
|
export { default as InputAddon } from './InputAddon';
|
||||||
export { default as InfinityIcon } from './InfinityIcon';
|
export { default as InfinityIcon } from './InfinityIcon';
|
||||||
export { default as SettingListItem } from './SettingListItem';
|
export { default as SettingListItem } from './SettingListItem';
|
||||||
|
export { default as DefaultSettingTag } from './DefaultSettingTag';
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface SparklineProps {
|
|||||||
yTickStep?: number;
|
yTickStep?: number;
|
||||||
tickCountX?: number;
|
tickCountX?: number;
|
||||||
showTooltip?: boolean;
|
showTooltip?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
valueMin?: number;
|
valueMin?: number;
|
||||||
valueMax?: number | null;
|
valueMax?: number | null;
|
||||||
yFormatter?: (v: number) => string;
|
yFormatter?: (v: number) => string;
|
||||||
@@ -80,13 +81,23 @@ interface SparklineView {
|
|||||||
extremaPoints: ExtremaResult | null;
|
extremaPoints: ExtremaResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(color: string, alpha: number): string {
|
||||||
let h = hex.trim();
|
const trimmed = color.trim();
|
||||||
|
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
|
||||||
|
if (fn) {
|
||||||
|
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
|
||||||
|
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
|
||||||
|
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
|
||||||
|
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
let h = trimmed;
|
||||||
if (h.startsWith('#')) h = h.slice(1);
|
if (h.startsWith('#')) h = h.slice(1);
|
||||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||||
if (h.length !== 6) return hex;
|
if (h.length !== 6) return trimmed;
|
||||||
const int = Number.parseInt(h, 16);
|
const int = Number.parseInt(h, 16);
|
||||||
if (Number.isNaN(int)) return hex;
|
if (Number.isNaN(int)) return trimmed;
|
||||||
const r = (int >> 16) & 255;
|
const r = (int >> 16) & 255;
|
||||||
const g = (int >> 8) & 255;
|
const g = (int >> 8) & 255;
|
||||||
const b = int & 255;
|
const b = int & 255;
|
||||||
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
yTickStep = 25,
|
yTickStep = 25,
|
||||||
tickCountX = 4,
|
tickCountX = 4,
|
||||||
showTooltip = false,
|
showTooltip = false,
|
||||||
|
showLegend = true,
|
||||||
valueMin = 0,
|
valueMin = 0,
|
||||||
valueMax = 100,
|
valueMax = 100,
|
||||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||||
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
);
|
);
|
||||||
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
plotRef.current?.redraw(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const redraw = () => plotRef.current?.redraw(false);
|
const redraw = () => plotRef.current?.redraw(false);
|
||||||
const moBody = new MutationObserver(redraw);
|
const moBody = new MutationObserver(redraw);
|
||||||
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{legendItems.length > 0 && (
|
{showLegend && legendItems.length > 0 && (
|
||||||
<div className="sparkline-legend" aria-hidden="true">
|
<div className="sparkline-legend" aria-hidden="true">
|
||||||
{legendItems.map((s) => (
|
{legendItems.map((s) => (
|
||||||
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
|
|||||||
|
|
||||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||||
const DEFAULT_SUMMARY: ClientsSummary = {
|
const DEFAULT_SUMMARY: ClientsSummary = {
|
||||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
total: 0, active: 0,
|
||||||
|
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
|
||||||
|
online: [], depleted: [], expiring: [], deactive: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClientSpeedEntry {
|
export interface ClientSpeedEntry {
|
||||||
@@ -114,7 +116,50 @@ export function computeClientsSummary(
|
|||||||
if (nearExpiry || nearLimit) expiring.push(email);
|
if (nearExpiry || nearLimit) expiring.push(email);
|
||||||
else active += 1;
|
else active += 1;
|
||||||
}
|
}
|
||||||
return { total: stats.length, active, online, depleted, expiring, deactive };
|
return {
|
||||||
|
total: stats.length,
|
||||||
|
active,
|
||||||
|
onlineCount: online.length,
|
||||||
|
depletedCount: depleted.length,
|
||||||
|
expiringCount: expiring.length,
|
||||||
|
deactiveCount: deactive.length,
|
||||||
|
online,
|
||||||
|
depleted,
|
||||||
|
expiring,
|
||||||
|
deactive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSpeedMap(
|
||||||
|
a: Record<string, ClientSpeedEntry>,
|
||||||
|
b: Record<string, ClientSpeedEntry>,
|
||||||
|
): boolean {
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
if (aKeys.length !== Object.keys(b).length) return false;
|
||||||
|
for (const key of aKeys) {
|
||||||
|
const left = a[key];
|
||||||
|
const right = b[key];
|
||||||
|
if (!right || left.up !== right.up || left.down !== right.down) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field list computeClientsSummary reads, and deliberately nothing else.
|
||||||
|
// lastOnline in particular churns for every online client on every push and no
|
||||||
|
// counter depends on it, so including it here would defeat the comparison.
|
||||||
|
export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const left = a[i];
|
||||||
|
const right = b[i];
|
||||||
|
if (left.email !== right.email
|
||||||
|
|| left.up !== right.up
|
||||||
|
|| left.down !== right.down
|
||||||
|
|| left.total !== right.total
|
||||||
|
|| left.enable !== right.enable
|
||||||
|
|| left.expiryTime !== right.expiryTime) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickClientsSummary(
|
export function pickClientsSummary(
|
||||||
@@ -155,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
|
|||||||
const qs = buildQS(params);
|
const qs = buildQS(params);
|
||||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
|
||||||
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
|
||||||
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
|
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
|
||||||
if (!validated.obj) throw new Error('Empty clients response');
|
if (!validated.obj) throw new Error('Empty clients response');
|
||||||
return validated.obj;
|
return validated.obj;
|
||||||
}
|
}
|
||||||
@@ -174,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
|
|||||||
return validated.obj || {};
|
return validated.obj || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClients() {
|
export interface UseClientsOptions {
|
||||||
|
// Callers that only need the mutations — the bulk modals, the groups page —
|
||||||
|
// pass false. Mounting them used to start a second 5-second poll of the paged
|
||||||
|
// list whose result they never read, which on a large panel means a full
|
||||||
|
// summary aggregate every 5 seconds for nothing.
|
||||||
|
list?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClients(options: UseClientsOptions = {}) {
|
||||||
|
const withList = options.list ?? true;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
// Null until the page has settled on a query. The clients page cannot build
|
||||||
|
// one until the persisted sort and the panel's configured page size are both
|
||||||
|
// known, and fetching before then cost three sequential requests per load —
|
||||||
|
// the first two thrown away (#trace).
|
||||||
|
const [query, setQueryState] = useState<ClientQueryParams | null>(null);
|
||||||
// setQuery shallow-compares so callers can pass a fresh object every render
|
// setQuery shallow-compares so callers can pass a fresh object every render
|
||||||
// (the common React pattern) without triggering a re-fetch when nothing
|
// (the common React pattern) without triggering a re-fetch when nothing
|
||||||
// actually changed.
|
// actually changed.
|
||||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||||
setQueryState((prev) => {
|
setQueryState((prev) => {
|
||||||
if (
|
if (
|
||||||
prev.page === next.page
|
prev
|
||||||
|
&& prev.page === next.page
|
||||||
&& prev.pageSize === next.pageSize
|
&& prev.pageSize === next.pageSize
|
||||||
&& (prev.search ?? '') === (next.search ?? '')
|
&& (prev.search ?? '') === (next.search ?? '')
|
||||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||||
@@ -206,8 +265,9 @@ export function useClients() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: keys.clients.list(query),
|
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
|
||||||
queryFn: () => fetchClientPage(query),
|
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
|
||||||
|
enabled: withList && query !== null,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
// List is sorted/paged server-side, so the WS patch can't add new or
|
// List is sorted/paged server-side, so the WS patch can't add new or
|
||||||
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
// re-sort rows; poll the current page to keep it live (pauses when hidden).
|
||||||
@@ -218,6 +278,7 @@ export function useClients() {
|
|||||||
const inboundOptionsQuery = useQuery({
|
const inboundOptionsQuery = useQuery({
|
||||||
queryKey: keys.inbounds.options(),
|
queryKey: keys.inbounds.options(),
|
||||||
queryFn: fetchInboundOptions,
|
queryFn: fetchInboundOptions,
|
||||||
|
enabled: withList,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +296,7 @@ export function useClients() {
|
|||||||
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
},
|
},
|
||||||
|
enabled: withList,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,7 +306,11 @@ export function useClients() {
|
|||||||
const allGroups = listQuery.data?.groups ?? [];
|
const allGroups = listQuery.data?.groups ?? [];
|
||||||
const fetched = listQuery.data !== undefined || listQuery.isError;
|
const fetched = listQuery.data !== undefined || listQuery.isError;
|
||||||
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
|
||||||
const loading = listQuery.isFetching;
|
// isFetching is deliberately NOT read here. Touching it makes it a tracked
|
||||||
|
// property, so the 5s refetchInterval notifies twice per cycle — two whole
|
||||||
|
// page renders even when structural sharing leaves the data identical, and
|
||||||
|
// each one bumps rc-table's immutable mark and re-runs every cell renderer.
|
||||||
|
// Callers that want a spinner for an explicit refresh drive it locally.
|
||||||
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
// Showing kept-previous data for a new key (filter/sort/page) — drives the
|
||||||
// table overlay so the 5s background poll doesn't flash it.
|
// table overlay so the 5s background poll doesn't flash it.
|
||||||
const transitioning = listQuery.isPlaceholderData;
|
const transitioning = listQuery.isPlaceholderData;
|
||||||
@@ -277,6 +343,11 @@ export function useClients() {
|
|||||||
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
|
||||||
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
|
||||||
const pageSize = (defaults.pageSize as number) ?? 0;
|
const pageSize = (defaults.pageSize as number) ?? 0;
|
||||||
|
// pageSize 0 means "one long page", which is indistinguishable from "the
|
||||||
|
// settings have not arrived yet" — so callers need this flag to know when the
|
||||||
|
// configured page size is real. isFetched (not isSuccess) so a failed
|
||||||
|
// settings request still lets the page fall back and render.
|
||||||
|
const settingsReady = defaultsQuery.isFetched;
|
||||||
|
|
||||||
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
||||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||||
@@ -565,15 +636,23 @@ export function useClients() {
|
|||||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||||
}
|
}
|
||||||
if (Array.isArray(p.clientTraffics)) {
|
if (Array.isArray(p.clientTraffics)) {
|
||||||
|
// Xray reports a row per client whether or not it moved a byte, so most of
|
||||||
|
// this map used to be zeros. A missing entry and a zero entry render
|
||||||
|
// identically (isActiveSpeed treats both as inactive), so the zeros are
|
||||||
|
// dropped and an unchanged result returns the previous object — which lets
|
||||||
|
// React bail out of the update instead of re-rendering the table.
|
||||||
const next: Record<string, ClientSpeedEntry> = {};
|
const next: Record<string, ClientSpeedEntry> = {};
|
||||||
for (const ct of p.clientTraffics) {
|
for (const ct of p.clientTraffics) {
|
||||||
if (!ct || !ct.email) continue;
|
if (!ct || !ct.email) continue;
|
||||||
|
const up = ct.up || 0;
|
||||||
|
const down = ct.down || 0;
|
||||||
|
if (up === 0 && down === 0) continue;
|
||||||
next[ct.email] = {
|
next[ct.email] = {
|
||||||
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
|
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||||
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
|
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setClientSpeed(next);
|
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
@@ -581,12 +660,17 @@ export function useClients() {
|
|||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
|
||||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||||
if (p.snapshot !== false) setAllClientStats(p.clients);
|
if (p.snapshot !== false) {
|
||||||
|
const rows = p.clients;
|
||||||
|
setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
|
||||||
|
}
|
||||||
|
const active = queryRef.current;
|
||||||
|
if (!active) return;
|
||||||
const byEmail = new Map<string, ClientTraffic>();
|
const byEmail = new Map<string, ClientTraffic>();
|
||||||
for (const row of p.clients) {
|
for (const row of p.clients) {
|
||||||
if (row && row.email) byEmail.set(row.email, row);
|
if (row && row.email) byEmail.set(row.email, row);
|
||||||
}
|
}
|
||||||
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
|
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
let touched = false;
|
let touched = false;
|
||||||
const next = prev.items.slice();
|
const next = prev.items.slice();
|
||||||
@@ -624,7 +708,6 @@ export function useClients() {
|
|||||||
setQuery,
|
setQuery,
|
||||||
inbounds,
|
inbounds,
|
||||||
onlines,
|
onlines,
|
||||||
loading,
|
|
||||||
transitioning,
|
transitioning,
|
||||||
fetched,
|
fetched,
|
||||||
fetchError,
|
fetchError,
|
||||||
@@ -634,6 +717,7 @@ export function useClients() {
|
|||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
settingsReady,
|
||||||
refresh,
|
refresh,
|
||||||
create,
|
create,
|
||||||
bulkCreate,
|
bulkCreate,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
|
||||||
|
const cloneRef = useRef(clone);
|
||||||
|
const equalsRef = useRef(equals);
|
||||||
|
cloneRef.current = clone;
|
||||||
|
equalsRef.current = equals;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<T | undefined>();
|
||||||
|
const [baseline, setBaseline] = useState<T | undefined>();
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
const baselineRef = useRef(baseline);
|
||||||
|
draftRef.current = draft;
|
||||||
|
baselineRef.current = baseline;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (server === undefined) return;
|
||||||
|
const currentDraft = draftRef.current;
|
||||||
|
const currentBaseline = baselineRef.current;
|
||||||
|
const isDirty = currentDraft !== undefined
|
||||||
|
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||||
|
setBaseline(server);
|
||||||
|
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||||
|
setDraft(cloneRef.current(server));
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const markSaved = useCallback((value: T) => {
|
||||||
|
setBaseline(cloneRef.current(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
|
||||||
|
[baseline, draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { draft, setDraft, isDirty, markSaved };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { theme as antdTheme } from 'antd';
|
import { theme as antdTheme } from 'antd';
|
||||||
import type { ThemeConfig } from 'antd';
|
import type { ThemeConfig } from 'antd';
|
||||||
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
document.body.classList.remove('dark', 'light');
|
||||||
|
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||||
if (isUltra) {
|
if (isUltra) {
|
||||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}
|
}
|
||||||
const msg = document.getElementById('message');
|
const msg = document.getElementById('message');
|
||||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
if (msg) {
|
||||||
|
msg.classList.remove('dark', 'light');
|
||||||
|
msg.classList.add(isDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// module load so the document is in the right theme before React mounts.
|
// module load so the document is in the right theme before React mounts.
|
||||||
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
|
|||||||
colorPrimaryActive: '#073ea8',
|
colorPrimaryActive: '#073ea8',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
|
||||||
|
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
|
||||||
|
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
|
||||||
|
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
|
||||||
|
//
|
||||||
|
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
|
||||||
|
// ConfigProvider (there is no root one), and without a fixed key each mints a
|
||||||
|
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
|
||||||
|
// token block under a new class instead of reusing the one already in the head.
|
||||||
|
const SHARED_STYLE_CONFIG = {
|
||||||
|
hashed: false,
|
||||||
|
cssVar: { key: 'xui' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
return {
|
return {
|
||||||
|
...SHARED_STYLE_CONFIG,
|
||||||
algorithm: antdTheme.defaultAlgorithm,
|
algorithm: antdTheme.defaultAlgorithm,
|
||||||
token: LIGHT_CONTRAST_TOKENS,
|
token: LIGHT_CONTRAST_TOKENS,
|
||||||
components: {
|
components: {
|
||||||
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
...SHARED_STYLE_CONFIG,
|
||||||
algorithm: antdTheme.darkAlgorithm,
|
algorithm: antdTheme.darkAlgorithm,
|
||||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||||
components: {
|
components: {
|
||||||
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
applyDom(isDark, isUltra);
|
applyDom(isDark, isUltra);
|
||||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type OutboundTrafficRow,
|
type OutboundTrafficRow,
|
||||||
} from '@/schemas/xray';
|
} from '@/schemas/xray';
|
||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
// One HTTP-mode batch request tests this many outbounds through a single
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||||
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
|||||||
// results progressively.
|
// results progressively.
|
||||||
const HTTP_BATCH_CHUNK = 16;
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
|
function normalizeOutboundTestUrl(url: string) {
|
||||||
|
return url || DEFAULT_TEST_URL;
|
||||||
|
}
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||||
const p = o?.protocol;
|
const p = o?.protocol;
|
||||||
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
|
||||||
const [xraySetting, setXraySettingState] = useState('');
|
const [xraySetting, setXraySettingState] = useState('');
|
||||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||||
|
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||||
|
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||||
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||||
const [testingAll, setTestingAll] = useState(false);
|
const [testingAll, setTestingAll] = useState(false);
|
||||||
|
|
||||||
const oldXraySettingRef = useRef('');
|
|
||||||
const oldOutboundTestUrlRef = useRef('');
|
|
||||||
const syncingRef = useRef(false);
|
const syncingRef = useRef(false);
|
||||||
const xraySettingRef = useRef('');
|
const xraySettingRef = useRef('');
|
||||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||||
|
const savedXraySettingRef = useRef(savedXraySetting);
|
||||||
|
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||||
|
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
|
savedXraySettingRef.current = savedXraySetting;
|
||||||
|
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
|
||||||
// Seed local editor state from the config query. Runs on first fetch and
|
|
||||||
// every time the query refetches (e.g. after a successful save).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configQuery.data) return;
|
if (!configQuery.data) return;
|
||||||
const obj = configQuery.data;
|
const obj = configQuery.data;
|
||||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||||
syncingRef.current = true;
|
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
|
||||||
setXraySettingState(pretty);
|
|
||||||
setTemplateSettingsState(obj.xraySetting);
|
|
||||||
oldXraySettingRef.current = pretty;
|
|
||||||
syncingRef.current = false;
|
|
||||||
setInboundTags(obj.inboundTags || []);
|
setInboundTags(obj.inboundTags || []);
|
||||||
setClientReverseTags(obj.clientReverseTags || []);
|
setClientReverseTags(obj.clientReverseTags || []);
|
||||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|
||||||
|
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
|
if (isDirty) return;
|
||||||
|
syncingRef.current = true;
|
||||||
|
setXraySettingState(pretty);
|
||||||
|
setTemplateSettingsState(obj.xraySetting);
|
||||||
|
setSavedXraySetting(pretty);
|
||||||
|
syncingRef.current = false;
|
||||||
setOutboundTestUrlState(nextUrl);
|
setOutboundTestUrlState(nextUrl);
|
||||||
oldOutboundTestUrlRef.current = nextUrl;
|
setSavedOutboundTestUrl(nextUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
}, [configQuery.data]);
|
}, [configQuery.data]);
|
||||||
|
|
||||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||||
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sentXraySetting = xraySettingRef.current;
|
const sentXraySetting = xraySettingRef.current;
|
||||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||||
xraySetting: sentXraySetting,
|
xraySetting: sentXraySetting,
|
||||||
outboundTestUrl: sentTestUrl,
|
outboundTestUrl: sentTestUrl,
|
||||||
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
},
|
},
|
||||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
oldXraySettingRef.current = sentXraySetting;
|
setSavedXraySetting(sentXraySetting);
|
||||||
oldOutboundTestUrlRef.current = sentTestUrl;
|
setSavedOutboundTestUrl(sentTestUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
}
|
}
|
||||||
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveDisabled = savedXraySetting === xraySetting
|
||||||
const timer = window.setInterval(() => {
|
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
|
||||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
|
||||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
|
||||||
}, DIRTY_POLL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
.ant-sidebar {
|
||||||
|
flex: 0 0 var(--sider-rail, 72px);
|
||||||
|
width: var(--sider-rail, 72px);
|
||||||
|
position: relative;
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-sidebar > .ant-layout-sider {
|
.ant-sidebar > .ant-layout-sider {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -5,6 +12,16 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||||
|
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-nav .ant-menu-item .anticon,
|
||||||
|
.sider-nav .ant-menu-submenu-title .anticon,
|
||||||
|
.sider-utility .ant-menu-item .anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.sider-brand,
|
.sider-brand,
|
||||||
.drawer-brand {
|
.drawer-brand {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -18,16 +35,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px 16px 14px 24px;
|
height: 58px;
|
||||||
|
padding: 0 16px 0 24px;
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
.sider-brand-collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 14px 4px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.brand-block {
|
||||||
@@ -37,10 +50,6 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-brand-collapsed .brand-block {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-actions {
|
.brand-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -246,11 +255,6 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-version.is-collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-footer {
|
.drawer-footer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
@@ -261,8 +265,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +275,11 @@
|
|||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-sidebar {
|
||||||
|
flex: 0 0 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .ant-drawer-content,
|
body.dark .ant-drawer-content,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType, CSSProperties } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Drawer, Layout, Menu } from 'antd';
|
import { Drawer, Layout, Menu } from 'antd';
|
||||||
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
|
const RAIL_WIDTH = 72;
|
||||||
|
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
|
||||||
|
|
||||||
|
let hoveredAcrossRemounts = false;
|
||||||
|
|
||||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
|
||||||
|
|
||||||
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
routing: SwapOutlined,
|
routing: SwapOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
function readCollapsed(): boolean {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
|
|||||||
href={REPO_URL}
|
href={REPO_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
|
className="sider-version"
|
||||||
aria-label={`GitHub ${label}`}
|
aria-label={`GitHub ${label}`}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
@@ -148,8 +143,23 @@ export default function AppSidebar() {
|
|||||||
const { allSetting } = useAllSettings();
|
const { allSetting } = useAllSettings();
|
||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const railCollapsed = !hovered;
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updateHovered = useCallback((value: boolean) => {
|
||||||
|
hoveredAcrossRemounts = value;
|
||||||
|
setHovered(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (el) updateHovered(el.matches(':hover'));
|
||||||
|
}, 150);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [updateHovered]);
|
||||||
|
|
||||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||||
const panelVersion = window.X_UI_CUR_VER || '';
|
const panelVersion = window.X_UI_CUR_VER || '';
|
||||||
@@ -218,7 +228,7 @@ export default function AppSidebar() {
|
|||||||
if (tab.key === '/xray') {
|
if (tab.key === '/xray') {
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||||
}
|
}
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title };
|
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||||
}),
|
}),
|
||||||
[settingsChildren, xrayChildren]);
|
[settingsChildren, xrayChildren]);
|
||||||
|
|
||||||
@@ -235,13 +245,6 @@ export default function AppSidebar() {
|
|||||||
openLink(String(key));
|
openLink(String(key));
|
||||||
}, [openLink]);
|
}, [openLink]);
|
||||||
|
|
||||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
|
||||||
if (type === 'clickTrigger') {
|
|
||||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
|
||||||
setCollapsed(isCollapsed);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cycleTheme = useCallback((id: string) => {
|
const cycleTheme = useCallback((id: string) => {
|
||||||
pauseAnimationsUntilLeave(id);
|
pauseAnimationsUntilLeave(id);
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
@@ -256,20 +259,24 @@ export default function AppSidebar() {
|
|||||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ant-sidebar">
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="ant-sidebar"
|
||||||
|
style={railStyle}
|
||||||
|
onMouseEnter={() => updateHovered(true)}
|
||||||
|
onMouseLeave={() => updateHovered(false)}
|
||||||
|
>
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
width={220}
|
width={220}
|
||||||
collapsible
|
collapsedWidth={RAIL_WIDTH}
|
||||||
collapsed={collapsed}
|
collapsed={railCollapsed}
|
||||||
breakpoint="md"
|
|
||||||
onCollapse={onSiderCollapse}
|
|
||||||
>
|
>
|
||||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
<div className="sider-brand">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!railCollapsed && (
|
||||||
<div className="brand-actions">
|
<div className="brand-actions">
|
||||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||||
@@ -287,7 +294,7 @@ export default function AppSidebar() {
|
|||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
openKeys={collapsed ? undefined : openKeys}
|
openKeys={railCollapsed ? undefined : openKeys}
|
||||||
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||||
className="sider-nav"
|
className="sider-nav"
|
||||||
items={toMenuItems(navItems)}
|
items={toMenuItems(navItems)}
|
||||||
@@ -302,7 +309,7 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
<VersionBadge version={panelVersion} collapsed={collapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class AllSetting {
|
|||||||
ldapDefaultTotalGB = 0;
|
ldapDefaultTotalGB = 0;
|
||||||
ldapDefaultExpiryDays = 0;
|
ldapDefaultExpiryDays = 0;
|
||||||
ldapDefaultLimitIP = 0;
|
ldapDefaultLimitIP = 0;
|
||||||
tgEnabledEvents = '';
|
tgEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpEnable = false;
|
smtpEnable = false;
|
||||||
smtpHost = '';
|
smtpHost = '';
|
||||||
smtpPort = 587;
|
smtpPort = 587;
|
||||||
@@ -101,7 +101,7 @@ export class AllSetting {
|
|||||||
smtpFromName = '';
|
smtpFromName = '';
|
||||||
smtpTo = '';
|
smtpTo = '';
|
||||||
smtpEncryptionType = 'starttls';
|
smtpEncryptionType = 'starttls';
|
||||||
smtpEnabledEvents = '';
|
smtpEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpCpu = 80;
|
smtpCpu = 80;
|
||||||
smtpMemory = 80;
|
smtpMemory = 80;
|
||||||
outboundDownThreshold = 3;
|
outboundDownThreshold = 3;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NumberFormatter } from '@/utils';
|
import { NumberFormatter } from '@/utils';
|
||||||
|
|
||||||
|
export const USAGE_WARN_PERCENT = 80;
|
||||||
|
export const USAGE_CRIT_PERCENT = 90;
|
||||||
|
export const USAGE_WARN_COLOR = '#faad14';
|
||||||
|
export const USAGE_CRIT_COLOR = '#ff4d4f';
|
||||||
|
|
||||||
export class CurTotal {
|
export class CurTotal {
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -16,9 +21,9 @@ export class CurTotal {
|
|||||||
|
|
||||||
get color(): string {
|
get color(): string {
|
||||||
const p = this.percent;
|
const p = this.percent;
|
||||||
if (p < 80) return '#1677ff';
|
if (p < USAGE_WARN_PERCENT) return '#1677ff';
|
||||||
if (p < 90) return '#faad14';
|
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
|
||||||
return '#ff4d4f';
|
return USAGE_CRIT_COLOR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
|
|||||||
description:
|
description:
|
||||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/openapi.json',
|
||||||
|
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/server/status',
|
path: '/panel/api/server/status',
|
||||||
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/clients/list/paged',
|
path: '/panel/api/clients/list/paged',
|
||||||
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
|
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
|
||||||
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
|
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
|
||||||
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
|
|||||||
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
||||||
],
|
],
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}',
|
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -1164,6 +1169,11 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/setting/defaultSettings',
|
path: '/panel/api/setting/defaultSettings',
|
||||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/setting/factoryDefaults',
|
||||||
|
summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/setting/update',
|
path: '/panel/api/setting/update',
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
|
|||||||
}: ClientBulkAddModalProps) {
|
}: ClientBulkAddModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const { bulkCreate } = useClients();
|
const { bulkCreate } = useClients({ list: false });
|
||||||
|
|
||||||
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
|
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
|
||||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-email-more {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Result,
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
|||||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||||
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
||||||
|
import { ClientInboundChips, ClientRowActions } from './RowCells';
|
||||||
import { emptyFilters, activeFilterCount } from './filters';
|
import { emptyFilters, activeFilterCount } from './filters';
|
||||||
import type { ClientFilters } from './filters';
|
import type { ClientFilters } from './filters';
|
||||||
import './ClientsPage.css';
|
import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
const DISABLED_PAGE_SIZE = 200;
|
const DISABLED_PAGE_SIZE = 200;
|
||||||
|
const DEFAULT_TABLE_PAGE_SIZE = 25;
|
||||||
|
|
||||||
function UngroupIcon() {
|
function UngroupIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -126,12 +127,29 @@ function UngroupIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server sends exact counters but caps the email arrays behind them, so a
|
||||||
|
// panel with thousands of depleted clients neither ships nor renders them all.
|
||||||
|
// The trailing chip reports what the popover left out.
|
||||||
|
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
|
||||||
|
const hidden = total - emails.length;
|
||||||
|
return (
|
||||||
|
<div className="client-email-list">
|
||||||
|
{emails.map((e) => <div key={e}>{e}</div>)}
|
||||||
|
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
searchKey: string;
|
searchKey: string;
|
||||||
filters: ClientFilters;
|
filters: ClientFilters;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
// The page size resolved on the previous visit. Without it the first list
|
||||||
|
// request has to wait for /setting/defaultSettings just to learn how many rows
|
||||||
|
// to ask for, which serialises two round trips on every load.
|
||||||
|
pageSize: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
|||||||
tunnel: 'orange',
|
tunnel: 'orange',
|
||||||
};
|
};
|
||||||
const INBOUND_CHIP_LIMIT = 1;
|
const INBOUND_CHIP_LIMIT = 1;
|
||||||
|
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
|
||||||
|
// every unattached client on every render.
|
||||||
|
const EMPTY_INBOUND_IDS: number[] = [];
|
||||||
|
|
||||||
function readFilterState(): PersistedFilterState {
|
function readFilterState(): PersistedFilterState {
|
||||||
try {
|
try {
|
||||||
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
|
|||||||
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
|
||||||
},
|
},
|
||||||
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
sort: typeof raw.sort === 'string' ? raw.sort : '',
|
||||||
|
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { searchKey: '', filters: emptyFilters(), sort: '' };
|
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +230,8 @@ export default function ClientsPage() {
|
|||||||
summary,
|
summary,
|
||||||
allGroups,
|
allGroups,
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
|
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
|
||||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
||||||
clientSpeed,
|
clientSpeed,
|
||||||
@@ -265,14 +287,31 @@ export default function ClientsPage() {
|
|||||||
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
|
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
|
||||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
|
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [tablePageSize, setTablePageSize] = useState(25);
|
// Derived, not mirrored into state by an effect: an effect lags one render
|
||||||
|
// behind the settings arriving, and that lag is what made the page fetch the
|
||||||
|
// list once with the placeholder size and again with the real one.
|
||||||
|
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
|
||||||
|
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
|
||||||
|
// Last visit's resolved size stands in until the settings land, so the list
|
||||||
|
// request goes out with the page mount instead of queueing behind them. If the
|
||||||
|
// admin has since changed the setting the authoritative value replaces it and
|
||||||
|
// costs one refetch — only on the load that follows the change. Null means
|
||||||
|
// nothing is known yet, which is the one case worth waiting for.
|
||||||
|
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
|
||||||
|
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
|
||||||
// debouncedSearch lags behind the input so we don't spam the server on every
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
||||||
// keystroke; the search box still feels instant locally.
|
// keystroke; the search box still feels instant locally.
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
|
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||||
}, [searchKey, filters, sortColumn, sortOrder]);
|
searchKey,
|
||||||
|
filters,
|
||||||
|
sort: sortValueFor(sortColumn, sortOrder),
|
||||||
|
// Only ever persist a size we actually resolved, never the render fallback.
|
||||||
|
pageSize: resolvedPageSize,
|
||||||
|
}));
|
||||||
|
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
|
||||||
@@ -303,6 +342,10 @@ export default function ClientsPage() {
|
|||||||
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
}, [filters.nodeIds, filters.inboundIds, inbounds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// With no remembered size and no settings yet, any query we build would be a
|
||||||
|
// guess, and issuing it costs a full server round trip that is thrown away as
|
||||||
|
// soon as the real size arrives.
|
||||||
|
if (resolvedPageSize === null) return;
|
||||||
setQuery({
|
setQuery({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize: tablePageSize,
|
pageSize: tablePageSize,
|
||||||
@@ -321,13 +364,21 @@ export default function ClientsPage() {
|
|||||||
sort: sortColumn || undefined,
|
sort: sortColumn || undefined,
|
||||||
order: sortOrder || undefined,
|
order: sortOrder || undefined,
|
||||||
});
|
});
|
||||||
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
}, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
|
||||||
|
|
||||||
const activeCount = activeFilterCount(filters);
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
useEffect(() => {
|
// Row handlers take an email and look the row up here at call time. Keying
|
||||||
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
// them on the record object instead would defeat the memoised cells: every
|
||||||
}, [pageSize]);
|
// traffic push replaces the row object of every client whose counters moved,
|
||||||
|
// so the memo would miss on exactly the rows that are busy. Reading through
|
||||||
|
// the ref also means a modal opened mid-poll shows current usage.
|
||||||
|
const rowsByEmail = useRef(new Map<string, ClientRecord>());
|
||||||
|
rowsByEmail.current = useMemo(() => {
|
||||||
|
const map = new Map<string, ClientRecord>();
|
||||||
|
for (const c of clients) map.set(c.email, c);
|
||||||
|
return map;
|
||||||
|
}, [clients]);
|
||||||
|
|
||||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||||
const inboundsById = useMemo(() => {
|
const inboundsById = useMemo(() => {
|
||||||
@@ -454,7 +505,9 @@ export default function ClientsPage() {
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEdit(row: ClientRecord) {
|
const onEdit = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
setFormMode('edit');
|
setFormMode('edit');
|
||||||
// Paged list omits per-client secrets to keep the row payload tiny;
|
// Paged list omits per-client secrets to keep the row payload tiny;
|
||||||
// edit needs them, so fetch the full record first.
|
// edit needs them, so fetch the full record first.
|
||||||
@@ -465,9 +518,11 @@ export default function ClientsPage() {
|
|||||||
setEditingAttachedIds([...ids]);
|
setEditingAttachedIds([...ids]);
|
||||||
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
function onDelete(row: ClientRecord) {
|
const onDelete = useCallback((email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
|
||||||
content: t('pages.clients.deleteConfirmContent'),
|
content: t('pages.clients.deleteConfirmContent'),
|
||||||
@@ -479,9 +534,10 @@ export default function ClientsPage() {
|
|||||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [modal, t, remove, messageApi]);
|
||||||
|
|
||||||
function onResetTraffic(row: ClientRecord) {
|
const onResetTraffic = useCallback((email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
if (!row?.email) {
|
if (!row?.email) {
|
||||||
messageApi.warning(t('pages.clients.resetNotPossible'));
|
messageApi.warning(t('pages.clients.resetNotPossible'));
|
||||||
return;
|
return;
|
||||||
@@ -496,19 +552,33 @@ export default function ClientsPage() {
|
|||||||
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [modal, t, resetTraffic, messageApi]);
|
||||||
|
|
||||||
async function onShowInfo(row: ClientRecord) {
|
const onShowInfo = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
setInfoOpen(true);
|
setInfoOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
async function onShowQr(row: ClientRecord) {
|
const onShowQr = useCallback(async (email: string) => {
|
||||||
|
const row = rowsByEmail.current.get(email);
|
||||||
|
if (!row) return;
|
||||||
const full = await hydrate(row.email);
|
const full = await hydrate(row.email);
|
||||||
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
|
||||||
setQrOpen(true);
|
setQrOpen(true);
|
||||||
}
|
}, [hydrate]);
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const onRefreshClick = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
||||||
setTextTitle(opts.title);
|
setTextTitle(opts.title);
|
||||||
@@ -743,7 +813,7 @@ export default function ClientsPage() {
|
|||||||
|
|
||||||
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
|
||||||
if (pag?.current) setCurrentPage(pag.current);
|
if (pag?.current) setCurrentPage(pag.current);
|
||||||
if (pag?.pageSize) setTablePageSize(pag.pageSize);
|
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
|
||||||
@@ -752,23 +822,14 @@ export default function ClientsPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_v, record) => (
|
render: (_v, record) => (
|
||||||
<Space size={4}>
|
<ClientRowActions
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
email={record.email}
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
|
onShowQr={onShowQr}
|
||||||
</Tooltip>
|
onShowInfo={onShowInfo}
|
||||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
onResetTraffic={onResetTraffic}
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
|
onEdit={onEdit}
|
||||||
</Tooltip>
|
onDelete={onDelete}
|
||||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
/>
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('edit')}>
|
|
||||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('delete')}>
|
|
||||||
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -850,42 +911,13 @@ export default function ClientsPage() {
|
|||||||
key: 'inboundIds',
|
key: 'inboundIds',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (_v, record) => {
|
render: (_v, record) => {
|
||||||
const ids = record.inboundIds || [];
|
|
||||||
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
|
|
||||||
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
|
|
||||||
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
|
|
||||||
const chip = (id: number, compact: boolean) => {
|
|
||||||
const ib = inboundsById[id];
|
|
||||||
const proto = (ib?.protocol || '').toLowerCase();
|
|
||||||
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
|
|
||||||
const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
|
|
||||||
return (
|
|
||||||
<Tooltip key={id} title={inboundLabel(id)}>
|
|
||||||
<Tag color={color} style={{ margin: 2 }}>
|
|
||||||
{compact ? compactLabel : inboundLabel(id)}
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ClientInboundChips
|
||||||
{visible.map((id) => chip(id, true))}
|
ids={record.inboundIds || EMPTY_INBOUND_IDS}
|
||||||
{overflow.length > 0 && (
|
inboundsById={inboundsById}
|
||||||
<Popover
|
protocolColors={INBOUND_PROTOCOL_COLORS}
|
||||||
trigger="click"
|
chipLimit={INBOUND_CHIP_LIMIT}
|
||||||
placement="bottomRight"
|
/>
|
||||||
content={
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
|
|
||||||
{overflow.map((id) => chip(id, false))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
|
|
||||||
+{overflow.length}
|
|
||||||
</Tag>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -994,7 +1026,7 @@ export default function ClientsPage() {
|
|||||||
status="error"
|
status="error"
|
||||||
title={t('somethingWentWrong')}
|
title={t('somethingWentWrong')}
|
||||||
subTitle={fetchError}
|
subTitle={fetchError}
|
||||||
extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
|
extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
@@ -1007,37 +1039,37 @@ export default function ClientsPage() {
|
|||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('online')}
|
title={t('online')}
|
||||||
open={summary.online.length ? undefined : false}
|
open={summary.onlineCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
<Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('depleted')}
|
title={t('depleted')}
|
||||||
open={summary.depleted.length ? undefined : false}
|
open={summary.depletedCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
<Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('depletingSoon')}
|
title={t('depletingSoon')}
|
||||||
open={summary.expiring.length ? undefined : false}
|
open={summary.expiringCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
<Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
title={t('disabled')}
|
title={t('disabled')}
|
||||||
open={summary.deactive.length ? undefined : false}
|
open={summary.deactiveCount ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
|
||||||
>
|
>
|
||||||
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
<Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
@@ -1364,7 +1396,7 @@ export default function ClientsPage() {
|
|||||||
showTotal={(n) => `${n}`}
|
showTotal={(n) => `${n}`}
|
||||||
onChange={(p, s) => {
|
onChange={(p, s) => {
|
||||||
setCurrentPage(p);
|
setCurrentPage(p);
|
||||||
if (s && s !== tablePageSize) setTablePageSize(s);
|
if (s && s !== tablePageSize) setPageSizeChoice(s);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1391,8 +1423,8 @@ export default function ClientsPage() {
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t('pages.clients.clientInfo')}
|
aria-label={t('pages.clients.clientInfo')}
|
||||||
onClick={() => onShowInfo(row)}
|
onClick={() => onShowInfo(row.email)}
|
||||||
onKeyDown={activateOnKey(() => onShowInfo(row))}
|
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1409,23 +1441,23 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
key: 'qr',
|
key: 'qr',
|
||||||
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
|
||||||
onClick: () => onShowQr(row),
|
onClick: () => onShowQr(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reset',
|
key: 'reset',
|
||||||
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
|
||||||
onClick: () => onResetTraffic(row),
|
onClick: () => onResetTraffic(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: <><EditOutlined /> {t('edit')}</>,
|
label: <><EditOutlined /> {t('edit')}</>,
|
||||||
onClick: () => onEdit(row),
|
onClick: () => onEdit(row.email),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
danger: true,
|
danger: true,
|
||||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||||
onClick: () => onDelete(row),
|
onClick: () => onDelete(row.email),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
|
||||||
|
|
||||||
|
interface ClientRowActionsProps {
|
||||||
|
email: string;
|
||||||
|
onShowQr: (email: string) => void;
|
||||||
|
onShowInfo: (email: string) => void;
|
||||||
|
onResetTraffic: (email: string) => void;
|
||||||
|
onEdit: (email: string) => void;
|
||||||
|
onDelete: (email: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
|
||||||
|
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
|
||||||
|
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
|
||||||
|
// Keyed on the email rather than the row object, because a push replaces the row
|
||||||
|
// object of every client whose counters moved; the page resolves the live row.
|
||||||
|
export const ClientRowActions = memo(function ClientRowActions({
|
||||||
|
email,
|
||||||
|
onShowQr,
|
||||||
|
onShowInfo,
|
||||||
|
onResetTraffic,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: ClientRowActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
<Tooltip title={t('pages.clients.qrCode')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
aria-label={t('pages.clients.qrCode')}
|
||||||
|
onClick={() => onShowQr(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
aria-label={t('pages.clients.clientInfo')}
|
||||||
|
onClick={() => onShowInfo(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<RetweetOutlined />}
|
||||||
|
aria-label={t('pages.inbounds.resetTraffic')}
|
||||||
|
onClick={() => onResetTraffic(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('edit')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
aria-label={t('edit')}
|
||||||
|
onClick={() => onEdit(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('delete')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
style={ICON_BUTTON_STYLE}
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label={t('delete')}
|
||||||
|
onClick={() => onDelete(email)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const CHIP_STYLE = { margin: 2 } as const;
|
||||||
|
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
|
||||||
|
const OVERFLOW_LIST_STYLE = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
gap: 4,
|
||||||
|
maxWidth: 280,
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ClientInboundChipsProps {
|
||||||
|
ids: number[];
|
||||||
|
inboundsById: Record<number, InboundOption>;
|
||||||
|
protocolColors: Record<string, string>;
|
||||||
|
chipLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments never change on a traffic push either, so the same memoisation
|
||||||
|
// applies: one Tooltip per visible chip plus a Popover for the overflow.
|
||||||
|
export const ClientInboundChips = memo(function ClientInboundChips({
|
||||||
|
ids,
|
||||||
|
inboundsById,
|
||||||
|
protocolColors,
|
||||||
|
chipLimit,
|
||||||
|
}: ClientInboundChipsProps) {
|
||||||
|
if (ids.length === 0) return <span className="cell-empty">—</span>;
|
||||||
|
|
||||||
|
const label = (id: number) => {
|
||||||
|
const ib = inboundsById[id];
|
||||||
|
return formatInboundLabel(ib?.tag, ib?.remark);
|
||||||
|
};
|
||||||
|
const chip = (id: number) => {
|
||||||
|
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
<Tooltip key={id} title={label(id)}>
|
||||||
|
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = ids.slice(0, chipLimit);
|
||||||
|
const overflow = ids.slice(chipLimit);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map(chip)}
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
|
||||||
|
>
|
||||||
|
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -93,7 +93,7 @@ export default function GroupsPage() {
|
|||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
|
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
|
||||||
|
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: keys.clients.groups(),
|
queryKey: keys.clients.groups(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,51 +1,470 @@
|
|||||||
|
/* Overview page — trend-first layout. Every colour comes from the AntD theme
|
||||||
|
tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
|
||||||
|
Set --ov-accent once here if you want a fixed accent instead of the primary. */
|
||||||
|
|
||||||
|
.index-page {
|
||||||
|
--ov-accent: var(--ant-color-primary);
|
||||||
|
--ov-line: var(--ant-color-border);
|
||||||
|
--ov-label: var(--ant-color-text-secondary);
|
||||||
|
--ov-faint: var(--ant-color-text-tertiary);
|
||||||
|
--ov-gap: 12px;
|
||||||
|
--ov-pad: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.index-page .content-area {
|
.index-page .content-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 64px;
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.index-page {
|
||||||
|
--ov-gap: 8px;
|
||||||
|
--ov-pad: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action {
|
.ov-page {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
max-width: 100%;
|
gap: var(--ov-gap);
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action > span:not(.anticon) {
|
/* — action bar — */
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
.ov-bar {
|
||||||
text-overflow: ellipsis;
|
display: flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action-update {
|
.ov-state {
|
||||||
color: var(--ant-color-warning);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .action-update .anticon {
|
|
||||||
color: var(--ant-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .history-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
color: var(--ov-label);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-toggle-icon {
|
.ov-error-detail {
|
||||||
cursor: pointer;
|
white-space: pre-wrap;
|
||||||
font-size: 16px;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-hidden .ant-statistic-content-value {
|
.ov-bar-actions {
|
||||||
filter: blur(6px);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-tile-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ov-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-legend-num {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px var(--ov-pad) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-conn-legend .ov-legend-label {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-chart {
|
||||||
|
padding: 12px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 12px var(--ov-pad) 0;
|
||||||
|
padding: 14px 0 var(--ov-pad);
|
||||||
|
border-top: 1px solid var(--ov-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-sep {
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ov-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-foot-part {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ov-wide-foot {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-wide-foot .ov-foot-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — system strip — */
|
||||||
|
|
||||||
|
/* tracks follow SystemStrip's cell order:
|
||||||
|
uptime (xray | os) · panel (memory | threads) · ip addresses */
|
||||||
|
.ov-strip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: var(--ov-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1439px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.ov-strip-cell + .ov-strip-cell {
|
||||||
|
border-inline-start: 1px solid var(--ov-line);
|
||||||
|
padding-inline-start: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-value {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ant-color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-split {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-split-sep {
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ov-line);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--ov-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-strip-sub + .ov-strip-value {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-ip {
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
transition: filter 0.2s ease;
|
transition: filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ip-visible .ant-statistic-content-value {
|
.ov-ip-v6 {
|
||||||
filter: none;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,29 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
ConfigProvider,
|
|
||||||
Layout,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Result,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
Statistic,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
BarsOutlined,
|
|
||||||
ControlOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
CloudUploadOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
AreaChartOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
EyeInvisibleOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DatabaseOutlined,
|
|
||||||
ForkOutlined,
|
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
TelegramFilled,
|
CloudDownloadOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
HddOutlined,
|
||||||
|
SwapOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||||
import { formatPanelVersion } from '@/lib/panel-version';
|
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { LazyMount } from '@/components/utility';
|
import { LazyMount } from '@/components/utility';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import StatusCard from './StatusCard';
|
import OverviewActionBar from './OverviewActionBar';
|
||||||
import XrayStatusCard from './XrayStatusCard';
|
import VitalTile from './VitalTile';
|
||||||
|
import ThroughputCard from './ThroughputCard';
|
||||||
|
import ConnectionsCard from './ConnectionsCard';
|
||||||
|
import SystemStrip from './SystemStrip';
|
||||||
|
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
@@ -90,6 +66,8 @@ export default function IndexPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
|
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
||||||
'/panel/api/setting/defaultSettings',
|
'/panel/api/setting/defaultSettings',
|
||||||
@@ -127,10 +105,6 @@ export default function IndexPage() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
function openPanelVersion() {
|
|
||||||
setPanelUpdateOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChannelChange(dev: boolean) {
|
async function handleChannelChange(dev: boolean) {
|
||||||
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
||||||
if (!res?.success) return;
|
if (!res?.success) return;
|
||||||
@@ -139,10 +113,6 @@ export default function IndexPage() {
|
|||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTelegram() {
|
|
||||||
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openConfig() {
|
async function openConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +135,23 @@ export default function IndexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||||
|
const totalDisk = status.disk.total;
|
||||||
|
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||||
|
|
||||||
|
const health = useMemo(() => {
|
||||||
|
const items = [
|
||||||
|
{ name: t('pages.index.cpu'), value: status.cpu.percent },
|
||||||
|
{ name: t('pages.index.memory'), value: status.mem.percent },
|
||||||
|
{ name: t('pages.index.swap'), value: status.swap.percent },
|
||||||
|
{ name: t('pages.index.storage'), value: status.disk.percent },
|
||||||
|
];
|
||||||
|
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
|
||||||
|
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
|
||||||
|
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
|
||||||
|
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
|
||||||
|
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
|
||||||
|
return null;
|
||||||
|
}, [status, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
@@ -190,277 +177,105 @@ export default function IndexPage() {
|
|||||||
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
|
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
<div className="ov-page">
|
||||||
<Col span={24}>
|
<OverviewActionBar
|
||||||
<StatusCard status={status} isMobile={isMobile} />
|
status={status}
|
||||||
</Col>
|
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}>
|
{health && (
|
||||||
<XrayStatusCard
|
<div className="ov-health" style={{ color: health.color }}>
|
||||||
status={status}
|
<span className="ov-health-mark" />
|
||||||
|
{health.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="ov-rule" />
|
||||||
|
|
||||||
|
<div className="ov-vitals">
|
||||||
|
<VitalTile
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
label={t('pages.index.cpu')}
|
||||||
|
percent={status.cpu.percent}
|
||||||
|
statusColor={status.cpu.color}
|
||||||
|
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
||||||
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
|
||||||
|
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
|
||||||
|
data={history.series.cpu}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
accessLogEnable={accessLogEnable}
|
|
||||||
onStopXray={stopXray}
|
|
||||||
onRestartXray={restartXray}
|
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
|
||||||
onOpenLogs={() => setLogsOpen(true)}
|
|
||||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<DatabaseOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.memory')}
|
||||||
<Card
|
percent={status.mem.percent}
|
||||||
title={t('menu.link')}
|
statusColor={status.mem.color}
|
||||||
hoverable
|
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
|
||||||
actions={[
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
|
||||||
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
|
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
|
||||||
<BarsOutlined />
|
data={history.series.mem}
|
||||||
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
isMobile={isMobile}
|
||||||
</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>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<SwapOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.swap')}
|
||||||
<Card
|
percent={status.swap.percent}
|
||||||
title={
|
statusColor={status.swap.color}
|
||||||
<Space>
|
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
||||||
<span>3X-UI</span>
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
|
||||||
{isMobile && displayVersion && (
|
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
|
||||||
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
|
data={history.series.swap}
|
||||||
{panelUpdateInfo.updateAvailable
|
isMobile={isMobile}
|
||||||
? 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>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
<VitalTile
|
||||||
|
icon={<HddOutlined />}
|
||||||
<Col xs={24} lg={12}>
|
label={t('pages.index.storage')}
|
||||||
<Card
|
percent={status.disk.percent}
|
||||||
title={t('pages.index.charts')}
|
statusColor={status.disk.color}
|
||||||
hoverable
|
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
||||||
actions={[
|
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
|
||||||
<Space
|
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
|
||||||
className="action"
|
data={history.series.diskUsage}
|
||||||
key="sys-history"
|
isMobile={isMobile}
|
||||||
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>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</div>
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<div className="ov-mid">
|
||||||
<Card title={t('pages.index.operationHours')} hoverable>
|
<ThroughputCard
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
status={status}
|
||||||
<Col span={12}>
|
up={history.series.netUp}
|
||||||
<Statistic
|
down={history.series.netDown}
|
||||||
title="Xray"
|
labels={history.labels}
|
||||||
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
isMobile={isMobile}
|
||||||
prefix={<ThunderboltOutlined />}
|
/>
|
||||||
/>
|
<ConnectionsCard
|
||||||
</Col>
|
status={status}
|
||||||
<Col span={12}>
|
tcp={history.series.tcpCount}
|
||||||
<Statistic
|
udp={history.series.udpCount}
|
||||||
title="OS"
|
labels={history.labels}
|
||||||
value={TimeFormatter.formatSecond(status.uptime)}
|
isMobile={isMobile}
|
||||||
prefix={<DesktopOutlined />}
|
/>
|
||||||
/>
|
</div>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<SystemStrip
|
||||||
<Card title={t('usage')} hoverable>
|
status={status}
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
showIp={showIp}
|
||||||
<Col span={12}>
|
onToggleIp={() => setShowIp((v) => !v)}
|
||||||
<Statistic
|
/>
|
||||||
title={t('pages.index.memory')}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
|||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
@@ -63,7 +63,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
|||||||
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
|
||||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { HttpUtil, LanguageManager } from '@/utils';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath } from './uriPath';
|
import { sanitizePath } from './uriPath';
|
||||||
@@ -169,7 +169,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
|
||||||
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -178,7 +178,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
|
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
|
||||||
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -207,7 +207,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
|
||||||
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -233,11 +233,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
|
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
|
||||||
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
|
||||||
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -307,7 +307,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
|
||||||
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
|
||||||
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -386,15 +386,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
|
||||||
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
|
||||||
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { RemarkTemplateField } from '@/components/form';
|
import { RemarkTemplateField } from '@/components/form';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
@@ -56,7 +56,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
|
||||||
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
|
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
|
||||||
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
@@ -105,7 +105,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
|
||||||
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
|
|||||||
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
||||||
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
|||||||
<Space.Compact style={{ width: '100%' }}>
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
|
precision={0}
|
||||||
style={{ width: '50%' }}
|
style={{ width: '50%' }}
|
||||||
value={state.num}
|
value={state.num}
|
||||||
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
|
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
|
||||||
aria-label={t('pages.settings.notifyTime.interval')}
|
aria-label={t('pages.settings.notifyTime.interval')}
|
||||||
/>
|
/>
|
||||||
<Select<Unit>
|
<Select<Unit>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -216,10 +217,10 @@ export default function BasicsTab({
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={directHappyEyeballs.tryDelayMs}
|
value={directHappyEyeballs.tryDelayMs}
|
||||||
placeholder="150"
|
placeholder="150"
|
||||||
onChange={(v) => setDirectHappyEyeballs({
|
onChange={onNumber((v) => setDirectHappyEyeballs({
|
||||||
...directHappyEyeballs,
|
...directHappyEyeballs,
|
||||||
tryDelayMs: typeof v === 'number' ? v : 0,
|
tryDelayMs: v,
|
||||||
})}
|
}))}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
|
|
||||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||||
const dnsEnabled = !!dns;
|
const dnsEnabled = !!dns;
|
||||||
|
const sourceHosts = dns?.hosts;
|
||||||
|
const incomingHosts = JSON.stringify(sourceHosts ?? {});
|
||||||
|
const lastWrittenHostsRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dnsEnabled) {
|
||||||
|
lastWrittenHostsRef.current = '{}';
|
||||||
|
setHostsList([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (incomingHosts === lastWrittenHostsRef.current) return;
|
||||||
|
lastWrittenHostsRef.current = incomingHosts;
|
||||||
|
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||||
|
domain,
|
||||||
|
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||||
|
})));
|
||||||
|
}, [dnsEnabled, incomingHosts, sourceHosts]);
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
(mutator: (next: XraySettingsValue) => void) => {
|
||||||
@@ -79,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dns) {
|
|
||||||
setHostsList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const src = dns.hosts || {};
|
|
||||||
setHostsList(
|
|
||||||
Object.entries(src).map(([domain, val]) => ({
|
|
||||||
domain,
|
|
||||||
values: Array.isArray(val) ? [...val] : [String(val)],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [dnsEnabled]);
|
|
||||||
|
|
||||||
function syncHosts(next: HostRow[]) {
|
function syncHosts(next: HostRow[]) {
|
||||||
|
const obj: Record<string, string | string[]> = {};
|
||||||
|
for (const row of next) {
|
||||||
|
if (!row.domain) continue;
|
||||||
|
const vals = (row.values || []).filter(Boolean);
|
||||||
|
if (vals.length === 0) continue;
|
||||||
|
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||||
|
}
|
||||||
|
lastWrittenHostsRef.current = JSON.stringify(obj);
|
||||||
setHostsList(next);
|
setHostsList(next);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.dns) return;
|
if (!tt.dns) return;
|
||||||
const obj: Record<string, string | string[]> = {};
|
|
||||||
for (const row of next) {
|
|
||||||
if (!row.domain) continue;
|
|
||||||
const vals = (row.values || []).filter(Boolean);
|
|
||||||
if (vals.length === 0) continue;
|
|
||||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
|
||||||
}
|
|
||||||
if (Object.keys(obj).length > 0) {
|
if (Object.keys(obj).length > 0) {
|
||||||
(tt.dns as DnsConfig).hosts = obj;
|
(tt.dns as DnsConfig).hosts = obj;
|
||||||
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
||||||
|
|||||||
@@ -68,9 +68,15 @@ export const InboundOptionSchema = z.object({
|
|||||||
|
|
||||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||||
|
|
||||||
|
// The *Count fields are exact; the email arrays stop at the server's cap and
|
||||||
|
// only feed the hover popovers, so never derive a counter from their length.
|
||||||
export const ClientsSummarySchema = z.object({
|
export const ClientsSummarySchema = z.object({
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
active: z.number(),
|
active: z.number(),
|
||||||
|
onlineCount: z.number().optional().default(0),
|
||||||
|
depletedCount: z.number().optional().default(0),
|
||||||
|
expiringCount: z.number().optional().default(0),
|
||||||
|
deactiveCount: z.number().optional().default(0),
|
||||||
online: nullableStringArray,
|
online: nullableStringArray,
|
||||||
depleted: nullableStringArray,
|
depleted: nullableStringArray,
|
||||||
expiring: nullableStringArray,
|
expiring: nullableStringArray,
|
||||||
|
|||||||
@@ -103,3 +103,7 @@ export const AllSettingSchema = z.object({
|
|||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||||
|
|
||||||
|
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
|
||||||
|
|
||||||
|
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useClients } from '@/hooks/useClients';
|
||||||
|
import { makeTestQueryClient } from '@/test/test-utils';
|
||||||
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyPage = {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
filtered: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groups: [],
|
||||||
|
summary: {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
onlineCount: 0,
|
||||||
|
depletedCount: 0,
|
||||||
|
expiringCount: 0,
|
||||||
|
deactiveCount: 0,
|
||||||
|
online: [],
|
||||||
|
depleted: [],
|
||||||
|
expiring: [],
|
||||||
|
deactive: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockPanel(defaults: Record<string, unknown>) {
|
||||||
|
const pagedUrls: string[] = [];
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/clients/list/paged')) {
|
||||||
|
pagedUrls.push(url);
|
||||||
|
return new Msg(true, '', emptyPage);
|
||||||
|
}
|
||||||
|
if (url.includes('/inbounds/options')) return new Msg(true, '', []);
|
||||||
|
return new Msg(true, '', null);
|
||||||
|
});
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
|
||||||
|
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
|
||||||
|
return new Msg(true, '', null);
|
||||||
|
});
|
||||||
|
return pagedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapperFor() {
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useClients query gating', () => {
|
||||||
|
it('does not fetch the list until the page supplies a query', async () => {
|
||||||
|
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||||
|
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||||
|
// The page has not called setQuery yet, so nothing should have gone out —
|
||||||
|
// this is what used to cost a thrown-away round trip on every page load.
|
||||||
|
expect(pagedUrls).toEqual([]);
|
||||||
|
expect(result.current.fetched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues exactly one request for a page load that settles on one query', async () => {
|
||||||
|
const pagedUrls = mockPanel({ pageSize: 50 });
|
||||||
|
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
expect(pagedUrls).toHaveLength(1);
|
||||||
|
expect(pagedUrls[0]).toContain('pageSize=50');
|
||||||
|
expect(pagedUrls[0]).toContain('sort=createdAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches as soon as a query arrives, without waiting for the settings', async () => {
|
||||||
|
// The page remembers the previous visit's page size in localStorage, so on a
|
||||||
|
// return visit it can supply a query on the first render. The hook must not
|
||||||
|
// hold that back behind /setting/defaultSettings, or the two round trips
|
||||||
|
// serialise and the list lands ~160ms later than it needs to.
|
||||||
|
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||||
|
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pagedUrls).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
|
||||||
|
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the list, options and onlines queries for mutation-only callers', async () => {
|
||||||
|
const pagedUrls = mockPanel({ pageSize: 25 });
|
||||||
|
const postSpy = vi.mocked(HttpUtil.post);
|
||||||
|
const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.settingsReady).toBe(true));
|
||||||
|
expect(pagedUrls).toEqual([]);
|
||||||
|
// subSettings still needs defaultSettings; onlines must not be polled.
|
||||||
|
const posted = postSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
|
||||||
|
expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
|
||||||
|
expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
|
||||||
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
|
||||||
|
|
||||||
|
// Counts how often the cell reads the inbound map, which happens once per chip
|
||||||
|
// per render. A traffic push re-renders the row, so if the cell is not memoised
|
||||||
|
// this climbs every five seconds for every visible row.
|
||||||
|
function countingInboundMap(source: Record<number, InboundOption>) {
|
||||||
|
const reads = { count: 0 };
|
||||||
|
const proxy = new Proxy(source, {
|
||||||
|
get(target, key) {
|
||||||
|
if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
|
||||||
|
return target[key as unknown as number];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { proxy, reads };
|
||||||
|
}
|
||||||
|
|
||||||
|
const INBOUNDS: Record<number, InboundOption> = {
|
||||||
|
1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
|
||||||
|
2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
return <>{children(() => setTick((n) => n + 1))}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('clients table row cells', () => {
|
||||||
|
it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
|
||||||
|
const { proxy, reads } = countingInboundMap(INBOUNDS);
|
||||||
|
const ids = [1, 2];
|
||||||
|
let bump: () => void = () => {};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Harness>
|
||||||
|
{(doBump) => {
|
||||||
|
bump = doBump;
|
||||||
|
return (
|
||||||
|
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Harness>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const afterFirstRender = reads.count;
|
||||||
|
expect(afterFirstRender).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Three simulated traffic pushes: the parent re-renders, the props do not change.
|
||||||
|
for (let i = 0; i < 3; i++) bump();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(reads.count).toBe(afterFirstRender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders the chips when the attachments actually change', async () => {
|
||||||
|
const { proxy, reads } = countingInboundMap(INBOUNDS);
|
||||||
|
|
||||||
|
function Swapper() {
|
||||||
|
const [ids, setIds] = useState<number[]>([1]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setIds([1, 2])}>swap</button>
|
||||||
|
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Swapper />);
|
||||||
|
const before = reads.count;
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'swap' }));
|
||||||
|
expect(reads.count).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row actions wired to the right client across re-renders', async () => {
|
||||||
|
const onShowQr = vi.fn();
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
const noop = vi.fn();
|
||||||
|
let bump: () => void = () => {};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Harness>
|
||||||
|
{(doBump) => {
|
||||||
|
bump = doBump;
|
||||||
|
return (
|
||||||
|
<ClientRowActions
|
||||||
|
email="alice@x"
|
||||||
|
onShowQr={onShowQr}
|
||||||
|
onShowInfo={noop}
|
||||||
|
onResetTraffic={noop}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Harness>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) bump();
|
||||||
|
|
||||||
|
// Queried by position rather than label: the suite loads the real en-US
|
||||||
|
// bundle, so the aria-labels are translated strings, not keys. Order is
|
||||||
|
// QR, info, reset traffic, edit, delete.
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons).toHaveLength(5);
|
||||||
|
await userEvent.click(buttons[0]);
|
||||||
|
await userEvent.click(buttons[3]);
|
||||||
|
|
||||||
|
expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
|
||||||
|
expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
|
import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
|
||||||
import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
|
import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
|
||||||
|
|
||||||
// Parity with web/service/client.go buildClientsSummary: the same client must
|
// Parity with web/service/client.go buildClientsSummary: the same client must
|
||||||
@@ -42,6 +42,24 @@ describe('computeClientsSummary', () => {
|
|||||||
expect(s.active).toBe(2); // online@x + offline@x
|
expect(s.active).toBe(2); // online@x + offline@x
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports a counter alongside every bucket list', () => {
|
||||||
|
const stats: Row[] = [
|
||||||
|
row({ email: 'online@x', enable: true }),
|
||||||
|
row({ email: 'disabled@x', enable: false }),
|
||||||
|
row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
|
||||||
|
row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
|
||||||
|
];
|
||||||
|
const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
|
||||||
|
|
||||||
|
// The server caps its lists but never its counters; the live recompute has
|
||||||
|
// both, so the summary card reads the same either way.
|
||||||
|
expect(s.onlineCount).toBe(s.online.length);
|
||||||
|
expect(s.depletedCount).toBe(s.depleted.length);
|
||||||
|
expect(s.expiringCount).toBe(s.expiring.length);
|
||||||
|
expect(s.deactiveCount).toBe(s.deactive.length);
|
||||||
|
expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
|
||||||
|
});
|
||||||
|
|
||||||
it('depleted wins over disabled and over online', () => {
|
it('depleted wins over disabled and over online', () => {
|
||||||
const stats: Row[] = [
|
const stats: Row[] = [
|
||||||
row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
|
row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
|
||||||
@@ -63,7 +81,9 @@ describe('computeClientsSummary', () => {
|
|||||||
|
|
||||||
describe('pickClientsSummary', () => {
|
describe('pickClientsSummary', () => {
|
||||||
const serverSummary: ClientsSummary = {
|
const serverSummary: ClientsSummary = {
|
||||||
total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
|
total: 67, active: 58,
|
||||||
|
onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
|
||||||
|
online: [], depleted: [], expiring: [], deactive: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
|
it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
|
||||||
@@ -84,3 +104,39 @@ describe('pickClientsSummary', () => {
|
|||||||
expect(s).toEqual(serverSummary);
|
expect(s).toEqual(serverSummary);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('websocket payload identity preservation', () => {
|
||||||
|
const speed = (up: number, down: number) => ({ up, down });
|
||||||
|
|
||||||
|
it('treats an unchanged speed map as unchanged', () => {
|
||||||
|
const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
|
||||||
|
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
|
||||||
|
expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
|
||||||
|
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
|
||||||
|
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
|
||||||
|
expect(sameSpeedMap({}, {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
|
||||||
|
const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
|
||||||
|
|
||||||
|
// lastOnline moves for every online client on every push and no counter
|
||||||
|
// depends on it, so it must not force a new snapshot.
|
||||||
|
const onlyLastOnlineMoved: Row[] = [
|
||||||
|
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
|
||||||
|
];
|
||||||
|
expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
|
||||||
|
|
||||||
|
for (const changed of [
|
||||||
|
row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
|
||||||
|
row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
|
||||||
|
row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
|
||||||
|
row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
|
||||||
|
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
|
||||||
|
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
|
||||||
|
]) {
|
||||||
|
expect(sameSummaryInputs(base, [changed])).toBe(false);
|
||||||
|
}
|
||||||
|
expect(sameSummaryInputs(base, [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
|
||||||
|
import { makeTestQueryClient, renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function clientWithDefaults(defaults: Record<string, string>) {
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
queryClient.setQueryData(keys.settings.factoryDefaults(), defaults);
|
||||||
|
return queryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('matchesFactoryDefault', () => {
|
||||||
|
it('compares by value with type-aware coercion', () => {
|
||||||
|
expect(matchesFactoryDefault(2096, '2096')).toBe(true);
|
||||||
|
expect(matchesFactoryDefault(8443, '2096')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault(true, 'true')).toBe(true);
|
||||||
|
expect(matchesFactoryDefault(false, 'true')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true);
|
||||||
|
expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never matches when the key has no shipped default', () => {
|
||||||
|
expect(matchesFactoryDefault(2096, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects blank or unparsable defaults instead of coercing them', () => {
|
||||||
|
expect(matchesFactoryDefault(0, '')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault(0, ' ')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault(0, 'none')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault(false, '')).toBe(false);
|
||||||
|
expect(matchesFactoryDefault(false, 'no')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DefaultSettingTag', () => {
|
||||||
|
it('shows the tag when the current value equals the shipped default, however it got there', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||||
|
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Default')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when the value differs from the default', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<DefaultSettingTag settingKey="subPort" value={8443} />,
|
||||||
|
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Default')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while defaults are unknown', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<DefaultSettingTag settingKey="subPort" value={2096} />,
|
||||||
|
{ queryClient: makeTestQueryClient() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Default')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import DnsTab from '@/pages/xray/dns/DnsTab';
|
||||||
|
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function withHosts(hosts: Record<string, string>): XraySettingsValue {
|
||||||
|
return {
|
||||||
|
dns: {
|
||||||
|
hosts,
|
||||||
|
servers: [],
|
||||||
|
},
|
||||||
|
} as unknown as XraySettingsValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DnsTab', () => {
|
||||||
|
it('keeps an empty row after adding a host', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<Harness />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
|
||||||
|
|
||||||
|
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a row visible while its domain is incomplete', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows hosts from an externally refreshed configuration', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
|
||||||
|
Refresh hosts
|
||||||
|
</button>
|
||||||
|
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears an incomplete host draft when DNS is disabled', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
|
||||||
|
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
|
||||||
|
import { AllSetting } from '@/models/setting';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Contract test between the three homes of a setting's default value: the Go
|
||||||
|
* defaultValueMap (authoritative, served by /setting/factoryDefaults), the
|
||||||
|
* frontend AllSetting class defaults (what the form shows when the API omits
|
||||||
|
* a value), and the tag's own comparison. If someone bumps a default on one
|
||||||
|
* side only, the Default tag would start calling a different value "Default"
|
||||||
|
* than the one the form displays — this test fails instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function goDefaultLiterals(): Record<string, string> {
|
||||||
|
const source = readFileSync(
|
||||||
|
resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const start = source.indexOf('var defaultValueMap = map[string]string{');
|
||||||
|
const end = source.indexOf('\n}', start);
|
||||||
|
const block = source.slice(start, end);
|
||||||
|
const literals: Record<string, string> = {};
|
||||||
|
for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) {
|
||||||
|
literals[match[1]] = JSON.parse(`"${match[2]}"`);
|
||||||
|
}
|
||||||
|
return literals;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('factory defaults contract', () => {
|
||||||
|
const goDefaults = goDefaultLiterals();
|
||||||
|
const frontend = new AllSetting() as unknown as Record<string, unknown>;
|
||||||
|
const sharedKeys = Object.keys(goDefaults).filter((key) => {
|
||||||
|
const value = frontend[key];
|
||||||
|
return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a plausible slice of the Go map', () => {
|
||||||
|
expect(goDefaults.webPort).toBe('2053');
|
||||||
|
expect(goDefaults.subPort).toBe('2096');
|
||||||
|
expect(sharedKeys.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => {
|
||||||
|
expect(
|
||||||
|
matchesFactoryDefault(frontend[key], goDefaults[key]),
|
||||||
|
`AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,4 +193,87 @@ describe('http-init fetch wrapper', () => {
|
|||||||
|
|
||||||
expect(initOf().signal).toBeInstanceOf(AbortSignal);
|
expect(initOf().signal).toBeInstanceOf(AbortSignal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a caller cancellation signal when a timeout is set', async () => {
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
expect(initOf().signal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
|
||||||
|
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal('AbortSignal', { timeout });
|
||||||
|
http = await import('@/api/http-init');
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
expect(initOf().signal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out when AbortSignal.any is unavailable', async () => {
|
||||||
|
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal('AbortSignal', { timeout });
|
||||||
|
http = await import('@/api/http-init');
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
|
||||||
|
const signal = initOf().signal as AbortSignal;
|
||||||
|
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
|
||||||
|
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts on the timeout when a caller signal is present', async () => {
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
|
||||||
|
const signal = initOf().signal as AbortSignal;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/x?keep=1', '/x?keep=1&added=yes'],
|
||||||
|
['/x?', '/x?added=yes'],
|
||||||
|
['/x#frag', '/x?added=yes#frag'],
|
||||||
|
['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
|
||||||
|
])('appends encoded params to %s', async (url, expected) => {
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
|
||||||
|
await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
|
||||||
|
|
||||||
|
expect(urlOf()).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the URL when no params are supplied', async () => {
|
||||||
|
http.setupHttp();
|
||||||
|
fetchMock.mockResolvedValue(okEnvelope());
|
||||||
|
|
||||||
|
await http.httpRequest('GET', '/x?keep=1#frag');
|
||||||
|
|
||||||
|
expect(urlOf()).toBe('/x?keep=1#frag');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Guards the 13-locale translation set two ways: every key in en-US must be
|
||||||
|
* referenced somewhere in the frontend or Go sources (dead keys accumulate
|
||||||
|
* silently — this test deleted over two hundred of them when it was
|
||||||
|
* introduced), and every locale must carry exactly the en-US key set
|
||||||
|
* (missing keys fall back to en-US at runtime, so nothing else fails the
|
||||||
|
* build when a translation is forgotten).
|
||||||
|
*
|
||||||
|
* References are matched as whole dotted tokens, not substrings, so a dead
|
||||||
|
* key cannot hide behind a longer live sibling. Dynamically built keys are
|
||||||
|
* covered by harvesting the string-literal prefixes that appear next to
|
||||||
|
* concatenation or template-literal interpolation; the prefix needs at
|
||||||
|
* least one dot but need not end on one, so a literal that stops mid-leaf
|
||||||
|
* right before the interpolation still keeps its subtree alive. This file
|
||||||
|
* excludes itself from the scan so the prose above cannot whitelist
|
||||||
|
* anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const repoRoot = resolve(process.cwd(), '..');
|
||||||
|
const translationDir = join(repoRoot, 'internal', 'web', 'translation');
|
||||||
|
const selfPath = fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = `${prefix}${k}`;
|
||||||
|
if (v !== null && typeof v === 'object') {
|
||||||
|
keys.push(...flattenKeys(v as Record<string, unknown>, `${key}.`));
|
||||||
|
} else {
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSources(dir: string, exts: string[], out: string[]): void {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry)) continue;
|
||||||
|
const full = join(dir, entry);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
collectSources(full, exts, out);
|
||||||
|
} else if (exts.some((ext) => entry.endsWith(ext)) && resolve(full) !== selfPath) {
|
||||||
|
out.push(readFileSync(full, 'utf8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('i18n keys', () => {
|
||||||
|
const enUS = JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8'));
|
||||||
|
const enKeys = flattenKeys(enUS);
|
||||||
|
|
||||||
|
const sources: string[] = [];
|
||||||
|
collectSources(join(repoRoot, 'frontend', 'src'), ['.ts', '.tsx'], sources);
|
||||||
|
collectSources(join(repoRoot, 'internal'), ['.go'], sources);
|
||||||
|
const blob = sources.join('\n');
|
||||||
|
|
||||||
|
const tokens = new Set(blob.match(/[A-Za-z][A-Za-z0-9_.]*/g) ?? []);
|
||||||
|
|
||||||
|
const prefixes: string[] = [];
|
||||||
|
for (const match of blob.matchAll(/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g)) {
|
||||||
|
prefixes.push(match[1]);
|
||||||
|
}
|
||||||
|
for (const match of blob.matchAll(/[`']([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)\$\{/g)) {
|
||||||
|
prefixes.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('every en-US key is referenced by the frontend or Go sources', () => {
|
||||||
|
const dead = enKeys.filter(
|
||||||
|
(key) => !tokens.has(key) && !prefixes.some((p) => key.startsWith(p)),
|
||||||
|
);
|
||||||
|
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every locale carries exactly the en-US key set', () => {
|
||||||
|
const enSet = new Set(enKeys);
|
||||||
|
for (const file of readdirSync(translationDir)) {
|
||||||
|
if (!file.endsWith('.json') || file === 'en-US.json') continue;
|
||||||
|
const keys = new Set(flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))));
|
||||||
|
const missing = enKeys.filter((k) => !keys.has(k));
|
||||||
|
const orphans = [...keys].filter((k) => !enSet.has(k));
|
||||||
|
expect(missing, `${file} is missing keys:\n ${missing.join('\n ')}`).toEqual([]);
|
||||||
|
expect(orphans, `${file} has keys absent from en-US:\n ${orphans.join('\n ')}`).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { afterEach, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { withTheme } from '../../.storybook/preview';
|
||||||
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
function Story() {
|
||||||
|
return <div>Story</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
|
||||||
|
return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.className = '';
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves unrelated body classes when applying the Storybook theme', () => {
|
||||||
|
document.body.className = 'storybook-fixture dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||||
|
const { rerender } = render(<StorybookTheme theme="light" />);
|
||||||
|
|
||||||
|
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('light')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('dark')).toBe(false);
|
||||||
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||||
|
|
||||||
|
rerender(<StorybookTheme theme="dark" />);
|
||||||
|
|
||||||
|
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('dark')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('light')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves unrelated body classes when applying the panel theme', () => {
|
||||||
|
document.body.className = 'panel-fixture';
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.id = 'message';
|
||||||
|
message.className = 'message-fixture';
|
||||||
|
document.body.append(message);
|
||||||
|
|
||||||
|
render(<ThemeProvider><div>Panel</div></ThemeProvider>);
|
||||||
|
|
||||||
|
expect(document.body.classList.contains('panel-fixture')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('dark')).toBe(true);
|
||||||
|
expect(document.body.classList.contains('light')).toBe(false);
|
||||||
|
expect(message.classList.contains('message-fixture')).toBe(true);
|
||||||
|
expect(message.classList.contains('dark')).toBe(true);
|
||||||
|
});
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { render, fireEvent } from '@testing-library/react';
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
|
|
||||||
export function renderWithProviders(ui: ReactElement) {
|
export function makeTestQueryClient() {
|
||||||
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithProviders(ui: ReactElement, options?: { queryClient?: QueryClient }) {
|
||||||
|
const queryClient = options?.queryClient ?? makeTestQueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>{ui}</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fieldLabels(): string[] {
|
export function fieldLabels(): string[] {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { makeTestQueryClient } from '@/test/test-utils';
|
||||||
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAllSettings', () => {
|
||||||
|
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
|
||||||
|
const subJsonUserAgentRegex = 'x'.repeat(2_049);
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||||
|
const values = [
|
||||||
|
{ webPort: 2053 },
|
||||||
|
{ webPort: 2054 },
|
||||||
|
];
|
||||||
|
let index = 0;
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
act(() => result.current.updateSetting({ webPort: 3000 }));
|
||||||
|
await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
|
|
||||||
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
||||||
|
expect(result.current.allSetting.webPort).toBe(3000);
|
||||||
|
expect(result.current.saveDisabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates redacted secrets after a successful save', async () => {
|
||||||
|
let fetchCount = 0;
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||||
|
if (url === '/panel/api/setting/all') {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||||
|
}
|
||||||
|
return new Msg(true, '');
|
||||||
|
});
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||||
|
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||||
|
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||||
|
expect(result.current.saveDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('establishes a saved baseline for a full-payload security save', async () => {
|
||||||
|
let fetchCount = 0;
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||||
|
if (url === '/panel/api/setting/all') {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||||
|
}
|
||||||
|
return new Msg(true, '');
|
||||||
|
});
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||||
|
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||||
|
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||||
|
expect(result.current.saveDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useXraySetting } from '@/hooks/useXraySetting';
|
||||||
|
import { makeTestQueryClient } from '@/test/test-utils';
|
||||||
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
|
||||||
|
function xrayPayload(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
xraySetting: {},
|
||||||
|
inboundTags: [],
|
||||||
|
clientReverseTags: [],
|
||||||
|
outboundTestUrl: 'https://test.example',
|
||||||
|
subscriptionOutbounds: [],
|
||||||
|
subscriptionOutboundTags: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useXraySetting', () => {
|
||||||
|
it('refreshes server-derived outbounds while the editor is dirty', async () => {
|
||||||
|
let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||||
|
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||||
|
return new Msg(true, '');
|
||||||
|
});
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
act(() => result.current.setXraySetting('{"outbounds":[]}'));
|
||||||
|
payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
|
||||||
|
await act(async () => result.current.fetchAll());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
|
||||||
|
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the outbound test URL input empty when it is cleared', async () => {
|
||||||
|
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||||
|
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||||
|
return new Msg(true, '');
|
||||||
|
});
|
||||||
|
const queryClient = makeTestQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
|
act(() => result.current.setOutboundTestUrl(''));
|
||||||
|
|
||||||
|
expect(result.current.outboundTestUrl).toBe('');
|
||||||
|
expect(result.current.saveDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||||
|
|
||||||
|
describe('useServerDraft', () => {
|
||||||
|
it('keeps an edited draft when the server refetches', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{ initialProps: { server: { value: 'one' } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.setDraft({ value: 'edited' }));
|
||||||
|
rerender({ server: { value: 'two' } });
|
||||||
|
|
||||||
|
expect(result.current.draft).toEqual({ value: 'edited' });
|
||||||
|
expect(result.current.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a refetch that matches the saved draft', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{ initialProps: { server: { value: 'one' } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.setDraft({ value: 'saved' }));
|
||||||
|
rerender({ server: { value: 'saved' } });
|
||||||
|
|
||||||
|
expect(result.current.draft).toEqual({ value: 'saved' });
|
||||||
|
expect(result.current.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares a preserved draft with the latest server value', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{ initialProps: { server: { value: 'one' } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.setDraft({ value: 'later' }));
|
||||||
|
rerender({ server: { value: 'saved' } });
|
||||||
|
act(() => result.current.setDraft({ value: 'one' }));
|
||||||
|
|
||||||
|
expect(result.current.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates clean drafts under StrictMode', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{
|
||||||
|
initialProps: { server: { value: 'one' } },
|
||||||
|
wrapper: StrictMode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ server: { value: 'two' } });
|
||||||
|
|
||||||
|
expect(result.current.draft).toEqual({ value: 'two' });
|
||||||
|
expect(result.current.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an edit made before the first server response', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{ initialProps: { server: undefined as { value: string } | undefined } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.setDraft({ value: 'edited' }));
|
||||||
|
rerender({ server: { value: 'one' } });
|
||||||
|
|
||||||
|
expect(result.current.draft).toEqual({ value: 'edited' });
|
||||||
|
expect(result.current.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a sent draft clean before its refetch arrives', () => {
|
||||||
|
const { result } = renderHook(
|
||||||
|
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||||
|
{ initialProps: { server: { value: 'one' } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current.setDraft({ value: 'saved' }));
|
||||||
|
act(() => result.current.markSaved({ value: 'saved' }));
|
||||||
|
|
||||||
|
expect(result.current.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { ClientPageResponseSchema } from '@/schemas/client';
|
||||||
|
import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMsg', () => {
|
||||||
|
it('rejects a successful response whose payload violates its schema', () => {
|
||||||
|
const msg = new Msg(true, '', { id: 'not-a-number' });
|
||||||
|
|
||||||
|
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
|
||||||
|
'test/value response failed validation',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a missing successful payload for callers that handle empty values', () => {
|
||||||
|
expect(parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed paged-client payloads', () => {
|
||||||
|
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
|
||||||
|
|
||||||
|
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
|
||||||
|
'clients/list/paged response failed validation',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchXrayConfig', () => {
|
||||||
|
it('keeps a malformed xray payload available for repair', async () => {
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
|
||||||
|
|
||||||
|
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -685,6 +685,14 @@ export class CPUFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TimeFormatter {
|
export class TimeFormatter {
|
||||||
|
static formatClock(unixSec: number): string {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
static formatSecond(second: number): string {
|
static formatSecond(second: number): string {
|
||||||
if (second < 60) return second.toFixed(0) + 's';
|
if (second < 60) return second.toFixed(0) + 's';
|
||||||
if (second < 3600) return (second / 60).toFixed(0) + 'm';
|
if (second < 3600) return (second / 60).toFixed(0) + 'm';
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { Msg } from '@/utils';
|
import { Msg } from '@/utils';
|
||||||
|
|
||||||
|
interface ParseMsgOptions {
|
||||||
|
strict?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMsg<T extends z.ZodType>(
|
export function parseMsg<T extends z.ZodType>(
|
||||||
msg: Msg<unknown>,
|
msg: Msg<unknown>,
|
||||||
schema: T,
|
schema: T,
|
||||||
context: string,
|
context: string,
|
||||||
|
options: ParseMsgOptions = {},
|
||||||
): Msg<z.infer<T>> {
|
): Msg<z.infer<T>> {
|
||||||
if (!msg.success || msg.obj == null) {
|
if (!msg.success || msg.obj == null) {
|
||||||
return msg as Msg<z.infer<T>>;
|
return msg as Msg<z.infer<T>>;
|
||||||
@@ -12,6 +17,7 @@ export function parseMsg<T extends z.ZodType>(
|
|||||||
const result = schema.safeParse(msg.obj);
|
const result = schema.safeParse(msg.obj);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
|
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
|
||||||
|
if (options.strict) throw new Error(`${context} response failed validation`);
|
||||||
return msg as Msg<z.infer<T>>;
|
return msg as Msg<z.infer<T>>;
|
||||||
}
|
}
|
||||||
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
|
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
|
||||||
|
|||||||
@@ -171,7 +171,13 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir,
|
outDir,
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: true,
|
// Everything in outDir is embedded into the Go binary via embed.FS, so
|
||||||
|
// production sourcemaps (~18MB across 112 files, 72% of dist) ship inside
|
||||||
|
// every release build. Nothing consumes them there; `npm run dev` serves
|
||||||
|
// its own maps regardless of this setting. To debug a minified bundle
|
||||||
|
// (including the XUI_DEBUG serve-from-disk path), build once with
|
||||||
|
// XUI_SOURCEMAP=true — no tracked-file edit to accidentally commit.
|
||||||
|
sourcemap: process.env.XUI_SOURCEMAP === 'true',
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
chunkSizeWarningLimit: 1500,
|
chunkSizeWarningLimit: 1500,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.49
|
||||||
github.com/mymmrac/telego v1.11.1
|
github.com/mymmrac/telego v1.11.1
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6
|
github.com/shirou/gopsutil/v4 v4.26.6
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.72.0
|
github.com/valyala/fasthttp v1.73.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
|
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
@@ -68,7 +69,6 @@ require (
|
|||||||
github.com/leodido/go-urn v1.5.0 // indirect
|
github.com/leodido/go-urn v1.5.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.48 // indirect
|
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
@@ -99,7 +99,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.29.0 // indirect
|
golang.org/x/arch v0.29.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
|
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
|
||||||
golang.org/x/mod v0.38.0 // indirect
|
golang.org/x/mod v0.38.0 // indirect
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
@@ -108,7 +108,7 @@ require (
|
|||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+
|
|||||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -204,8 +204,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
|||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
@@ -250,8 +250,8 @@ golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
|||||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
|
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
||||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
|
|||||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.5.0
|
3.6.0
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||||
|
if err := InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
seed := make([]model.Setting, 128)
|
||||||
|
value := strings.Repeat("x", 1024)
|
||||||
|
for i := range seed {
|
||||||
|
seed[i] = model.Setting{Key: fmt.Sprintf("backup-seed-%d", i), Value: value}
|
||||||
|
}
|
||||||
|
if err := db.Create(&seed).Error; err != nil {
|
||||||
|
t.Fatalf("seed database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
firstWrite := make(chan error, 1)
|
||||||
|
writesDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for i := range 128 {
|
||||||
|
if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
|
||||||
|
if i == 0 {
|
||||||
|
firstWrite <- err
|
||||||
|
}
|
||||||
|
writesDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
firstWrite <- nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
writesDone <- nil
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writesDone <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := <-firstWrite; err != nil {
|
||||||
|
t.Fatalf("first concurrent write: %v", err)
|
||||||
|
}
|
||||||
|
backupPath := filepath.Join(t.TempDir(), "backup.db")
|
||||||
|
if err := BackupSQLite(backupPath); err != nil {
|
||||||
|
close(stop)
|
||||||
|
<-writesDone
|
||||||
|
t.Fatalf("BackupSQLite: %v", err)
|
||||||
|
}
|
||||||
|
close(stop)
|
||||||
|
if err := <-writesDone; err != nil {
|
||||||
|
t.Fatalf("concurrent write: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateSQLiteDB(backupPath); err != nil {
|
||||||
|
t.Fatalf("validate backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backup, err := sql.Open("sqlite3", backupPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open backup: %v", err)
|
||||||
|
}
|
||||||
|
defer backup.Close()
|
||||||
|
|
||||||
|
var seedCount int
|
||||||
|
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key LIKE 'backup-seed-%'").Scan(&seedCount); err != nil {
|
||||||
|
t.Fatalf("count seeded rows: %v", err)
|
||||||
|
}
|
||||||
|
if seedCount != 128 {
|
||||||
|
t.Fatalf("seeded row count = %d, want 128", seedCount)
|
||||||
|
}
|
||||||
|
var firstWriteCount int
|
||||||
|
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key = 'backup-write-0'").Scan(&firstWriteCount); err != nil {
|
||||||
|
t.Fatalf("count first concurrent write: %v", err)
|
||||||
|
}
|
||||||
|
if firstWriteCount != 1 {
|
||||||
|
t.Fatalf("first concurrent write count = %d, want 1", firstWriteCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupSQLiteTimesOutWaitingForSourceConnection(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
||||||
|
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get database connection pool: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
held, err := sqlDB.Conn(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hold source connection: %v", err)
|
||||||
|
}
|
||||||
|
defer held.Close()
|
||||||
|
|
||||||
|
previousTimeout := backupSQLiteTimeout
|
||||||
|
backupSQLiteTimeout = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { backupSQLiteTimeout = previousTimeout })
|
||||||
|
err = BackupSQLite(filepath.Join(t.TempDir(), "backup.db"))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("BackupSQLite error = %v, want context deadline exceeded", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupSQLiteRefusesExistingDestination(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
||||||
|
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
backupPath := filepath.Join(t.TempDir(), "backup.db")
|
||||||
|
if err := os.WriteFile(backupPath, []byte("existing backup"), 0o600); err != nil {
|
||||||
|
t.Fatalf("create existing destination: %v", err)
|
||||||
|
}
|
||||||
|
err := BackupSQLite(backupPath)
|
||||||
|
want := fmt.Sprintf("sqlite backup destination already exists: %s", backupPath)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("BackupSQLite error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read existing destination: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "existing backup" {
|
||||||
|
t.Fatalf("existing destination = %q, want %q", data, "existing backup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupSQLiteStepPages(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
||||||
|
if got := backupSQLiteStepPages(); got != -1 {
|
||||||
|
t.Fatalf("WAL backup step pages = %d, want -1", got)
|
||||||
|
}
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "DELETE")
|
||||||
|
if got := backupSQLiteStepPages(); got != 128 {
|
||||||
|
t.Fatalf("DELETE backup step pages = %d, want 128", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitDBCleansBackupDirectories(t *testing.T) {
|
||||||
|
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
orphanDir := filepath.Join(dbDir, sqliteBackupDirPrefix+"orphan")
|
||||||
|
if err := os.Mkdir(orphanDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create orphan backup directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(orphanDir, "backup.db"), []byte("backup"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write orphan backup: %v", err)
|
||||||
|
}
|
||||||
|
regularDir := filepath.Join(dbDir, ".x-ui-keep")
|
||||||
|
if err := os.Mkdir(regularDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create regular directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
if _, err := os.Stat(orphanDir); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("orphan backup directory error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(regularDir); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("regular directory info = %v, %v; want existing directory", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-5
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,6 +26,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
|
"github.com/mattn/go-sqlite3"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -32,6 +35,8 @@ import (
|
|||||||
|
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
|
|
||||||
|
var backupSQLiteTimeout = 2 * time.Minute
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DialectSQLite = "sqlite"
|
DialectSQLite = "sqlite"
|
||||||
DialectPostgres = "postgres"
|
DialectPostgres = "postgres"
|
||||||
@@ -52,8 +57,9 @@ func Dialect() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultUsername = "admin"
|
defaultUsername = "admin"
|
||||||
defaultPassword = "admin"
|
defaultPassword = "admin"
|
||||||
|
sqliteBackupDirPrefix = ".x-ui-backup-"
|
||||||
)
|
)
|
||||||
|
|
||||||
func allModels() []any {
|
func allModels() []any {
|
||||||
@@ -1944,6 +1950,9 @@ func InitDB(dbPath string) error {
|
|||||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
|
||||||
|
log.Printf("clean SQLite backup directories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
sync := sqliteSynchronous()
|
sync := sqliteSynchronous()
|
||||||
journal := sqliteJournalMode()
|
journal := sqliteJournalMode()
|
||||||
@@ -2045,6 +2054,31 @@ func sqliteJournalMode() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func backupSQLiteStepPages() int {
|
||||||
|
if sqliteJournalMode() == "DELETE" {
|
||||||
|
return 128
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupSQLiteBackupDirs(dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
|
||||||
|
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func sqliteSynchronous() string {
|
func sqliteSynchronous() string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
|
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
|
||||||
case "OFF":
|
case "OFF":
|
||||||
@@ -2099,11 +2133,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
|||||||
return bytes.Equal(buf, signature), nil
|
return bytes.Equal(buf, signature), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Checkpoint() error {
|
func BackupSQLite(dstPath string) (err error) {
|
||||||
if IsPostgres() {
|
if IsPostgres() {
|
||||||
return nil
|
return errors.New("sqlite backup is unavailable for PostgreSQL")
|
||||||
}
|
}
|
||||||
return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
|
if db == nil {
|
||||||
|
return errors.New("database is not initialized")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(dstPath); err == nil {
|
||||||
|
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sourceDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sourceConn, err := sourceDB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sourceConn.Close()
|
||||||
|
|
||||||
|
destinationDB, err := sql.Open("sqlite3", dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destinationDB.Close()
|
||||||
|
destinationConn, err := destinationDB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destinationConn.Close()
|
||||||
|
|
||||||
|
return sourceConn.Raw(func(sourceDriver any) error {
|
||||||
|
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
|
||||||
|
}
|
||||||
|
return destinationConn.Raw(func(destinationDriver any) error {
|
||||||
|
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
|
||||||
|
}
|
||||||
|
backup, err := destination.Backup("main", source, "main")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
finished := false
|
||||||
|
defer func() {
|
||||||
|
if !finished {
|
||||||
|
_ = backup.Finish()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
done, err := backup.Step(backupSQLiteStepPages())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
finished = true
|
||||||
|
return backup.Finish()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateSQLiteDB(dbPath string) error {
|
func ValidateSQLiteDB(dbPath string) error {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func journalModeOf(t *testing.T) string {
|
func journalModeOf(t *testing.T) string {
|
||||||
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
|
|||||||
t.Fatalf("journal_mode = %q, want delete", got)
|
t.Fatalf("journal_mode = %q, want delete", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
|
|
||||||
t.Setenv("XUI_DB_JOURNAL_MODE", "")
|
|
||||||
dbDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dbDir, "x-ui.db")
|
|
||||||
if err := InitDB(dbPath); err != nil {
|
|
||||||
t.Fatalf("InitDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = CloseDB() })
|
|
||||||
|
|
||||||
if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
|
|
||||||
t.Fatalf("write setting: %v", err)
|
|
||||||
}
|
|
||||||
if err := Checkpoint(); err != nil {
|
|
||||||
t.Fatalf("Checkpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := os.ReadFile(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read db file: %v", err)
|
|
||||||
}
|
|
||||||
copyPath := filepath.Join(t.TempDir(), "copy.db")
|
|
||||||
if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
|
|
||||||
t.Fatalf("write copy: %v", err)
|
|
||||||
}
|
|
||||||
if err := ValidateSQLiteDB(copyPath); err != nil {
|
|
||||||
t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dump, err := DumpSQLiteToBytes(copyPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("dump copy: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(dump, []byte("walBackupProbe")) {
|
|
||||||
t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ func TestMain(m *testing.M) {
|
|||||||
fmt.Fprintf(f, "%d\n", os.Getpid())
|
fmt.Fprintf(f, "%d\n", os.Getpid())
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
if exitFile := os.Getenv("MTG_FAKE_EXIT_FILE"); exitFile != "" {
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(exitFile); err == nil {
|
||||||
|
os.Exit(1)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
select {}
|
select {}
|
||||||
}
|
}
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
|
|||||||
+50
-29
@@ -109,6 +109,7 @@ func (w *procLogWriter) LastLine() string {
|
|||||||
|
|
||||||
// Process wraps a single mtg process invocation for one mtproto inbound.
|
// Process wraps a single mtg process invocation for one mtproto inbound.
|
||||||
type Process struct {
|
type Process struct {
|
||||||
|
mu sync.RWMutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
configPath string
|
configPath string
|
||||||
@@ -126,20 +127,20 @@ func newProcess(configPath, label string) *Process {
|
|||||||
|
|
||||||
// IsRunning reports whether the mtg process is currently running.
|
// IsRunning reports whether the mtg process is currently running.
|
||||||
func (p *Process) IsRunning() bool {
|
func (p *Process) IsRunning() bool {
|
||||||
if p.cmd == nil || p.cmd.Process == nil {
|
p.mu.RLock()
|
||||||
|
cmd, done := p.cmd, p.done
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if p.done != nil {
|
if done != nil {
|
||||||
select {
|
select {
|
||||||
case <-p.done:
|
case <-done:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.cmd.ProcessState == nil {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResult returns the last log line or the exit error from the mtg process.
|
// GetResult returns the last log line or the exit error from the mtg process.
|
||||||
@@ -147,8 +148,11 @@ func (p *Process) GetResult() string {
|
|||||||
if line := p.logWriter.LastLine(); line != "" {
|
if line := p.logWriter.LastLine(); line != "" {
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
if p.exitErr != nil {
|
p.mu.RLock()
|
||||||
return p.exitErr.Error()
|
exitErr := p.exitErr
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if exitErr != nil {
|
||||||
|
return exitErr.Error()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -161,22 +165,27 @@ func (p *Process) Start() error {
|
|||||||
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
|
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
|
||||||
cmd.Stdout = p.logWriter
|
cmd.Stdout = p.logWriter
|
||||||
cmd.Stderr = p.logWriter
|
cmd.Stderr = p.logWriter
|
||||||
|
done := make(chan struct{})
|
||||||
|
p.mu.Lock()
|
||||||
p.cmd = cmd
|
p.cmd = cmd
|
||||||
p.done = make(chan struct{})
|
p.done = done
|
||||||
p.exitErr = nil
|
p.exitErr = nil
|
||||||
|
p.mu.Unlock()
|
||||||
p.intentionalStop.Store(false)
|
p.intentionalStop.Store(false)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
close(p.done)
|
close(done)
|
||||||
|
p.mu.Lock()
|
||||||
p.cmd = nil
|
p.cmd = nil
|
||||||
|
p.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
attachChildLifetime(cmd)
|
attachChildLifetime(cmd)
|
||||||
go p.wait(cmd)
|
go p.wait(cmd, done)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) wait(cmd *exec.Cmd) {
|
func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
|
||||||
defer close(p.done)
|
defer close(done)
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
p.logWriter.Flush()
|
p.logWriter.Flush()
|
||||||
if err == nil || p.intentionalStop.Load() {
|
if err == nil || p.intentionalStop.Load() {
|
||||||
@@ -184,12 +193,18 @@ func (p *Process) wait(cmd *exec.Cmd) {
|
|||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
|
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
|
||||||
p.exitErr = err
|
p.setExitErr(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Errorf("mtproto: mtg process exited: %v", err)
|
logger.Errorf("mtproto: mtg process exited: %v", err)
|
||||||
|
p.setExitErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) setExitErr(err error) {
|
||||||
|
p.mu.Lock()
|
||||||
p.exitErr = err
|
p.exitErr = err
|
||||||
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop terminates the running mtg process gracefully, falling back to a kill.
|
// Stop terminates the running mtg process gracefully, falling back to a kill.
|
||||||
@@ -198,40 +213,46 @@ func (p *Process) Stop() error {
|
|||||||
return errors.New("mtg is not running")
|
return errors.New("mtg is not running")
|
||||||
}
|
}
|
||||||
p.intentionalStop.Store(true)
|
p.intentionalStop.Store(true)
|
||||||
|
p.mu.RLock()
|
||||||
if runtime.GOOS == "windows" {
|
cmd, done := p.cmd, p.done
|
||||||
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
p.mu.RUnlock()
|
||||||
return err
|
if cmd == nil || cmd.Process == nil {
|
||||||
}
|
return errors.New("mtg is not running")
|
||||||
return p.waitForExit(forceStopTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
if runtime.GOOS == "windows" {
|
||||||
|
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return waitForExit(done, forceStopTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||||
if errors.Is(err, os.ErrProcessDone) {
|
if errors.Is(err, os.ErrProcessDone) {
|
||||||
return p.waitForExit(forceStopTimeout)
|
return waitForExit(done, forceStopTimeout)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.waitForExit(gracefulStopTimeout); err == nil {
|
if err := waitForExit(done, gracefulStopTimeout); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
|
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
|
||||||
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return p.waitForExit(forceStopTimeout)
|
return waitForExit(done, forceStopTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Process) waitForExit(timeout time.Duration) error {
|
func waitForExit(done <-chan struct{}, timeout time.Duration) error {
|
||||||
if p.done == nil {
|
if done == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(timeout)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-p.done:
|
case <-done:
|
||||||
return nil
|
return nil
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
|
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
|
||||||
|
proc := newProcess("", "test")
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
defer func() {
|
||||||
|
close(stop)
|
||||||
|
workers.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
workers.Go(func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
proc.mu.Lock()
|
||||||
|
proc.cmd = &exec.Cmd{}
|
||||||
|
proc.done = make(chan struct{})
|
||||||
|
proc.mu.Unlock()
|
||||||
|
proc.setExitErr(errors.New("exit"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for range 4 {
|
||||||
|
workers.Go(func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = proc.IsRunning()
|
||||||
|
_ = proc.GetResult()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessStatusDuringExit(t *testing.T) {
|
||||||
|
pidFile := installFakeMtg(t)
|
||||||
|
exitFile := filepath.Join(t.TempDir(), "exit")
|
||||||
|
t.Setenv("MTG_FAKE_EXIT_FILE", exitFile)
|
||||||
|
configPath := filepath.Join(t.TempDir(), "mtg.toml")
|
||||||
|
if err := os.WriteFile(configPath, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
proc := newProcess(configPath, "test")
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
t.Fatalf("start process: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = proc.Stop()
|
||||||
|
})
|
||||||
|
waitSpawnCount(t, pidFile, 1)
|
||||||
|
|
||||||
|
stopReads := make(chan struct{})
|
||||||
|
var readers sync.WaitGroup
|
||||||
|
defer func() {
|
||||||
|
close(stopReads)
|
||||||
|
readers.Wait()
|
||||||
|
}()
|
||||||
|
for range 4 {
|
||||||
|
readers.Go(func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopReads:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = proc.IsRunning()
|
||||||
|
_ = proc.GetResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.mu.RLock()
|
||||||
|
done := proc.done
|
||||||
|
proc.mu.RUnlock()
|
||||||
|
if err := os.WriteFile(exitFile, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("trigger exit: %v", err)
|
||||||
|
}
|
||||||
|
if err := waitForExit(done, time.Second); err != nil {
|
||||||
|
t.Fatalf("wait for process exit: %v", err)
|
||||||
|
}
|
||||||
|
if proc.IsRunning() {
|
||||||
|
t.Fatal("process must not be running after exit")
|
||||||
|
}
|
||||||
|
if got := proc.GetResult(); !strings.Contains(got, "exit status 1") {
|
||||||
|
t.Fatalf("GetResult after an unexpected exit = %q, want exit status", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,9 @@ import (
|
|||||||
// with a short timeout so a slow/dead provider can't stall a client's sub.
|
// with a short timeout so a slow/dead provider can't stall a client's sub.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
subscriptionCacheTTL = 5 * time.Minute
|
subscriptionCacheTTL = 5 * time.Minute
|
||||||
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
||||||
|
subscriptionCacheCapacity = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
||||||
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
|
|||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type subscriptionFetch struct {
|
||||||
|
done chan struct{}
|
||||||
|
links []string
|
||||||
|
}
|
||||||
|
|
||||||
var subscriptionCache = struct {
|
var subscriptionCache = struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
m map[string]subscriptionCacheEntry
|
m map[string]subscriptionCacheEntry
|
||||||
}{m: make(map[string]subscriptionCacheEntry)}
|
inflight map[string]*subscriptionFetch
|
||||||
|
}{
|
||||||
|
m: make(map[string]subscriptionCacheEntry),
|
||||||
|
inflight: make(map[string]*subscriptionFetch),
|
||||||
|
}
|
||||||
|
|
||||||
// fetchSubscriptionLinks returns the share links contained in a remote
|
// fetchSubscriptionLinks returns the share links contained in a remote
|
||||||
// subscription URL, using a short-lived cache. On any failure it returns the
|
// subscription URL, using a short-lived cache. On any failure it returns the
|
||||||
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
|
|||||||
|
|
||||||
subscriptionCache.Lock()
|
subscriptionCache.Lock()
|
||||||
cached, ok := subscriptionCache.m[rawURL]
|
cached, ok := subscriptionCache.m[rawURL]
|
||||||
subscriptionCache.Unlock()
|
|
||||||
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
|
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
|
||||||
|
subscriptionCache.Unlock()
|
||||||
return cached.links
|
return cached.links
|
||||||
}
|
}
|
||||||
|
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
<-fetch.done
|
||||||
|
return fetch.links
|
||||||
|
}
|
||||||
|
fetch := &subscriptionFetch{done: make(chan struct{})}
|
||||||
|
subscriptionCache.inflight[rawURL] = fetch
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
defer func() {
|
||||||
|
subscriptionCache.Lock()
|
||||||
|
close(fetch.done)
|
||||||
|
delete(subscriptionCache.inflight, rawURL)
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
links, err := doFetchSubscriptionLinks(rawURL)
|
links, err := doFetchSubscriptionLinks(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Serve stale on error rather than dropping the client's configs.
|
|
||||||
if ok {
|
if ok {
|
||||||
return cached.links
|
fetch.links = cached.links
|
||||||
}
|
}
|
||||||
return nil
|
return fetch.links
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriptionCache.Lock()
|
subscriptionCache.Lock()
|
||||||
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
|
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
|
||||||
|
trimSubscriptionCacheLocked(rawURL)
|
||||||
subscriptionCache.Unlock()
|
subscriptionCache.Unlock()
|
||||||
return links
|
fetch.links = links
|
||||||
|
return fetch.links
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSubscriptionCacheLocked(keep string) {
|
||||||
|
for len(subscriptionCache.m) > subscriptionCacheCapacity {
|
||||||
|
var oldestURL string
|
||||||
|
var oldest time.Time
|
||||||
|
for rawURL, entry := range subscriptionCache.m {
|
||||||
|
if rawURL == keep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
|
||||||
|
oldestURL = rawURL
|
||||||
|
oldest = entry.fetchedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(subscriptionCache.m, oldestURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
||||||
|
|||||||
@@ -4,10 +4,146 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resetSubscriptionCache(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
subscriptionCache.Lock()
|
||||||
|
previousEntries := subscriptionCache.m
|
||||||
|
previousInflight := subscriptionCache.inflight
|
||||||
|
subscriptionCache.m = make(map[string]subscriptionCacheEntry)
|
||||||
|
subscriptionCache.inflight = make(map[string]*subscriptionFetch)
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
subscriptionCache.Lock()
|
||||||
|
subscriptionCache.m = previousEntries
|
||||||
|
subscriptionCache.inflight = previousInflight
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
||||||
|
resetSubscriptionCache(t)
|
||||||
|
var requests atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
<-release
|
||||||
|
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
const callers = 16
|
||||||
|
results := make(chan []string, callers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range callers {
|
||||||
|
wg.Go(func() {
|
||||||
|
results <- fetchSubscriptionLinks(srv.URL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
for links := range results {
|
||||||
|
if len(links) != 1 || links[0] != "vless://uuid@example.com:443" {
|
||||||
|
t.Fatalf("links = %#v", links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := requests.Load(); got != 1 {
|
||||||
|
t.Fatalf("requests = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
||||||
|
resetSubscriptionCache(t)
|
||||||
|
var requests atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
for i := range subscriptionCacheCapacity + 1 {
|
||||||
|
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
|
||||||
|
if len(links) != 1 {
|
||||||
|
t.Fatalf("links at %d = %#v", i, links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionCache.Lock()
|
||||||
|
entries := len(subscriptionCache.m)
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
if entries != subscriptionCacheCapacity {
|
||||||
|
t.Fatalf("cache entries = %d, want %d", entries, subscriptionCacheCapacity)
|
||||||
|
}
|
||||||
|
if got := requests.Load(); got != subscriptionCacheCapacity+1 {
|
||||||
|
t.Fatalf("requests = %d, want %d", got, subscriptionCacheCapacity+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
|
||||||
|
resetSubscriptionCache(t)
|
||||||
|
stale := []string{"vless://stale@example.com:443"}
|
||||||
|
release := make(chan struct{})
|
||||||
|
var staleRequests atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/stale" {
|
||||||
|
staleRequests.Add(1)
|
||||||
|
<-release
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("vless://fresh@example.com:443"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
staleURL := srv.URL + "/stale"
|
||||||
|
|
||||||
|
subscriptionCache.Lock()
|
||||||
|
subscriptionCache.m[staleURL] = subscriptionCacheEntry{
|
||||||
|
links: stale,
|
||||||
|
fetchedAt: time.Now().Add(-subscriptionCacheTTL),
|
||||||
|
}
|
||||||
|
for i := range subscriptionCacheCapacity - 1 {
|
||||||
|
subscriptionCache.m["cached-"+strconv.Itoa(i)] = subscriptionCacheEntry{fetchedAt: time.Now()}
|
||||||
|
}
|
||||||
|
subscriptionCache.Unlock()
|
||||||
|
|
||||||
|
const callers = 16
|
||||||
|
results := make(chan []string, callers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range callers {
|
||||||
|
wg.Go(func() {
|
||||||
|
results <- fetchSubscriptionLinks(staleURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
|
||||||
|
t.Fatalf("fresh links = %#v", links)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
for links := range results {
|
||||||
|
if len(links) != 1 || links[0] != stale[0] {
|
||||||
|
t.Fatalf("links = %#v, want %#v", links, stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := staleRequests.Load(); got != 1 {
|
||||||
|
t.Fatalf("requests = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
|
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var warnSuppressedForwardedOnce sync.Once
|
||||||
|
|
||||||
|
var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"}
|
||||||
|
|
||||||
|
func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) {
|
||||||
|
if !hasForwardedHeaders(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
trusted = true
|
||||||
|
defer func() {
|
||||||
|
_ = recover()
|
||||||
|
}()
|
||||||
|
|
||||||
|
configured, err := s.settingService.GetTrustedProxyCIDRs()
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
configured = strings.TrimSpace(configured)
|
||||||
|
if configured == "" || configured == service.DefaultTrustedProxyCIDRs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return remoteAddrInCIDRs(c.Request.RemoteAddr, configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasForwardedHeaders(c *gin.Context) bool {
|
||||||
|
for _, name := range forwardedHeaderNames {
|
||||||
|
if c.GetHeader(name) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func warnSuppressedForwardedHeaders(c *gin.Context) {
|
||||||
|
present := make([]string, 0, 3)
|
||||||
|
for _, name := range forwardedHeaderNames {
|
||||||
|
if c.GetHeader(name) != "" {
|
||||||
|
present = append(present, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(present) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headers := strings.Join(present, ", ")
|
||||||
|
logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr)
|
||||||
|
warnSuppressedForwardedOnce.Do(func() {
|
||||||
|
logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteAddrInCIDRs(remoteAddr, cidrs string) bool {
|
||||||
|
host := remoteAddr
|
||||||
|
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(host))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for value := range strings.SplitSeq(cidrs, ",") {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
|
||||||
|
req.Host = "panel.example.com:2096"
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = req
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTrustedProxyCIDRs(t *testing.T, value string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil {
|
||||||
|
t.Fatalf("set trustedProxyCIDRs: %v", err)
|
||||||
|
}
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
stored, err := settingService.GetTrustedProxyCIDRs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err)
|
||||||
|
}
|
||||||
|
if stored != value {
|
||||||
|
t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stored *string
|
||||||
|
remoteAddr string
|
||||||
|
wantScheme string
|
||||||
|
wantHost string
|
||||||
|
wantHostWithPort string
|
||||||
|
wantHostHeader string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no stored row keeps trusting forwarded headers",
|
||||||
|
stored: nil,
|
||||||
|
remoteAddr: "203.0.113.9:51000",
|
||||||
|
wantScheme: "https",
|
||||||
|
wantHost: "sub.example.net",
|
||||||
|
wantHostWithPort: "sub.example.net",
|
||||||
|
wantHostHeader: "sub.example.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty stored value keeps trusting forwarded headers",
|
||||||
|
stored: new(""),
|
||||||
|
remoteAddr: "203.0.113.9:51000",
|
||||||
|
wantScheme: "https",
|
||||||
|
wantHost: "sub.example.net",
|
||||||
|
wantHostWithPort: "sub.example.net",
|
||||||
|
wantHostHeader: "sub.example.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stored shipped default keeps trusting forwarded headers",
|
||||||
|
stored: new(service.DefaultTrustedProxyCIDRs),
|
||||||
|
remoteAddr: "203.0.113.9:51000",
|
||||||
|
wantScheme: "https",
|
||||||
|
wantHost: "sub.example.net",
|
||||||
|
wantHostWithPort: "sub.example.net",
|
||||||
|
wantHostHeader: "sub.example.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared boundary ignores an origin outside it",
|
||||||
|
stored: new("10.0.0.0/8"),
|
||||||
|
remoteAddr: "203.0.113.9:51000",
|
||||||
|
wantScheme: "http",
|
||||||
|
wantHost: "panel.example.com",
|
||||||
|
wantHostWithPort: "panel.example.com:2096",
|
||||||
|
wantHostHeader: "panel.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared boundary trusts an origin inside it",
|
||||||
|
stored: new("10.0.0.0/8"),
|
||||||
|
remoteAddr: "10.1.2.3:44000",
|
||||||
|
wantScheme: "https",
|
||||||
|
wantHost: "sub.example.net",
|
||||||
|
wantHostWithPort: "sub.example.net",
|
||||||
|
wantHostHeader: "sub.example.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared boundary ignores an unparsable origin",
|
||||||
|
stored: new("10.0.0.0/8"),
|
||||||
|
remoteAddr: "not-an-ip",
|
||||||
|
wantScheme: "http",
|
||||||
|
wantHost: "panel.example.com",
|
||||||
|
wantHostWithPort: "panel.example.com:2096",
|
||||||
|
wantHostHeader: "panel.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
if tc.stored != nil {
|
||||||
|
setTrustedProxyCIDRs(t, *tc.stored)
|
||||||
|
}
|
||||||
|
s := &SubService{}
|
||||||
|
|
||||||
|
c := requestFrom(t, tc.remoteAddr, map[string]string{
|
||||||
|
"X-Forwarded-Host": "sub.example.net",
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
})
|
||||||
|
scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c)
|
||||||
|
|
||||||
|
if scheme != tc.wantScheme {
|
||||||
|
t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme)
|
||||||
|
}
|
||||||
|
if host != tc.wantHost {
|
||||||
|
t.Errorf("host = %q, want %q", host, tc.wantHost)
|
||||||
|
}
|
||||||
|
if hostWithPort != tc.wantHostWithPort {
|
||||||
|
t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort)
|
||||||
|
}
|
||||||
|
if hostHeader != tc.wantHostHeader {
|
||||||
|
t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
|
||||||
|
initSubDB(t)
|
||||||
|
setTrustedProxyCIDRs(t, "10.0.0.0/8")
|
||||||
|
s := &SubService{}
|
||||||
|
|
||||||
|
c := requestFrom(t, "203.0.113.9:51000", map[string]string{
|
||||||
|
"X-Real-IP": "198.51.100.7",
|
||||||
|
})
|
||||||
|
_, host, _, hostHeader := s.ResolveRequest(c)
|
||||||
|
|
||||||
|
if host != "panel.example.com" {
|
||||||
|
t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host)
|
||||||
|
}
|
||||||
|
if hostHeader != "panel.example.com" {
|
||||||
|
t.Errorf("hostHeader = %q, want the request host", hostHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasForwardedHeaders(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "no forwarded headers", want: false},
|
||||||
|
{name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true},
|
||||||
|
{name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true},
|
||||||
|
{name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want {
|
||||||
|
t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteAddrInCIDRs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
cidrs string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true},
|
||||||
|
{name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true},
|
||||||
|
{name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false},
|
||||||
|
{name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true},
|
||||||
|
{name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true},
|
||||||
|
{name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true},
|
||||||
|
{name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false},
|
||||||
|
{name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want {
|
||||||
|
t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-6
@@ -2486,18 +2486,29 @@ type PageData struct {
|
|||||||
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
// ResolveRequest extracts scheme and host info from request/headers consistently.
|
||||||
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
|
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
|
||||||
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
|
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
|
||||||
|
trusted := s.forwardedHeadersTrusted(c)
|
||||||
|
if !trusted {
|
||||||
|
warnSuppressedForwardedHeaders(c)
|
||||||
|
}
|
||||||
|
forwarded := func(name string) string {
|
||||||
|
if !trusted {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.GetHeader(name)
|
||||||
|
}
|
||||||
|
|
||||||
// scheme
|
// scheme
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
// base host (no port)
|
// base host (no port)
|
||||||
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
|
if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = c.GetHeader("X-Real-IP")
|
host = forwarded("X-Real-IP")
|
||||||
}
|
}
|
||||||
if host == "" {
|
if host == "" {
|
||||||
var err error
|
var err error
|
||||||
@@ -2508,7 +2519,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// host:port for URLs
|
// host:port for URLs
|
||||||
hostWithPort = c.GetHeader("X-Forwarded-Host")
|
hostWithPort = forwarded("X-Forwarded-Host")
|
||||||
if hostWithPort == "" {
|
if hostWithPort == "" {
|
||||||
hostWithPort = c.Request.Host
|
hostWithPort = c.Request.Host
|
||||||
}
|
}
|
||||||
@@ -2517,9 +2528,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// header display host
|
// header display host
|
||||||
hostHeader = c.GetHeader("X-Forwarded-Host")
|
hostHeader = forwarded("X-Forwarded-Host")
|
||||||
if hostHeader == "" {
|
if hostHeader == "" {
|
||||||
hostHeader = c.GetHeader("X-Real-IP")
|
hostHeader = forwarded("X-Real-IP")
|
||||||
}
|
}
|
||||||
if hostHeader == "" {
|
if hostHeader == "" {
|
||||||
hostHeader = host
|
hostHeader = host
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||||||
api.Use(middleware.ConfigEnvelopeMiddleware())
|
api.Use(middleware.ConfigEnvelopeMiddleware())
|
||||||
api.Use(middleware.CSRFMiddleware())
|
api.Use(middleware.CSRFMiddleware())
|
||||||
|
|
||||||
|
api.GET("/openapi.json", ServeOpenAPISpec)
|
||||||
|
|
||||||
// Inbounds API
|
// Inbounds API
|
||||||
inbounds := api.Group("/inbounds")
|
inbounds := api.Group("/inbounds")
|
||||||
a.inboundController = NewInboundController(inbounds)
|
a.inboundController = NewInboundController(inbounds)
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type routeDef struct {
|
|
||||||
Method string
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
|
||||||
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
|
||||||
|
|
||||||
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
|
|
||||||
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
|
||||||
|
|
||||||
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
|
|
||||||
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
|
||||||
// placeholders (paths starting with /{...}) are skipped because they aren't
|
|
||||||
// registered on the main Gin engine.
|
|
||||||
func buildDocSet(t *testing.T) map[string]bool {
|
|
||||||
t.Helper()
|
|
||||||
controllerDir, err := filepath.Abs(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get current dir: %v", err)
|
|
||||||
}
|
|
||||||
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
|
|
||||||
data, err := os.ReadFile(endpointsPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
|
|
||||||
}
|
|
||||||
docSet := make(map[string]bool)
|
|
||||||
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
|
||||||
method, path := m[1], m[2]
|
|
||||||
if method == "WS" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
docSet[method+" "+path] = true
|
|
||||||
}
|
|
||||||
if len(docSet) == 0 {
|
|
||||||
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
|
||||||
}
|
|
||||||
return docSet
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIRoutesDocumented(t *testing.T) {
|
|
||||||
docSet := buildDocSet(t)
|
|
||||||
|
|
||||||
controllerDir, err := filepath.Abs(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get current dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var allRoutes []routeDef
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(controllerDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read controller dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read %s: %v", entry.Name(), err)
|
|
||||||
}
|
|
||||||
src := string(data)
|
|
||||||
|
|
||||||
// Determine the base path for this file based on its initRouter patterns
|
|
||||||
basePath := ""
|
|
||||||
switch entry.Name() {
|
|
||||||
case "index.go":
|
|
||||||
basePath = ""
|
|
||||||
case "spa.go":
|
|
||||||
basePath = "/panel"
|
|
||||||
case "api.go":
|
|
||||||
basePath = "/panel/api"
|
|
||||||
case "inbound.go":
|
|
||||||
basePath = "/panel/api/inbounds"
|
|
||||||
case "client.go":
|
|
||||||
basePath = "/panel/api/clients"
|
|
||||||
case "group.go":
|
|
||||||
basePath = "/panel/api/clients"
|
|
||||||
case "server.go":
|
|
||||||
basePath = "/panel/api/server"
|
|
||||||
case "node.go":
|
|
||||||
basePath = "/panel/api/nodes"
|
|
||||||
case "host.go":
|
|
||||||
basePath = "/panel/api/hosts"
|
|
||||||
case "setting.go":
|
|
||||||
basePath = "/panel/api/setting"
|
|
||||||
case "xray_setting.go":
|
|
||||||
basePath = "/panel/api/xray"
|
|
||||||
case "websocket.go":
|
|
||||||
basePath = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all route registrations
|
|
||||||
matches := routePattern.FindAllStringSubmatch(src, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
method := m[2]
|
|
||||||
path := strings.TrimSpace(m[3])
|
|
||||||
if basePath == "" {
|
|
||||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
|
|
||||||
} else {
|
|
||||||
fullPath := basePath + path
|
|
||||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
|
||||||
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
|
||||||
|
|
||||||
missingFromDocs := 0
|
|
||||||
foundInDoc := 0
|
|
||||||
sourceSet := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, r := range allRoutes {
|
|
||||||
key := r.Method + " " + r.Path
|
|
||||||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
|
||||||
spaPages := map[string]bool{
|
|
||||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
|
||||||
"/panel/clients": true, "/panel/groups": true,
|
|
||||||
"/panel/nodes": true, "/panel/settings": true,
|
|
||||||
"/panel/xray": true, "/panel/outbound": true,
|
|
||||||
"/panel/routing": true, "/panel/api-docs": true,
|
|
||||||
}
|
|
||||||
if spaPages[r.Path] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
|
||||||
if r.Path == "/panel/csrf-token" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip Chrome DevTools route
|
|
||||||
if strings.Contains(r.Path, ".well-known") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSet[key] = true
|
|
||||||
if docSet[key] {
|
|
||||||
foundInDoc++
|
|
||||||
} else {
|
|
||||||
missingFromDocs++
|
|
||||||
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
|
||||||
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
|
||||||
|
|
||||||
if missingFromDocs > 0 {
|
|
||||||
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
|
|
||||||
g.POST("/all", a.getAllSetting)
|
g.POST("/all", a.getAllSetting)
|
||||||
g.POST("/defaultSettings", a.getDefaultSettings)
|
g.POST("/defaultSettings", a.getDefaultSettings)
|
||||||
|
g.POST("/factoryDefaults", a.getFactoryDefaults)
|
||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/validateRegex", a.validateRegex)
|
g.POST("/validateRegex", a.validateRegex)
|
||||||
g.POST("/updateUser", a.updateUser)
|
g.POST("/updateUser", a.updateUser)
|
||||||
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
|||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *SettingController) getFactoryDefaults(c *gin.Context) {
|
||||||
|
jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
// updateSetting updates all settings with the provided data.
|
// updateSetting updates all settings with the provided data.
|
||||||
func (a *SettingController) updateSetting(c *gin.Context) {
|
func (a *SettingController) updateSetting(c *gin.Context) {
|
||||||
form, ok := middleware.BindAndValidate[updateSettingForm](c)
|
form, ok := middleware.BindAndValidate[updateSettingForm](c)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func trustedProxyCIDRs() (trusted string) {
|
func trustedProxyCIDRs() (trusted string) {
|
||||||
trusted = "127.0.0.1/32,::1/128"
|
trusted = service.DefaultTrustedProxyCIDRs
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = recover()
|
_ = recover()
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ type XrayTrafficJob struct {
|
|||||||
// refetch for the rest.
|
// refetch for the rest.
|
||||||
const clientStatsSnapshotMaxClients = 5000
|
const clientStatsSnapshotMaxClients = 5000
|
||||||
|
|
||||||
|
// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
|
||||||
|
// alongside the active-email list and set derived from the same pass.
|
||||||
|
//
|
||||||
|
// Xray reports a row for every known email whether or not it transferred
|
||||||
|
// anything, so on a large panel nearly every delta is zero. The database writes
|
||||||
|
// and the external-API inform consume the full slice before this point; the
|
||||||
|
// WebSocket frame only feeds the dashboard's live speed column, where an absent
|
||||||
|
// row and a zero row render identically. Broadcasting just the movers keeps that
|
||||||
|
// frame from growing with the client count — at 5k clients it was carrying about
|
||||||
|
// a megabyte of zeros every five seconds.
|
||||||
|
func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
|
||||||
|
moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
|
||||||
|
emails := make([]string, 0, len(clientTraffics))
|
||||||
|
active := make(map[string]bool, len(clientTraffics))
|
||||||
|
for _, ct := range clientTraffics {
|
||||||
|
if ct == nil || ct.Up+ct.Down <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moved = append(moved, ct)
|
||||||
|
emails = append(emails, ct.Email)
|
||||||
|
active[ct.Email] = true
|
||||||
|
}
|
||||||
|
return moved, emails, active
|
||||||
|
}
|
||||||
|
|
||||||
const externalInformTimeout = 3 * time.Second
|
const externalInformTimeout = 3 * time.Second
|
||||||
|
|
||||||
var externalInformClient = &fasthttp.Client{
|
var externalInformClient = &fasthttp.Client{
|
||||||
@@ -88,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
// than the shared last_online column, which remote-node syncs also bump
|
// than the shared last_online column, which remote-node syncs also bump
|
||||||
// and would otherwise make a client active only on a remote node appear
|
// and would otherwise make a client active only on a remote node appear
|
||||||
// online on local inbounds.
|
// online on local inbounds.
|
||||||
activeEmails := make([]string, 0, len(clientTraffics))
|
movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
|
||||||
deltaActive := make(map[string]bool, len(clientTraffics))
|
|
||||||
for _, ct := range clientTraffics {
|
|
||||||
if ct != nil && ct.Up+ct.Down > 0 {
|
|
||||||
activeEmails = append(activeEmails, ct.Email)
|
|
||||||
deltaActive[ct.Email] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// When the core supports the online-stats API, union in connection-based
|
// When the core supports the online-stats API, union in connection-based
|
||||||
// onlines. Neither signal alone covers everything: an idle-but-connected
|
// onlines. Neither signal alone covers everything: an idle-but-connected
|
||||||
// client moves no bytes between polls (the delta heuristic's blind spot),
|
// client moves no bytes between polls (the delta heuristic's blind spot),
|
||||||
@@ -179,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
}
|
}
|
||||||
websocket.BroadcastTraffic(map[string]any{
|
websocket.BroadcastTraffic(map[string]any{
|
||||||
"traffics": traffics,
|
"traffics": traffics,
|
||||||
"clientTraffics": clientTraffics,
|
"clientTraffics": movedTraffics,
|
||||||
"onlineClients": onlineClients,
|
"onlineClients": onlineClients,
|
||||||
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
||||||
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitMovedClientTraffics(t *testing.T) {
|
||||||
|
rows := []*xray.ClientTraffic{
|
||||||
|
{Email: "idle@x", Up: 0, Down: 0},
|
||||||
|
{Email: "up@x", Up: 1024, Down: 0},
|
||||||
|
{Email: "down@x", Up: 0, Down: 2048},
|
||||||
|
nil,
|
||||||
|
{Email: "both@x", Up: 512, Down: 512},
|
||||||
|
{Email: "alsoidle@x", Up: 0, Down: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
moved, emails, active := splitMovedClientTraffics(rows)
|
||||||
|
|
||||||
|
t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
|
||||||
|
got := make([]string, 0, len(moved))
|
||||||
|
for _, ct := range moved {
|
||||||
|
got = append(got, ct.Email)
|
||||||
|
}
|
||||||
|
want := []string{"up@x", "down@x", "both@x"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("moved = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
|
||||||
|
want := []string{"up@x", "down@x", "both@x"}
|
||||||
|
if !slices.Equal(emails, want) {
|
||||||
|
t.Fatalf("activeEmails = %v, want %v", emails, want)
|
||||||
|
}
|
||||||
|
if len(active) != len(want) {
|
||||||
|
t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
|
||||||
|
}
|
||||||
|
for _, e := range want {
|
||||||
|
if !active[e] {
|
||||||
|
t.Fatalf("deltaActive missing %q", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active["idle@x"] {
|
||||||
|
t.Fatal("an idle client must not count as active")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
|
||||||
|
moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
|
||||||
|
{Email: "a@x"}, {Email: "b@x"},
|
||||||
|
})
|
||||||
|
if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
|
||||||
|
t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
|
||||||
|
API route omitted there silently vanishes from the generated OpenAPI docs,
|
||||||
|
and an entry for a removed route documents an endpoint that 404s. This test
|
||||||
|
constructs the real router and diffs it against the registry both ways.
|
||||||
|
|
||||||
|
Scope: everything under /panel/api/ plus the session-auth surface the
|
||||||
|
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
|
||||||
|
/ws). SPA page routes are UI, not API, and stay out; registry paths that
|
||||||
|
start with "/{" describe the standalone subscription server, which this
|
||||||
|
engine does not serve.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var contractExtraRoutes = map[string]bool{
|
||||||
|
"POST /login": true,
|
||||||
|
"POST /logout": true,
|
||||||
|
"GET /csrf-token": true,
|
||||||
|
"POST /getTwoFactorEnable": true,
|
||||||
|
"GET /ws": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func inContractScope(method, path string) bool {
|
||||||
|
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
|
||||||
|
}
|
||||||
|
|
||||||
|
func registeredContractRoutes(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("init db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
|
||||||
|
previous := global.GetWebServer()
|
||||||
|
s := NewServer()
|
||||||
|
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
|
||||||
|
global.SetWebServer(s)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.cancel()
|
||||||
|
global.SetWebServer(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine, err := s.initRouter()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init router: %v", err)
|
||||||
|
}
|
||||||
|
routes := make(map[string]bool)
|
||||||
|
for _, r := range engine.Routes() {
|
||||||
|
routes[r.Method+" "+r.Path] = true
|
||||||
|
}
|
||||||
|
if len(routes) == 0 {
|
||||||
|
t.Fatal("no routes registered; router construction is broken")
|
||||||
|
}
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentedContractRoutes(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read endpoints.ts: %v", err)
|
||||||
|
}
|
||||||
|
text := string(source)
|
||||||
|
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
|
||||||
|
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
|
||||||
|
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
|
||||||
|
if declared := strings.Count(text, "method: '"); len(methods) != declared {
|
||||||
|
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
|
||||||
|
}
|
||||||
|
docs := make(map[string]bool)
|
||||||
|
for i, m := range methods {
|
||||||
|
segmentEnd := len(text)
|
||||||
|
if i+1 < len(methods) {
|
||||||
|
segmentEnd = methods[i+1][0]
|
||||||
|
}
|
||||||
|
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
|
||||||
|
if pathMatch == nil {
|
||||||
|
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
|
||||||
|
}
|
||||||
|
method := text[m[2]:m[3]]
|
||||||
|
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docs[method+" "+pathMatch[1]] = true
|
||||||
|
}
|
||||||
|
if len(docs) == 0 {
|
||||||
|
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
|
||||||
|
}
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteRegistryContract(t *testing.T) {
|
||||||
|
registered := registeredContractRoutes(t)
|
||||||
|
documented := documentedContractRoutes(t)
|
||||||
|
|
||||||
|
t.Run("every API route is documented", func(t *testing.T) {
|
||||||
|
var missing []string
|
||||||
|
for route := range registered {
|
||||||
|
fields := strings.Fields(route)
|
||||||
|
if inContractScope(fields[0], fields[1]) && !documented[route] {
|
||||||
|
missing = append(missing, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(missing)
|
||||||
|
for _, route := range missing {
|
||||||
|
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every documented route is registered", func(t *testing.T) {
|
||||||
|
var stale []string
|
||||||
|
for route := range documented {
|
||||||
|
if !registered[route] {
|
||||||
|
stale = append(stale, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(stale)
|
||||||
|
for _, route := range stale {
|
||||||
|
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
||||||
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
|
|||||||
|
|
||||||
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
||||||
// the clients page can render the dashboard stat cards and their hover
|
// the clients page can render the dashboard stat cards and their hover
|
||||||
// popovers without shipping the full client array.
|
// popovers without shipping the full client array. The counters are exact;
|
||||||
|
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
|
||||||
type ClientsSummary struct {
|
type ClientsSummary struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Active int `json:"active"`
|
Active int `json:"active"`
|
||||||
Online []string `json:"online"`
|
OnlineCount int `json:"onlineCount"`
|
||||||
Depleted []string `json:"depleted"`
|
DepletedCount int `json:"depletedCount"`
|
||||||
Expiring []string `json:"expiring"`
|
ExpiringCount int `json:"expiringCount"`
|
||||||
Deactive []string `json:"deactive"`
|
DeactiveCount int `json:"deactiveCount"`
|
||||||
|
Online []string `json:"online"`
|
||||||
|
Depleted []string `json:"depleted"`
|
||||||
|
Expiring []string `json:"expiring"`
|
||||||
|
Deactive []string `json:"deactive"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
clientPageDefaultSize = 25
|
clientPageDefaultSize = 25
|
||||||
clientPageMaxSize = 200
|
clientPageMaxSize = 200
|
||||||
|
// clientSummaryEmailCap bounds each bucket's email list. Shipping every
|
||||||
|
// matching email made the response — and the Zod validation the page runs
|
||||||
|
// over it — grow with the client count on a request that repeats every 5s,
|
||||||
|
// and left the hover popover rendering thousands of rows.
|
||||||
|
clientSummaryEmailCap = 200
|
||||||
|
// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
|
||||||
|
// matching the sentinel the in-memory comparator used.
|
||||||
|
sqlNeverSentinel = "4611686018427387903"
|
||||||
|
// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
|
||||||
|
// false: without the COALESCE such a row would match neither the enabled
|
||||||
|
// nor the disabled branch of any predicate.
|
||||||
|
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListPaged loads every client (with traffic + attachments) into memory,
|
const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
|
||||||
// applies the requested filter / search / protocol predicates, sorts, and
|
OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
|
||||||
// returns the requested page along with total and filtered counts. The DB
|
OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
|
||||||
// query itself is unchanged from List(); the win is that the response
|
OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
|
||||||
// only carries 25-ish slim rows over the wire instead of all 2000 full
|
OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
|
||||||
// records, which on real panels was the dominant cost.
|
OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
|
||||||
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
|
||||||
all, err := s.List()
|
|
||||||
if err != nil {
|
// clientQuery builds the statements behind the clients page: a clients row
|
||||||
return nil, err
|
// joined to its traffic counters, plus the expressions every bucket predicate
|
||||||
|
// shares. Filtering, sorting, paging and the summary all run in the database.
|
||||||
|
// Loading every client (with attachments and traffic) into Go and doing it in
|
||||||
|
// memory cost ~200ms per request at 20k clients on a page that polls every
|
||||||
|
// 5 seconds, which is what made the table feel stuck on large panels.
|
||||||
|
type clientQuery struct {
|
||||||
|
db *gorm.DB
|
||||||
|
joins []clientQueryJoin
|
||||||
|
usedExpr string
|
||||||
|
nowMs int64
|
||||||
|
expireDiffMs int64
|
||||||
|
trafficDiffBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientQueryJoin struct {
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
|
||||||
|
q := clientQuery{
|
||||||
|
db: db,
|
||||||
|
nowMs: nowMs,
|
||||||
|
expireDiffMs: expireDiffMs,
|
||||||
|
trafficDiffBytes: trafficDiffBytes,
|
||||||
|
joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
|
||||||
|
usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
|
||||||
}
|
}
|
||||||
total := len(all)
|
freshSince := globalTrafficFreshSince()
|
||||||
|
var probe int64
|
||||||
|
err := db.Model(&model.ClientGlobalTraffic{}).
|
||||||
|
Where("updated_at >= ?", freshSince).
|
||||||
|
Limit(1).Count(&probe).Error
|
||||||
|
if err != nil || probe == 0 {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
// A master still pushes cross-panel usage here, so the predicates have to
|
||||||
|
// see the same raised counters overlayGlobalTraffic applies on read.
|
||||||
|
q.joins = append(q.joins, clientQueryJoin{
|
||||||
|
sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
|
||||||
|
" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
|
||||||
|
args: []any{freshSince},
|
||||||
|
})
|
||||||
|
q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
|
||||||
|
" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) from() *gorm.DB {
|
||||||
|
tx := q.db.Table("clients AS c")
|
||||||
|
for _, j := range q.joins {
|
||||||
|
tx = tx.Joins(j.sql, j.args...)
|
||||||
|
}
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) depletedExpr() string {
|
||||||
|
return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
|
||||||
|
" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) nearDepletionExpr() string {
|
||||||
|
return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
|
||||||
|
" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) expiringExpr() string {
|
||||||
|
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) activeExpr() string {
|
||||||
|
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
||||||
|
// client that also ran out counts once, under depleted, so the stat cards add
|
||||||
|
// up to the client total.
|
||||||
|
func (q clientQuery) summaryDeactiveExpr() string {
|
||||||
|
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyParams narrows tx by every predicate the clients page sends. Matching is
|
||||||
|
// OR within a field and AND across fields, mirroring the query-param contract.
|
||||||
|
// The second return says whether anything narrowed the set, so an unfiltered
|
||||||
|
// request can reuse the total count instead of scanning for it again.
|
||||||
|
func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
|
||||||
|
narrowed := false
|
||||||
|
where := func(cond string, args ...any) {
|
||||||
|
narrowed = true
|
||||||
|
tx = tx.Where(cond, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
|
||||||
|
pattern := "%" + escapeLikeLiteral(needle) + "%"
|
||||||
|
where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
|
||||||
|
}
|
||||||
|
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
||||||
|
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
||||||
|
" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
|
||||||
|
}
|
||||||
|
if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
|
||||||
|
where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
|
||||||
|
}
|
||||||
|
if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
|
||||||
|
cond, args := q.bucketCond(buckets, onlines)
|
||||||
|
where(cond, args...)
|
||||||
|
}
|
||||||
|
if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
|
||||||
|
// 0 means "never expires" and a negative value is the delayed-start
|
||||||
|
// sentinel; both sit outside any bounded range.
|
||||||
|
where("c.expiry_time > 0")
|
||||||
|
if params.ExpiryFrom > 0 {
|
||||||
|
where("c.expiry_time >= ?", params.ExpiryFrom)
|
||||||
|
}
|
||||||
|
if params.ExpiryTo > 0 {
|
||||||
|
where("c.expiry_time <= ?", params.ExpiryTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if params.UsageFrom > 0 {
|
||||||
|
where(q.usedExpr+" >= ?", params.UsageFrom)
|
||||||
|
}
|
||||||
|
if params.UsageTo > 0 {
|
||||||
|
where(q.usedExpr+" <= ?", params.UsageTo)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
|
||||||
|
case "on":
|
||||||
|
where("COALESCE(c.reset, 0) > 0")
|
||||||
|
case "off":
|
||||||
|
where("COALESCE(c.reset, 0) <= 0")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
|
||||||
|
case "yes":
|
||||||
|
where("COALESCE(c.tg_id, 0) <> 0")
|
||||||
|
case "no":
|
||||||
|
where("COALESCE(c.tg_id, 0) = 0")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
|
||||||
|
case "yes":
|
||||||
|
where("TRIM(COALESCE(c.comment, '')) <> ''")
|
||||||
|
case "no":
|
||||||
|
where("TRIM(COALESCE(c.comment, '')) = ''")
|
||||||
|
}
|
||||||
|
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
||||||
|
where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
|
||||||
|
}
|
||||||
|
return tx, narrowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
||||||
|
conds := make([]string, 0, len(buckets))
|
||||||
|
args := make([]any, 0, len(buckets))
|
||||||
|
for _, b := range buckets {
|
||||||
|
switch b {
|
||||||
|
case "active":
|
||||||
|
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
||||||
|
case "deactive":
|
||||||
|
conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
||||||
|
case "depleted":
|
||||||
|
conds = append(conds, q.depletedExpr())
|
||||||
|
case "expiring":
|
||||||
|
conds = append(conds, q.expiringExpr())
|
||||||
|
case "online":
|
||||||
|
cond, inArgs := emailInCond("c.email", onlines)
|
||||||
|
conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
|
||||||
|
args = append(args, inArgs...)
|
||||||
|
default:
|
||||||
|
// An unrecognised bucket name matched every client before the
|
||||||
|
// predicates moved into SQL; keep that so a stale saved filter
|
||||||
|
// cannot silently empty the table.
|
||||||
|
conds = append(conds, "(1 = 1)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "(" + strings.Join(conds, " OR ") + ")", args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
|
||||||
|
dir := " ASC"
|
||||||
|
if order == "descend" {
|
||||||
|
dir = " DESC"
|
||||||
|
}
|
||||||
|
// createdAt / updatedAt / lastOnline broke ties on the client id inside the
|
||||||
|
// comparator, so reversing the sort reversed the tiebreak with it. The
|
||||||
|
// other keys leaned on a stable sort over an id-ordered slice instead.
|
||||||
|
tieDir := " ASC"
|
||||||
|
var expr string
|
||||||
|
switch sortKey {
|
||||||
|
case "enable":
|
||||||
|
expr = sqlClientEnabled
|
||||||
|
case "email":
|
||||||
|
expr = "LOWER(c.email)"
|
||||||
|
case "inboundIds":
|
||||||
|
expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
|
||||||
|
case "traffic":
|
||||||
|
expr = q.usedExpr
|
||||||
|
case "remaining":
|
||||||
|
expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
|
||||||
|
case "expiryTime":
|
||||||
|
expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
|
||||||
|
case "createdAt":
|
||||||
|
expr, tieDir = "c.created_at", dir
|
||||||
|
case "updatedAt":
|
||||||
|
expr, tieDir = "c.updated_at", dir
|
||||||
|
case "lastOnline":
|
||||||
|
expr, tieDir = "COALESCE(ct.last_online, 0)", dir
|
||||||
|
default:
|
||||||
|
return tx.Order("c.id ASC")
|
||||||
|
}
|
||||||
|
return tx.Order(expr + dir + ", c.id" + tieDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaged returns one page of clients together with the counts the clients
|
||||||
|
// page header needs. Every predicate runs in SQL, so the cost tracks the page
|
||||||
|
// size rather than the number of clients on the panel.
|
||||||
|
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
pageSize := params.PageSize
|
pageSize := params.PageSize
|
||||||
if pageSize <= 0 {
|
if pageSize <= 0 {
|
||||||
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
protocols := parseCSVStrings(params.Protocol)
|
|
||||||
inboundIDs := parseCSVInts(params.Inbound)
|
|
||||||
buckets := parseCSVStrings(params.Filter)
|
|
||||||
|
|
||||||
var protocolByInbound map[int]string
|
|
||||||
if len(protocols) > 0 {
|
|
||||||
inbounds, err := inboundSvc.GetAllInbounds()
|
|
||||||
if err == nil {
|
|
||||||
protocolByInbound = make(map[int]string, len(inbounds))
|
|
||||||
for _, ib := range inbounds {
|
|
||||||
protocolByInbound[ib.Id] = string(ib.Protocol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onlines := inboundSvc.GetOnlineClients()
|
|
||||||
onlineSet := make(map[string]struct{}, len(onlines))
|
|
||||||
for _, e := range onlines {
|
|
||||||
onlineSet[e] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var expireDiffMs, trafficDiffBytes int64
|
var expireDiffMs, trafficDiffBytes int64
|
||||||
if settingSvc != nil {
|
if settingSvc != nil {
|
||||||
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
||||||
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nowMs := time.Now().UnixMilli()
|
onlines := inboundSvc.GetOnlineClients()
|
||||||
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
|
q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
|
||||||
|
|
||||||
needle := strings.ToLower(strings.TrimSpace(params.Search))
|
var total int64
|
||||||
|
if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
|
||||||
filtered := make([]ClientWithAttachments, 0, len(all))
|
return nil, err
|
||||||
for _, c := range all {
|
|
||||||
if needle != "" && !clientMatchesSearch(c, needle) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesHasTgID(c, params.HasTgID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesHasComment(c, params.HasComment) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !clientMatchesAnyGroup(c, params.Group) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, c)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sortClients(filtered, params.Sort, params.Order)
|
summary, err := q.summary(onlines, int(total))
|
||||||
|
if err != nil {
|
||||||
filteredCount := len(filtered)
|
return nil, err
|
||||||
start := (page - 1) * pageSize
|
|
||||||
end := start + pageSize
|
|
||||||
if start > filteredCount {
|
|
||||||
start = filteredCount
|
|
||||||
}
|
|
||||||
if end > filteredCount {
|
|
||||||
end = filteredCount
|
|
||||||
}
|
|
||||||
pageRows := filtered[start:end]
|
|
||||||
|
|
||||||
items := make([]ClientSlim, 0, len(pageRows))
|
|
||||||
for _, c := range pageRows {
|
|
||||||
items = append(items, toClientSlim(c))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
groupRows, gErr := s.ListGroups()
|
filtered := total
|
||||||
if gErr != nil {
|
if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
|
||||||
return nil, gErr
|
if err := scoped.Count(&filtered).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
groups := make([]string, 0, len(groupRows))
|
|
||||||
for _, g := range groupRows {
|
items := []ClientSlim{}
|
||||||
groups = append(groups, g.Name)
|
offset := (page - 1) * pageSize
|
||||||
|
if int64(offset) < filtered {
|
||||||
|
items, err = q.pageRows(params, onlines, offset, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err := s.listGroupNames()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ClientPageResponse{
|
return &ClientPageResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: int(total),
|
||||||
Filtered: filteredCount,
|
Filtered: int(filtered),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
|
// pageRows resolves the requested page to client ids, then loads the records,
|
||||||
|
// attachments and traffic for those ids only. A page never exceeds
|
||||||
|
// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
|
||||||
|
// lists need no chunking.
|
||||||
|
func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
|
||||||
|
tx, _ := q.applyParams(q.from(), params, onlines)
|
||||||
|
var ids []int
|
||||||
|
if err := q.applyOrder(tx, params.Sort, params.Order).
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Pluck("c.id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []ClientSlim{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []model.ClientRecord
|
||||||
|
if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byId := make(map[int]*model.ClientRecord, len(records))
|
||||||
|
emails := make([]string, 0, len(records))
|
||||||
|
for i := range records {
|
||||||
|
byId[records[i].Id] = &records[i]
|
||||||
|
if records[i].Email != "" {
|
||||||
|
emails = append(emails, records[i].Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var links []model.ClientInbound
|
||||||
|
if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attachments := make(map[int][]int, len(ids))
|
||||||
|
for _, l := range links {
|
||||||
|
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||||
|
}
|
||||||
|
|
||||||
|
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||||
|
if len(emails) > 0 {
|
||||||
|
var stats []xray.ClientTraffic
|
||||||
|
if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overlayGlobalTrafficValues(q.db, stats)
|
||||||
|
for i := range stats {
|
||||||
|
trafficByEmail[stats[i].Email] = &stats[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ClientSlim, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
rec := byId[id]
|
||||||
|
if rec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, ClientSlim{
|
||||||
|
Email: rec.Email,
|
||||||
|
SubID: rec.SubID,
|
||||||
|
Enable: rec.Enable,
|
||||||
|
TotalGB: rec.TotalGB,
|
||||||
|
ExpiryTime: rec.ExpiryTime,
|
||||||
|
LimitIP: rec.LimitIP,
|
||||||
|
Reset: rec.Reset,
|
||||||
|
Group: rec.Group,
|
||||||
|
Comment: rec.Comment,
|
||||||
|
InboundIds: attachments[rec.Id],
|
||||||
|
Traffic: trafficByEmail[rec.Email],
|
||||||
|
CreatedAt: rec.CreatedAt,
|
||||||
|
UpdatedAt: rec.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
|
||||||
s := ClientsSummary{
|
s := ClientsSummary{
|
||||||
Total: len(all),
|
Total: total,
|
||||||
Online: []string{},
|
Online: []string{},
|
||||||
Depleted: []string{},
|
Depleted: []string{},
|
||||||
Expiring: []string{},
|
Expiring: []string{},
|
||||||
Deactive: []string{},
|
Deactive: []string{},
|
||||||
}
|
}
|
||||||
for _, c := range all {
|
|
||||||
used := int64(0)
|
var counts struct {
|
||||||
if c.Traffic != nil {
|
Active int64
|
||||||
used = c.Traffic.Up + c.Traffic.Down
|
Depleted int64
|
||||||
|
Expiring int64
|
||||||
|
Deactive int64
|
||||||
|
}
|
||||||
|
// SUM over an empty table yields NULL, which not every driver scans into an
|
||||||
|
// int; COALESCE keeps a panel with no clients from erroring out.
|
||||||
|
if err := q.from().Select(
|
||||||
|
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
||||||
|
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
||||||
|
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
||||||
|
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||||
|
).Scan(&counts).Error; err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.Active = int(counts.Active)
|
||||||
|
s.DepletedCount = int(counts.Depleted)
|
||||||
|
s.ExpiringCount = int(counts.Expiring)
|
||||||
|
s.DeactiveCount = int(counts.Deactive)
|
||||||
|
|
||||||
|
buckets := []struct {
|
||||||
|
cond string
|
||||||
|
count int
|
||||||
|
out *[]string
|
||||||
|
}{
|
||||||
|
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
||||||
|
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
||||||
|
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||||
|
}
|
||||||
|
for _, b := range buckets {
|
||||||
|
// The counter already says the bucket is empty, so skip the scan that
|
||||||
|
// would look for emails it cannot find.
|
||||||
|
if b.count == 0 {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
var emails []string
|
||||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
if err := q.from().Where(b.cond).
|
||||||
if c.Enable {
|
Order("c.id ASC").Limit(clientSummaryEmailCap).
|
||||||
if _, ok := onlineSet[c.Email]; ok {
|
Pluck("c.email", &emails).Error; err != nil {
|
||||||
s.Online = append(s.Online, c.Email)
|
return s, err
|
||||||
|
}
|
||||||
|
if len(emails) > 0 {
|
||||||
|
*b.out = emails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
online, onlineCount, err := q.onlineEmails(onlines)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.Online = online
|
||||||
|
s.OnlineCount = onlineCount
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlineEmails intersects the emails xray reports as connected with the enabled
|
||||||
|
// clients this panel stores. The online set lives in memory and is bounded by
|
||||||
|
// live connections, so it drives the query rather than a scan of every client.
|
||||||
|
func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
|
||||||
|
matched := []string{}
|
||||||
|
count := 0
|
||||||
|
for _, batch := range chunkStrings(onlines, sqlInChunk) {
|
||||||
|
var page []string
|
||||||
|
if err := q.db.Model(&model.ClientRecord{}).
|
||||||
|
Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
|
||||||
|
Order("id ASC").
|
||||||
|
Pluck("email", &page).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
count += len(page)
|
||||||
|
if room := clientSummaryEmailCap - len(matched); room > 0 {
|
||||||
|
matched = append(matched, page[:min(room, len(page))]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGroupNames returns the group names the clients page offers as filters:
|
||||||
|
// the stored groups plus any name a client still carries. ListGroups also sums
|
||||||
|
// per-client traffic per group, which this page never reads and which costs a
|
||||||
|
// full join over client_traffics on every poll.
|
||||||
|
func (s *ClientService) listGroupNames() ([]string, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var stored []string
|
||||||
|
if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var used []string
|
||||||
|
if err := db.Model(&model.ClientRecord{}).
|
||||||
|
Where("group_name <> ''").
|
||||||
|
Distinct().
|
||||||
|
Pluck("group_name", &used).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(stored)+len(used))
|
||||||
|
out := make([]string, 0, len(stored)+len(used))
|
||||||
|
for _, list := range [][]string{stored, used} {
|
||||||
|
for _, name := range list {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
if _, dup := seen[name]; dup {
|
||||||
if exhausted || expired {
|
continue
|
||||||
s.Depleted = append(s.Depleted, c.Email)
|
}
|
||||||
continue
|
seen[name] = struct{}{}
|
||||||
}
|
out = append(out, name)
|
||||||
if !c.Enable {
|
|
||||||
s.Deactive = append(s.Deactive, c.Email)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
||||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
||||||
if nearExpiry || nearLimit {
|
|
||||||
s.Expiring = append(s.Expiring, c.Email)
|
|
||||||
} else {
|
|
||||||
s.Active++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
func sqlInt(v int64) string {
|
||||||
return ClientSlim{
|
return strconv.FormatInt(v, 10)
|
||||||
Email: c.Email,
|
|
||||||
SubID: c.SubID,
|
|
||||||
Enable: c.Enable,
|
|
||||||
TotalGB: c.TotalGB,
|
|
||||||
ExpiryTime: c.ExpiryTime,
|
|
||||||
LimitIP: c.LimitIP,
|
|
||||||
Reset: c.Reset,
|
|
||||||
Group: c.Group,
|
|
||||||
Comment: c.Comment,
|
|
||||||
InboundIds: c.InboundIds,
|
|
||||||
Traffic: c.Traffic,
|
|
||||||
CreatedAt: c.CreatedAt,
|
|
||||||
UpdatedAt: c.UpdatedAt,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
||||||
if needle == "" {
|
// matching literally, the way strings.Contains did.
|
||||||
return true
|
func escapeLikeLiteral(s string) string {
|
||||||
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailInCond renders an IN over a possibly large email set, split so no single
|
||||||
|
// IN list outgrows the drivers' bind-parameter ceiling.
|
||||||
|
func emailInCond(column string, emails []string) (string, []any) {
|
||||||
|
if len(emails) == 0 {
|
||||||
|
return "1 = 0", nil
|
||||||
}
|
}
|
||||||
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
chunks := chunkStrings(emails, sqlInChunk)
|
||||||
for _, v := range candidates {
|
parts := make([]string, 0, len(chunks))
|
||||||
if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
args := make([]any, 0, len(chunks))
|
||||||
return true
|
for _, chunk := range chunks {
|
||||||
}
|
parts = append(parts, column+" IN ?")
|
||||||
|
args = append(args, chunk)
|
||||||
}
|
}
|
||||||
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
|
return "(" + strings.Join(parts, " OR ") + ")", args
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
||||||
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
|
||||||
for _, id := range c.InboundIds {
|
|
||||||
p := byInbound[id]
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if slices.Contains(protocols, strings.ToLower(p)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
|
||||||
for _, id := range c.InboundIds {
|
|
||||||
if slices.Contains(inboundIds, id) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
||||||
for _, b := range buckets {
|
|
||||||
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
|
||||||
if fromMs <= 0 && toMs <= 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// expiryTime of 0 means "never expires"; treat it as outside any bounded
|
|
||||||
// range so users filtering by date see only clients with concrete expiries.
|
|
||||||
if c.ExpiryTime == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Negative expiry is the "delayed start" sentinel; same treatment as never.
|
|
||||||
if c.ExpiryTime < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if fromMs > 0 && c.ExpiryTime < fromMs {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if toMs > 0 && c.ExpiryTime > toMs {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
|
||||||
if fromBytes <= 0 && toBytes <= 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
used := int64(0)
|
|
||||||
if c.Traffic != nil {
|
|
||||||
used = c.Traffic.Up + c.Traffic.Down
|
|
||||||
}
|
|
||||||
if fromBytes > 0 && used < fromBytes {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if toBytes > 0 && used > toBytes {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "on":
|
|
||||||
return c.Reset > 0
|
|
||||||
case "off":
|
|
||||||
return c.Reset <= 0
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "yes":
|
|
||||||
return c.TgID != 0
|
|
||||||
case "no":
|
|
||||||
return c.TgID == 0
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "yes":
|
|
||||||
return strings.TrimSpace(c.Comment) != ""
|
|
||||||
case "no":
|
|
||||||
return strings.TrimSpace(c.Comment) == ""
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
|
|
||||||
groups := parseCSVStrings(csv)
|
|
||||||
if len(groups) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
current := strings.TrimSpace(c.Group)
|
|
||||||
for _, g := range groups {
|
|
||||||
if g == "" {
|
|
||||||
if current == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.EqualFold(g, current) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
|
||||||
if bucket == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
used := int64(0)
|
|
||||||
if c.Traffic != nil {
|
|
||||||
used = c.Traffic.Up + c.Traffic.Down
|
|
||||||
}
|
|
||||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
|
||||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
|
||||||
switch bucket {
|
|
||||||
case "online":
|
|
||||||
if onlineSet == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := onlineSet[c.Email]
|
|
||||||
return ok && c.Enable
|
|
||||||
case "depleted":
|
|
||||||
return exhausted || expired
|
|
||||||
case "deactive":
|
|
||||||
return !c.Enable
|
|
||||||
case "active":
|
|
||||||
return c.Enable && !exhausted && !expired
|
|
||||||
case "expiring":
|
|
||||||
if !c.Enable || exhausted || expired {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
|
||||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
|
||||||
return nearExpiry || nearLimit
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
|
||||||
if sortKey == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
desc := order == "descend"
|
|
||||||
less := func(i, j int) bool {
|
|
||||||
a, b := rows[i], rows[j]
|
|
||||||
switch sortKey {
|
|
||||||
case "enable":
|
|
||||||
if a.Enable == b.Enable {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !a.Enable && b.Enable
|
|
||||||
case "email":
|
|
||||||
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
|
|
||||||
case "inboundIds":
|
|
||||||
return len(a.InboundIds) < len(b.InboundIds)
|
|
||||||
case "traffic":
|
|
||||||
ua := int64(0)
|
|
||||||
if a.Traffic != nil {
|
|
||||||
ua = a.Traffic.Up + a.Traffic.Down
|
|
||||||
}
|
|
||||||
ub := int64(0)
|
|
||||||
if b.Traffic != nil {
|
|
||||||
ub = b.Traffic.Up + b.Traffic.Down
|
|
||||||
}
|
|
||||||
return ua < ub
|
|
||||||
case "remaining":
|
|
||||||
ra := int64(1<<62 - 1)
|
|
||||||
if a.TotalGB > 0 {
|
|
||||||
used := int64(0)
|
|
||||||
if a.Traffic != nil {
|
|
||||||
used = a.Traffic.Up + a.Traffic.Down
|
|
||||||
}
|
|
||||||
ra = a.TotalGB - used
|
|
||||||
}
|
|
||||||
rb := int64(1<<62 - 1)
|
|
||||||
if b.TotalGB > 0 {
|
|
||||||
used := int64(0)
|
|
||||||
if b.Traffic != nil {
|
|
||||||
used = b.Traffic.Up + b.Traffic.Down
|
|
||||||
}
|
|
||||||
rb = b.TotalGB - used
|
|
||||||
}
|
|
||||||
return ra < rb
|
|
||||||
case "expiryTime":
|
|
||||||
ea := int64(1<<62 - 1)
|
|
||||||
if a.ExpiryTime > 0 {
|
|
||||||
ea = a.ExpiryTime
|
|
||||||
}
|
|
||||||
eb := int64(1<<62 - 1)
|
|
||||||
if b.ExpiryTime > 0 {
|
|
||||||
eb = b.ExpiryTime
|
|
||||||
}
|
|
||||||
return ea < eb
|
|
||||||
case "createdAt":
|
|
||||||
if a.CreatedAt == b.CreatedAt {
|
|
||||||
return a.Id < b.Id
|
|
||||||
}
|
|
||||||
return a.CreatedAt < b.CreatedAt
|
|
||||||
case "updatedAt":
|
|
||||||
if a.UpdatedAt == b.UpdatedAt {
|
|
||||||
return a.Id < b.Id
|
|
||||||
}
|
|
||||||
return a.UpdatedAt < b.UpdatedAt
|
|
||||||
case "lastOnline":
|
|
||||||
la := int64(0)
|
|
||||||
if a.Traffic != nil {
|
|
||||||
la = a.Traffic.LastOnline
|
|
||||||
}
|
|
||||||
lb := int64(0)
|
|
||||||
if b.Traffic != nil {
|
|
||||||
lb = b.Traffic.LastOnline
|
|
||||||
}
|
|
||||||
if la == lb {
|
|
||||||
return a.Id < b.Id
|
|
||||||
}
|
|
||||||
return la < lb
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sort.SliceStable(rows, func(i, j int) bool {
|
|
||||||
if desc {
|
|
||||||
return less(j, i)
|
|
||||||
}
|
|
||||||
return less(i, j)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pagingDay = int64(86400000)
|
||||||
|
pagingGB = int64(1) << 30
|
||||||
|
)
|
||||||
|
|
||||||
|
type pagingSeed struct {
|
||||||
|
email string
|
||||||
|
enable bool
|
||||||
|
totalGB int64
|
||||||
|
expiryTime int64
|
||||||
|
used int64
|
||||||
|
subID string
|
||||||
|
uuid string
|
||||||
|
password string
|
||||||
|
auth string
|
||||||
|
comment string
|
||||||
|
group string
|
||||||
|
tgID int64
|
||||||
|
reset int
|
||||||
|
lastOnline int64
|
||||||
|
inbounds []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPagingClients writes one vless and one trojan inbound plus a fixed client
|
||||||
|
// set covering every bucket, sort key and search field ListPaged supports.
|
||||||
|
// Returns "now" so the expectations can be phrased relative to it.
|
||||||
|
func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||||
|
trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
|
||||||
|
for _, ib := range []*model.Inbound{vless, trojan} {
|
||||||
|
if err := db.Create(ib).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds := []pagingSeed{
|
||||||
|
{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
|
||||||
|
{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
|
||||||
|
{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
|
||||||
|
{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
|
||||||
|
{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
|
||||||
|
{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
|
||||||
|
{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
|
||||||
|
{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
|
||||||
|
{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
|
||||||
|
{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
|
||||||
|
{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
|
||||||
|
{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range seeds {
|
||||||
|
rec := model.ClientRecord{
|
||||||
|
Email: s.email,
|
||||||
|
SubID: s.subID,
|
||||||
|
UUID: s.uuid,
|
||||||
|
Password: s.password,
|
||||||
|
Auth: s.auth,
|
||||||
|
Comment: s.comment,
|
||||||
|
Group: s.group,
|
||||||
|
TgID: s.tgID,
|
||||||
|
Reset: s.reset,
|
||||||
|
Enable: s.enable,
|
||||||
|
TotalGB: s.totalGB,
|
||||||
|
ExpiryTime: s.expiryTime,
|
||||||
|
CreatedAt: now - int64(len(seeds)-i)*pagingDay,
|
||||||
|
UpdatedAt: now - int64(i)*pagingDay,
|
||||||
|
}
|
||||||
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
|
t.Fatalf("create client %s: %v", s.email, err)
|
||||||
|
}
|
||||||
|
if !s.enable {
|
||||||
|
// clients.enable carries a `default:true` tag, so GORM leaves the
|
||||||
|
// zero value out of the INSERT and the column comes back true.
|
||||||
|
// Restate updated_at so the autoUpdateTime hook cannot reshuffle
|
||||||
|
// the sort fixtures.
|
||||||
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
||||||
|
Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
|
||||||
|
t.Fatalf("disable %s: %v", s.email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traffic := xray.ClientTraffic{
|
||||||
|
Email: s.email,
|
||||||
|
Enable: s.enable,
|
||||||
|
Up: s.used / 2,
|
||||||
|
Down: s.used - s.used/2,
|
||||||
|
Total: s.totalGB,
|
||||||
|
ExpiryTime: s.expiryTime,
|
||||||
|
LastOnline: s.lastOnline,
|
||||||
|
}
|
||||||
|
if err := db.Create(&traffic).Error; err != nil {
|
||||||
|
t.Fatalf("create traffic %s: %v", s.email, err)
|
||||||
|
}
|
||||||
|
for _, id := range s.inbounds {
|
||||||
|
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
|
||||||
|
t.Fatalf("attach %s to %d: %v", s.email, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now, seeds
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagedEmails(items []ClientSlim) []string {
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it.Email)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
|
||||||
|
t.Helper()
|
||||||
|
setupBulkDB(t)
|
||||||
|
settingSvc := &SettingService{}
|
||||||
|
if err := settingSvc.setInt("expireDiff", 3); err != nil {
|
||||||
|
t.Fatalf("set expireDiff: %v", err)
|
||||||
|
}
|
||||||
|
if err := settingSvc.setInt("trafficDiff", 1); err != nil {
|
||||||
|
t.Fatalf("set trafficDiff: %v", err)
|
||||||
|
}
|
||||||
|
return &ClientService{}, &InboundService{}, settingSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedFilters(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
now, _ := seedPagingClients(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
params ClientPageParams
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no filter returns every client in id order",
|
||||||
|
params: ClientPageParams{PageSize: 50},
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "depleted bucket covers quota and expiry",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "depleted"},
|
||||||
|
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deactive bucket is every disabled client",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
||||||
|
want: []string{"echo@x", "foxtrot@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expiring bucket covers near expiry and near quota",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "expiring"},
|
||||||
|
want: []string{"golf@x", "hotel@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active bucket keeps enabled clients that still have room",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
||||||
|
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "buckets are ORed",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
|
||||||
|
want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown bucket keeps matching everything",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol filter follows the attachments",
|
||||||
|
params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
|
||||||
|
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inbound filter follows the attachments",
|
||||||
|
params: ClientPageParams{PageSize: 50, Inbound: "2"},
|
||||||
|
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the email",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "KILO"},
|
||||||
|
want: []string{"kilo_1@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search treats LIKE wildcards literally",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
|
||||||
|
want: []string{"kilo_1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the subId",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
|
||||||
|
want: []string{"alpha@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the uuid",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
|
||||||
|
want: []string{"alpha@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the password",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
|
||||||
|
want: []string{"bravo@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the auth",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
|
||||||
|
want: []string{"charlie@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the comment",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "vip customer"},
|
||||||
|
want: []string{"juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search matches the telegram id",
|
||||||
|
params: ClientPageParams{PageSize: 50, Search: "555"},
|
||||||
|
want: []string{"juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group filter is case insensitive",
|
||||||
|
params: ClientPageParams{PageSize: 50, Group: "vip"},
|
||||||
|
want: []string{"juliet@x", "kilo_1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hasComment yes",
|
||||||
|
params: ClientPageParams{PageSize: 50, HasComment: "yes"},
|
||||||
|
want: []string{"juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hasTgId yes",
|
||||||
|
params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
|
||||||
|
want: []string{"juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "autoRenew on",
|
||||||
|
params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
|
||||||
|
want: []string{"juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "usage range is inclusive on both bounds",
|
||||||
|
params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
|
||||||
|
want: []string{"alpha@x", "bravo@x", "delta@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expiry range excludes never and delayed start",
|
||||||
|
params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
|
||||||
|
want: []string{"golf@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filters combine with AND",
|
||||||
|
params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
|
||||||
|
want: []string{"charlie@x", "foxtrot@x"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
got := pagedEmails(resp.Items)
|
||||||
|
if !slices.Equal(got, tc.want) {
|
||||||
|
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
if resp.Filtered != len(tc.want) {
|
||||||
|
t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
|
||||||
|
}
|
||||||
|
if resp.Total != 12 {
|
||||||
|
t.Fatalf("total = %d, want 12", resp.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedSorting(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sort string
|
||||||
|
order string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no sort key keeps insertion order",
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email ascending", sort: "email", order: "ascend",
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email descending", sort: "email", order: "descend",
|
||||||
|
want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "traffic descending", sort: "traffic", order: "descend",
|
||||||
|
want: []string{"charlie@x", "hotel@x", "alpha@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
|
||||||
|
want: []string{"alpha@x", "india@x", "juliet@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
|
||||||
|
want: []string{"delta@x", "foxtrot@x", "golf@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
|
||||||
|
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
|
||||||
|
want: []string{"bravo@x", "alpha@x", "kilo1@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
|
||||||
|
want: []string{"echo@x", "foxtrot@x", "alpha@x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
|
||||||
|
want: []string{"bravo@x", "alpha@x", "charlie@x"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
got := pagedEmails(resp.Items)
|
||||||
|
if !slices.Equal(got, tc.want) {
|
||||||
|
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedPagination(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
t.Run("second page continues where the first stopped", func(t *testing.T) {
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
|
||||||
|
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("emails = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if resp.Page != 2 || resp.PageSize != 5 {
|
||||||
|
t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 0 {
|
||||||
|
t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Filtered != 12 || resp.Total != 12 {
|
||||||
|
t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("page size is clamped to the maximum", func(t *testing.T) {
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
if resp.PageSize != clientPageMaxSize {
|
||||||
|
t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedRowContents(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
byEmail := make(map[string]ClientSlim, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
byEmail[it.Email] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("attachments are reported in inbound order", func(t *testing.T) {
|
||||||
|
got := byEmail["bravo@x"].InboundIds
|
||||||
|
if !slices.Equal(got, []int{1, 2}) {
|
||||||
|
t.Fatalf("inboundIds = %v, want [1 2]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unattached client reports no inbounds", func(t *testing.T) {
|
||||||
|
if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
|
||||||
|
t.Fatalf("inboundIds = %v, want none", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("traffic counters ride along with the row", func(t *testing.T) {
|
||||||
|
got := byEmail["hotel@x"].Traffic
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("traffic = nil, want the seeded counters")
|
||||||
|
}
|
||||||
|
if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
|
||||||
|
t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("groups list every name in use", func(t *testing.T) {
|
||||||
|
if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
|
||||||
|
t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedSummary(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
s := resp.Summary
|
||||||
|
|
||||||
|
t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
|
||||||
|
if s.Total != 12 {
|
||||||
|
t.Fatalf("total = %d, want 12", s.Total)
|
||||||
|
}
|
||||||
|
if s.DepletedCount != 3 {
|
||||||
|
t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
|
||||||
|
}
|
||||||
|
if s.ExpiringCount != 2 {
|
||||||
|
t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
|
||||||
|
}
|
||||||
|
if s.DeactiveCount != 1 {
|
||||||
|
t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
|
||||||
|
}
|
||||||
|
if s.Active != 6 {
|
||||||
|
t.Fatalf("active = %d, want 6", s.Active)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every client lands in exactly one counter", func(t *testing.T) {
|
||||||
|
if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
|
||||||
|
t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
||||||
|
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
||||||
|
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
||||||
|
}
|
||||||
|
if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
|
||||||
|
t.Fatalf("expiring = %v, want %v", s.Expiring, want)
|
||||||
|
}
|
||||||
|
if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
|
||||||
|
t.Fatalf("deactive = %v, want %v", s.Deactive, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
const n = clientSummaryEmailCap + 25
|
||||||
|
past := time.Now().UnixMilli() - pagingDay
|
||||||
|
for i := range n {
|
||||||
|
rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
|
||||||
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
|
t.Fatalf("create client %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Summary.DepletedCount != n {
|
||||||
|
t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
|
||||||
|
}
|
||||||
|
if len(resp.Summary.Depleted) != clientSummaryEmailCap {
|
||||||
|
t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedGlobalTrafficOverlay(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
|
||||||
|
// cross-panel usage has to move it into the depleted bucket.
|
||||||
|
if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
|
||||||
|
{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
|
||||||
|
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("depleted = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if resp.Summary.DepletedCount != 4 {
|
||||||
|
t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
|
||||||
|
}
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
if it.Email != "bravo@x" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
|
||||||
|
t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientQueryOnlineEmails(t *testing.T) {
|
||||||
|
_, _, _ = setupPagingServices(t)
|
||||||
|
seedPagingClients(t)
|
||||||
|
|
||||||
|
q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
|
||||||
|
emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("onlineEmails: %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
|
||||||
|
t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("count = %d, want 2", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailInCondChunksLargeSets(t *testing.T) {
|
||||||
|
emails := make([]string, sqlInChunk+1)
|
||||||
|
for i := range emails {
|
||||||
|
emails[i] = "e" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
cond, args := emailInCond("c.email", emails)
|
||||||
|
if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
|
||||||
|
t.Fatalf("cond = %q, want %q", cond, want)
|
||||||
|
}
|
||||||
|
if len(args) != 2 {
|
||||||
|
t.Fatalf("args = %d chunks, want 2", len(args))
|
||||||
|
}
|
||||||
|
if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
|
||||||
|
t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyCond, emptyArgs := emailInCond("c.email", nil)
|
||||||
|
if emptyCond != "1 = 0" || emptyArgs != nil {
|
||||||
|
t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeLikeLiteral(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"plain", "plain"},
|
||||||
|
{"a_b", `a\_b`},
|
||||||
|
{"50%", `50\%`},
|
||||||
|
{`back\slash`, `back\\slash`},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := escapeLikeLiteral(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPagedEmptyPanel(t *testing.T) {
|
||||||
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||||
|
|
||||||
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged on a panel with no clients: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
|
||||||
|
t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
|
||||||
|
}
|
||||||
|
if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
|
||||||
|
t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
|
||||||
|
}
|
||||||
|
if resp.Groups == nil {
|
||||||
|
t.Fatal("groups = nil, want an empty list so the filter drawer renders")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"maps"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -256,9 +257,7 @@ func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
|
|||||||
case "stream":
|
case "stream":
|
||||||
stream = fixture
|
stream = fixture
|
||||||
case "security":
|
case "security":
|
||||||
for key, value := range fixture {
|
maps.Copy(stream, fixture)
|
||||||
stream[key] = value
|
|
||||||
}
|
|
||||||
case "sockopt":
|
case "sockopt":
|
||||||
stream["sockopt"] = fixture
|
stream["sockopt"] = fixture
|
||||||
case "finalmask":
|
case "finalmask":
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
|||||||
now := time.Now().Unix() * 1000
|
now := time.Now().Unix() * 1000
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
var tags []string
|
var tags []string
|
||||||
err := tx.Table("inbounds").
|
err := tx.Table("inbounds").
|
||||||
Select("inbounds.tag").
|
Select("inbounds.tag").
|
||||||
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, err
|
return false, 0, err
|
||||||
}
|
}
|
||||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
err1 := s.xrayApi.DelInbound(tag)
|
err1 := s.xrayApi.DelInbound(tag)
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
@@ -153,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p != nil && len(localTargets) > 0 {
|
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
|
||||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||||
for _, t := range localTargets {
|
for _, t := range localTargets {
|
||||||
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
tree := snap.OnlineTree
|
tree := snap.OnlineTree
|
||||||
switch {
|
switch {
|
||||||
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
|
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
|
||||||
@@ -1058,17 +1058,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
tree = remapped
|
tree = remapped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.SetNodeOnlineTree(nodeID, tree)
|
process.SetNodeOnlineTree(nodeID, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
return structuralChange, nil
|
return structuralChange, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetOnlineClients() []string {
|
func (s *InboundService) GetOnlineClients() []string {
|
||||||
if p == nil {
|
process := currentXrayProcess()
|
||||||
|
if process == nil {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
return p.GetOnlineClients()
|
return process.GetOnlineClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
||||||
@@ -1077,11 +1078,12 @@ func (s *InboundService) GetOnlineClients() []string {
|
|||||||
// node-id keying so a client three hops down is attributed to its real node,
|
// node-id keying so a client three hops down is attributed to its real node,
|
||||||
// not the intermediate one it was synced through.
|
// not the intermediate one it was synced through.
|
||||||
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||||
if p == nil {
|
process := currentXrayProcess()
|
||||||
|
if process == nil {
|
||||||
return map[string][]string{}
|
return map[string][]string{}
|
||||||
}
|
}
|
||||||
out := p.GetMergedNodeTrees()
|
out := process.GetMergedNodeTrees()
|
||||||
if local := p.GetLocalOnlineClients(); len(local) > 0 {
|
if local := process.GetLocalOnlineClients(); len(local) > 0 {
|
||||||
if guid := s.panelGuid(); guid != "" {
|
if guid := s.panelGuid(); guid != "" {
|
||||||
out[guid] = mergeEmails(out[guid], local)
|
out[guid] = mergeEmails(out[guid], local)
|
||||||
}
|
}
|
||||||
@@ -1094,10 +1096,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
|||||||
// report per-inbound activity, so a GUID missing from the map means "don't
|
// report per-inbound activity, so a GUID missing from the map means "don't
|
||||||
// gate" for that node's inbounds.
|
// gate" for that node's inbounds.
|
||||||
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||||
if p == nil {
|
process := currentXrayProcess()
|
||||||
|
if process == nil {
|
||||||
return map[string][]string{}
|
return map[string][]string{}
|
||||||
}
|
}
|
||||||
active := p.GetLocalActiveInbounds()
|
active := process.GetLocalActiveInbounds()
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return map[string][]string{}
|
return map[string][]string{}
|
||||||
}
|
}
|
||||||
@@ -1109,14 +1112,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
|
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
p.SetNodeOnlineTree(nodeID, tree)
|
process.SetNodeOnlineTree(nodeID, tree)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
p.ClearNodeOnlineClients(nodeID)
|
process.ClearNodeOnlineClients(nodeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,8 +1180,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
|
|||||||
// xray.Process for why the local sets are kept separate from the shared
|
// xray.Process for why the local sets are kept separate from the shared
|
||||||
// last_online column.
|
// last_online column.
|
||||||
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
|
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -452,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
|||||||
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
|
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
|
||||||
return false, 0, err
|
return false, 0, err
|
||||||
}
|
}
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
err1 = s.xrayApi.Init(p.GetAPIPort())
|
err1 = s.xrayApi.Init(process.GetAPIPort())
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
return true, int64(len(traffics)), nil
|
return true, int64(len(traffics)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import (
|
|||||||
// CheckXrayConfig's version gate.
|
// CheckXrayConfig's version gate.
|
||||||
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
||||||
coreVersion := "Unknown"
|
coreVersion := "Unknown"
|
||||||
if p != nil {
|
if process := currentXrayProcess(); process != nil {
|
||||||
coreVersion = p.GetXrayVersion()
|
coreVersion = process.GetXrayVersion()
|
||||||
}
|
}
|
||||||
kept := make([]any, 0, len(outbounds))
|
kept := make([]any, 0, len(outbounds))
|
||||||
var dropped []string
|
var dropped []string
|
||||||
|
|||||||
@@ -624,8 +624,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|||||||
status.AppStats.Mem = rtm.Sys
|
status.AppStats.Mem = rtm.Sys
|
||||||
}
|
}
|
||||||
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
||||||
if p != nil && p.IsRunning() {
|
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||||
status.AppStats.Uptime = p.GetUptime()
|
status.AppStats.Uptime = process.GetUptime()
|
||||||
} else {
|
} else {
|
||||||
status.AppStats.Uptime = 0
|
status.AppStats.Uptime = 0
|
||||||
}
|
}
|
||||||
@@ -668,8 +668,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
|||||||
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
|
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
|
||||||
systemMetrics.append("udpCount", t, float64(status.UdpCount))
|
systemMetrics.append("udpCount", t, float64(status.UdpCount))
|
||||||
online := 0
|
online := 0
|
||||||
if p != nil && p.IsRunning() {
|
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||||
online = len(p.GetOnlineClients())
|
online = len(process.GetOnlineClients())
|
||||||
}
|
}
|
||||||
systemMetrics.append("online", t, float64(online))
|
systemMetrics.append("online", t, float64(online))
|
||||||
if len(status.Loads) >= 3 {
|
if len(status.Loads) >= 3 {
|
||||||
@@ -1303,25 +1303,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
|
|||||||
if database.IsPostgres() {
|
if database.IsPostgres() {
|
||||||
return s.exportPostgresDB()
|
return s.exportPostgresDB()
|
||||||
}
|
}
|
||||||
// Update by manually trigger a checkpoint operation
|
backupPath, cleanup, err := s.backupSQLite()
|
||||||
err := database.Checkpoint()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Open the file for reading
|
defer cleanup()
|
||||||
file, err := os.Open(config.GetDBPath())
|
return os.ReadFile(backupPath)
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Read the file contents
|
func (s *ServerService) backupSQLite() (string, func(), error) {
|
||||||
fileContents, err := io.ReadAll(file)
|
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
cleanup := func() { _ = os.RemoveAll(backupDir) }
|
||||||
return fileContents, nil
|
backupPath := filepath.Join(backupDir, "backup.db")
|
||||||
|
if err := database.BackupSQLite(backupPath); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return backupPath, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupFilename returns the filename for a database backup, named after the
|
// BackupFilename returns the filename for a database backup, named after the
|
||||||
@@ -1421,11 +1422,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
|
|||||||
return data, "x-ui.db", nil
|
return data, "x-ui.db", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
|
backupPath, cleanup, err := s.backupSQLite()
|
||||||
if err := database.Checkpoint(); err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
|
defer cleanup()
|
||||||
|
data, err := database.DumpSQLiteToBytes(backupPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user