wangzhibo
2023-10-31 9da39812105bfc387db3b3b7c4896983e03d247d
init
81个文件已添加
21107 ■■■■■ 已修改文件
.commitlintrc.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.dockerignore 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.eslintignore 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.eslintrc.yml 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.lintstagedrc.yml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.prettierignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.prettierrc 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CHANGELOG.md 1405 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Caddyfile 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Dockerfile 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
LICENSE 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/config.yaml 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/docker-compose.yml 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/preview-connections.webp 补丁 | 查看 | 原始文档 | blame | 历史
docs/preview-overview.webp 补丁 | 查看 | 原始文档 | blame | 历史
index.html 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 6701 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
postcss.config.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/apple-touch-icon-180x180.png 补丁 | 查看 | 原始文档 | blame | 历史
public/config.yml 7027 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/favicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
public/favicon.svg 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/maskable-icon-512x512.png 补丁 | 查看 | 原始文档 | blame | 历史
public/pwa-192x192.png 补丁 | 查看 | 原始文档 | blame | 历史
public/pwa-512x512.png 补丁 | 查看 | 原始文档 | blame | 历史
public/pwa-64x64.png 补丁 | 查看 | 原始文档 | blame | 历史
pwa-assets.config.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/App.tsx 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/apis/index.ts 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/Twemoji.Mozilla.ttf 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Button.tsx 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Collapse.tsx 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConfigTitle.tsx 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConnectionsSettingsModal.tsx 271 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConnectionsTableDetailsModal.tsx 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Header.tsx 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Latency.tsx 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/LogoText.tsx 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/LogsSettingsModal.tsx 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Modal.tsx 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProxiesSettingsModal.tsx 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProxyNodeCard.tsx 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProxyNodePreview.tsx 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProxyPreviewBar.tsx 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ProxyPreviewDots.tsx 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/RenderInTwoColumns.tsx 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/SubscriptionInfo.tsx 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/index.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/constants/index.ts 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/helpers/global.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/helpers/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/helpers/proxies.ts 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/i18n/dict.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/i18n/en.ts 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/i18n/index.tsx 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/i18n/zh.ts 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/index.css 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.tsx 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Config.tsx 510 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Connections.tsx 542 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Logs.tsx 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Overview.tsx 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Proxies.tsx 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Rules.tsx 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/Setup.tsx 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/config.ts 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/connections.ts 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/global.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/proxies.ts 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/request.ts 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/rules.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/signals/theme.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/types/index.d.ts 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/vite-env.d.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tailwind.config.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tsconfig.json 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
typings/index.d.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.commitlintrc.yml
New file
@@ -0,0 +1,2 @@
extends:
  - '@commitlint/config-conventional'
.dockerignore
New file
@@ -0,0 +1 @@
node_modules
.eslintignore
New file
@@ -0,0 +1 @@
dist
.eslintrc.yml
New file
@@ -0,0 +1,50 @@
parser: '@typescript-eslint/parser'
parserOptions:
  ecmaFeatures:
    jsx: true
  ecmaVersion: latest
  sourceType: module
env:
  node: true
plugins:
  - '@typescript-eslint'
  - prettier
extends:
  - eslint:recommended
  - plugin:@typescript-eslint/eslint-recommended
  - plugin:@typescript-eslint/recommended
  - prettier
settings:
  import/resolver:
    typescript:
    node: true
rules:
  '@typescript-eslint/no-unused-vars':
    - error
    - argsIgnorePattern: '^_'
  no-empty: off
  padding-line-between-statements:
    - error
    - blankLine: always
      prev: '*'
      next: return
    - blankLine: always
      prev: '*'
      next: if
    - blankLine: always
      prev: if
      next: '*'
    - blankLine: always
      prev: '*'
      next: switch
    - blankLine: always
      prev: switch
      next: '*'
.gitignore
New file
@@ -0,0 +1,3 @@
.idea
node_modules
dist
.lintstagedrc.yml
New file
@@ -0,0 +1,3 @@
'package.json': 'sort-package-json'
'*.{js,jsx,ts,tsx}': 'eslint --fix'
'*.{js,jsx,ts,tsx,md,html,css,json,yml,yaml}': 'prettier --write'
.prettierignore
New file
@@ -0,0 +1,3 @@
dist
CHANGELOG.md
pnpm-lock.yaml
.prettierrc
New file
@@ -0,0 +1,9 @@
{
  "plugins": [
    "prettier-plugin-organize-imports",
    "prettier-plugin-tailwindcss"
  ],
  "semi": false,
  "singleQuote": true,
  "endOfLine": "lf"
}
CHANGELOG.md
New file
@@ -0,0 +1,1405 @@
# Changelog
## [1.124.2](https://github.com/MetaCubeX/metacubexd/compare/v1.124.1...v1.124.2) (2023-10-09)
### Bug Fixes
* **setup:** setup with url search string not working properly ([ce4622b](https://github.com/MetaCubeX/metacubexd/commit/ce4622b9543d14b304bf10bcc2d542ee248a14bc))
## [1.124.1](https://github.com/MetaCubeX/metacubexd/compare/v1.124.0...v1.124.1) (2023-10-08)
### Bug Fixes
* **setup:** use location info from router instead of browser ([7ecc51c](https://github.com/MetaCubeX/metacubexd/commit/7ecc51c43c581105506f23192f4d7b6938ec944c))
## [1.124.0](https://github.com/MetaCubeX/metacubexd/compare/v1.123.0...v1.124.0) (2023-10-04)
### Features
* **config:** toast error message when dns query failed for some reason, closes [#321](https://github.com/MetaCubeX/metacubexd/issues/321) ([82c2772](https://github.com/MetaCubeX/metacubexd/commit/82c2772afe913039b0695ef449f9f48b54eb822b))
## [1.123.0](https://github.com/MetaCubeX/metacubexd/compare/v1.122.1...v1.123.0) (2023-10-02)
### Features
* **config:** display currently selected endpoint url ([59e0b07](https://github.com/MetaCubeX/metacubexd/commit/59e0b076bf42db40c2ff1bafa2ba126f79997d25))
## [1.122.1](https://github.com/MetaCubeX/metacubexd/compare/v1.122.0...v1.122.1) (2023-09-28)
### Bug Fixes
* **rule:** rule pages filter logic is not stable, fixes [#313](https://github.com/MetaCubeX/metacubexd/issues/313) ([1f22147](https://github.com/MetaCubeX/metacubexd/commit/1f22147cd14462e11b82cd367cbf7b786358d6e5))
## [1.122.0](https://github.com/MetaCubeX/metacubexd/compare/v1.121.0...v1.122.0) (2023-09-28)
### Features
* publish gh-pages with tag name as commit message ([336b954](https://github.com/MetaCubeX/metacubexd/commit/336b9545e662b78f5234d5dfcf926e017857c643))
## [1.121.0](https://github.com/MetaCubeX/metacubexd/compare/v1.120.0...v1.121.0) (2023-09-27)
### Features
* **proxy:** remove collapse component overflow-visible class ([389af52](https://github.com/MetaCubeX/metacubexd/commit/389af52c74e249fac957addd43eeeb8976957022))
## [1.120.0](https://github.com/MetaCubeX/metacubexd/compare/v1.119.0...v1.120.0) (2023-09-27)
### Features
* **proxy:** compact proxy node card ([40f07b8](https://github.com/MetaCubeX/metacubexd/commit/40f07b8efe7b6d48da0da5db1ce1372d07a14d3c))
## [1.119.0](https://github.com/MetaCubeX/metacubexd/compare/v1.118.0...v1.119.0) (2023-09-26)
### Features
* **rule:** fuzzy filter ([f53a1a4](https://github.com/MetaCubeX/metacubexd/commit/f53a1a4cb34211d1a9b6c14ed7938702d194eb1d))
## [1.118.0](https://github.com/MetaCubeX/metacubexd/compare/v1.117.0...v1.118.0) (2023-09-26)
### Features
* **rule:** filter rules and rule providers by search input ([db16200](https://github.com/MetaCubeX/metacubexd/commit/db16200b1ce42f31a813a41b6c3eedbab63baeac))
## [1.117.0](https://github.com/MetaCubeX/metacubexd/compare/v1.116.0...v1.117.0) (2023-09-26)
### Features
* **rule:** implement virtual scroll on rules and rule providers, fixes [#285](https://github.com/MetaCubeX/metacubexd/issues/285) ([7145cec](https://github.com/MetaCubeX/metacubexd/commit/7145cec62fd4fc409d3f0e46f7aaaf0bea354abd))
## [1.116.0](https://github.com/MetaCubeX/metacubexd/compare/v1.115.1...v1.116.0) (2023-09-26)
### Features
* bump version ([66d92a9](https://github.com/MetaCubeX/metacubexd/commit/66d92a9b529504e2edbfd087a7d6783b8f257354))
## [1.115.1](https://github.com/MetaCubeX/metacubexd/compare/v1.115.0...v1.115.1) (2023-09-25)
### Bug Fixes
* **proxy:** proxy preview dots layout issue ([4b7f4c8](https://github.com/MetaCubeX/metacubexd/commit/4b7f4c899f4549ffc89d84a0a93f5f837918fdb3))
## [1.115.0](https://github.com/MetaCubeX/metacubexd/compare/v1.114.0...v1.115.0) (2023-09-25)
### Features
* publish gh-pages with force_orphan option enabled ([067bedd](https://github.com/MetaCubeX/metacubexd/commit/067bedd43f9f75310335bc3b97f003ca06ea4d38))
## [1.114.0](https://github.com/MetaCubeX/metacubexd/compare/v1.113.1...v1.114.0) (2023-09-25)
### Features
* publish compressed dist with tar gzip format ([0c9b31d](https://github.com/MetaCubeX/metacubexd/commit/0c9b31d03f9d6670e1fabbeee4e3791a2d17a775))
## [1.113.1](https://github.com/MetaCubeX/metacubexd/compare/v1.113.0...v1.113.1) (2023-09-25)
### Bug Fixes
* **proxy:** proxy provider health check not working, closes [#282](https://github.com/MetaCubeX/metacubexd/issues/282) ([db24ad2](https://github.com/MetaCubeX/metacubexd/commit/db24ad20bb5fbfcf195dc4c71f3024091bf16f1b))
## [1.113.0](https://github.com/MetaCubeX/metacubexd/compare/v1.112.0...v1.113.0) (2023-09-24)
### Features
* **proxy:** single proxy node latency testing state indicator ([0052ce4](https://github.com/MetaCubeX/metacubexd/commit/0052ce4325fec2d185df98ad187efa70171d0a28))
## [1.112.0](https://github.com/MetaCubeX/metacubexd/compare/v1.111.1...v1.112.0) (2023-09-24)
### Features
* **proxy:** single proxy node latency test ([9255658](https://github.com/MetaCubeX/metacubexd/commit/9255658e5408b0c48e1f35bdaf8fecf407bba869))
## [1.111.1](https://github.com/MetaCubeX/metacubexd/compare/v1.111.0...v1.111.1) (2023-09-24)
### Bug Fixes
* **proxy:** fetch proxies after latency test ([81fe550](https://github.com/MetaCubeX/metacubexd/commit/81fe55091292015b623e2545fd7d0547cfffbed0))
## [1.111.0](https://github.com/MetaCubeX/metacubexd/compare/v1.110.0...v1.111.0) (2023-09-23)
### Features
* **modal:** adjust padding of the modal header and footer ([53ee2f3](https://github.com/MetaCubeX/metacubexd/commit/53ee2f31b397b3abd6a208a88492be2f6eaaeebe))
* **modals:** add modal component ([#268](https://github.com/MetaCubeX/metacubexd/issues/268)) ([eca9a16](https://github.com/MetaCubeX/metacubexd/commit/eca9a160cc1db5558eb1a7ed14c9e544295ca7b4))
* **proxy:** shorten the autoCloseConns i18n text ([2ee48c8](https://github.com/MetaCubeX/metacubexd/commit/2ee48c8ac90c79844254ff07a98fe48b50ca68c0))
* **theme:** update default theme ([be0bc55](https://github.com/MetaCubeX/metacubexd/commit/be0bc55317006725c0a155162a2b604a888b6847))
### Bug Fixes
* **rules:** reload button style issue ([04c8d25](https://github.com/MetaCubeX/metacubexd/commit/04c8d2524b6d6b0fb1b2f36b98a0ffe2a20fbf8c))
## [1.110.0](https://github.com/MetaCubeX/metacubexd/compare/v1.109.0...v1.110.0) (2023-09-22)
### Features
* **config:** new version update available indicator ([857d0c5](https://github.com/MetaCubeX/metacubexd/commit/857d0c5387e9a19c6ca27d408461f10aa8b6ef9e))
## [1.109.0](https://github.com/MetaCubeX/metacubexd/compare/v1.108.2...v1.109.0) (2023-09-22)
### Features
* **config:** i18n support for port numbers ([07d3e7f](https://github.com/MetaCubeX/metacubexd/commit/07d3e7fdce396fcdfb5740d943fa16c0187f4b63))
* **config:** render versions in two grid columns ([64f2d71](https://github.com/MetaCubeX/metacubexd/commit/64f2d7139a036ec4abcd56baab6d0ca0429ecd39))
* **i18n:** new language switcher in config page ([21d479a](https://github.com/MetaCubeX/metacubexd/commit/21d479a1df14c5fa0947e1824c7ce77609180d69))
* overscroll-y-none ([063cb31](https://github.com/MetaCubeX/metacubexd/commit/063cb31f151e56c6caa46f868bf861fe5748b4c6))
## [1.108.2](https://github.com/MetaCubeX/metacubexd/compare/v1.108.1...v1.108.2) (2023-09-22)
### Bug Fixes
* time from now date string not following locale changes ([ba21999](https://github.com/MetaCubeX/metacubexd/commit/ba21999ed17cdf8997207ac7727525ba07ce0676))
## [1.108.1](https://github.com/MetaCubeX/metacubexd/compare/v1.108.0...v1.108.1) (2023-09-22)
### Bug Fixes
* **connection:** cannot filter by undefined ([08776a5](https://github.com/MetaCubeX/metacubexd/commit/08776a5bee855d3d7d7fa32b1ca43b41272ce988))
## [1.108.0](https://github.com/MetaCubeX/metacubexd/compare/v1.107.0...v1.108.0) (2023-09-22)
### Features
* **config:** support tun related configurations ([512accd](https://github.com/MetaCubeX/metacubexd/commit/512accd4257a8f11cd412fb78224eb0b7c832f3e))
### Bug Fixes
* **connection:** limit the max width of client source ip select, closes [#270](https://github.com/MetaCubeX/metacubexd/issues/270) ([ca1e3ce](https://github.com/MetaCubeX/metacubexd/commit/ca1e3ce82c7a200abb0dd2ac744827a57a410763))
## [1.107.0](https://github.com/MetaCubeX/metacubexd/compare/v1.106.0...v1.107.0) (2023-09-22)
### Features
* **button:** icon button ([5ef8410](https://github.com/MetaCubeX/metacubexd/commit/5ef8410da5cbc67ad7fc6bd63775e325aaf57828))
* **config:** move switch language button into header ([9cc99eb](https://github.com/MetaCubeX/metacubexd/commit/9cc99ebb8e380f9b5e855b6d3250dfe96e237134))
* **config:** update config page buttons layout, closes [#263](https://github.com/MetaCubeX/metacubexd/issues/263) ([dd0452d](https://github.com/MetaCubeX/metacubexd/commit/dd0452dcd3f68bad959f756cf191ea1c2470b2c1))
### Bug Fixes
* **modals:** close icon button style issue ([7f8d71c](https://github.com/MetaCubeX/metacubexd/commit/7f8d71ccc5b847ddb39e332199e21ede43483ea6))
## [1.106.0](https://github.com/MetaCubeX/metacubexd/compare/v1.105.0...v1.106.0) (2023-09-22)
### Features
* **connection:** filter client sourceIP with tag name support, closes [#264](https://github.com/MetaCubeX/metacubexd/issues/264) ([8df4d7a](https://github.com/MetaCubeX/metacubexd/commit/8df4d7a7e93570c9d4d76b5903907e1bcb2ab45a))
## [1.105.0](https://github.com/MetaCubeX/metacubexd/compare/v1.104.0...v1.105.0) (2023-09-21)
### Features
* **LogoText:** update logo navigation links ([d7e8c2f](https://github.com/MetaCubeX/metacubexd/commit/d7e8c2f4a43f8805681de725208036fdd0150496))
## [1.104.0](https://github.com/MetaCubeX/metacubexd/compare/v1.103.0...v1.104.0) (2023-09-21)
### Features
* **connection:** filter connections with sourceIP, closes [#246](https://github.com/MetaCubeX/metacubexd/issues/246) ([ffa2447](https://github.com/MetaCubeX/metacubexd/commit/ffa244760f639309c64f0a41b721921a4b57e558))
## [1.103.0](https://github.com/MetaCubeX/metacubexd/compare/v1.102.0...v1.103.0) (2023-09-21)
### Features
* **connection:** add sniffHost support, [#246](https://github.com/MetaCubeX/metacubexd/issues/246) ([2b16de1](https://github.com/MetaCubeX/metacubexd/commit/2b16de18b0bad7886baabe34ee7769c3e33bfff8))
## [1.102.0](https://github.com/MetaCubeX/metacubexd/compare/v1.101.0...v1.102.0) (2023-09-21)
### Features
* **config:** three columns on small screen device, five on larger ([f4e82e0](https://github.com/MetaCubeX/metacubexd/commit/f4e82e0e270ff1cc6ec5ad0cec04cda807febcfa))
* **proxy:** regression on render proxies in two columns ([8502a6e](https://github.com/MetaCubeX/metacubexd/commit/8502a6e4896e7a1c5e2049fcd2521a5434fcf70e))
## [1.101.0](https://github.com/MetaCubeX/metacubexd/compare/v1.100.1...v1.101.0) (2023-09-21)
### Features
* **proxy:** fetch proxies after latency test ([2a0b21b](https://github.com/MetaCubeX/metacubexd/commit/2a0b21bcc7bd0b096e1bc8008fef3ccf1649496a))
## [1.100.1](https://github.com/MetaCubeX/metacubexd/compare/v1.100.0...v1.100.1) (2023-09-19)
### Performance Improvements
* **overview:** shrink the data size every minute, closes [#234](https://github.com/MetaCubeX/metacubexd/issues/234) ([e0766fc](https://github.com/MetaCubeX/metacubexd/commit/e0766fc2c829b51728e16a587b68d13bd34c3bd9))
## [1.100.0](https://github.com/MetaCubeX/metacubexd/compare/v1.99.0...v1.100.0) (2023-09-19)
### Features
* add a close button to the modal ([#239](https://github.com/MetaCubeX/metacubexd/issues/239)) ([737f1b0](https://github.com/MetaCubeX/metacubexd/commit/737f1b018975f3e13a566c0dd744e1d1f545421f))
## [1.99.0](https://github.com/MetaCubeX/metacubexd/compare/v1.98.3...v1.99.0) (2023-09-18)
### Features
* show provider expire message with i18n ([#237](https://github.com/MetaCubeX/metacubexd/issues/237)) ([b9e9a95](https://github.com/MetaCubeX/metacubexd/commit/b9e9a955f26e1ebf9f45fd951fbbfe295f487c85))
## [1.98.3](https://github.com/MetaCubeX/metacubexd/compare/v1.98.2...v1.98.3) (2023-09-17)
### Bug Fixes
* **proxy:** wrap subscription info usage percentage with toFinite ([ed947f6](https://github.com/MetaCubeX/metacubexd/commit/ed947f62a26c30a6f30aaf73066d7aea6f520bb0))
## [1.98.2](https://github.com/MetaCubeX/metacubexd/compare/v1.98.1...v1.98.2) (2023-09-17)
### Bug Fixes
* subscriptionInfo may not be valid ([e7ba6c7](https://github.com/MetaCubeX/metacubexd/commit/e7ba6c7c34ceae395cd59337d965ee74b672e799))
## [1.98.1](https://github.com/MetaCubeX/metacubexd/compare/v1.98.0...v1.98.1) (2023-09-17)
### Bug Fixes
* **proxy:** selectProxyInGroup ([dc0b286](https://github.com/MetaCubeX/metacubexd/commit/dc0b286a888085352be54de1dda50962330318cf))
## [1.98.0](https://github.com/MetaCubeX/metacubexd/compare/v1.97.0...v1.98.0) (2023-09-17)
### Features
* **pwa:** add pwa shortcut icon, closes [#201](https://github.com/MetaCubeX/metacubexd/issues/201) ([3a23e99](https://github.com/MetaCubeX/metacubexd/commit/3a23e99c269373bbbaae95cdca65d7a0dbdddc10))
## [1.97.0](https://github.com/MetaCubeX/metacubexd/compare/v1.96.0...v1.97.0) (2023-09-17)
### Features
* **logs:** fuzzy filter, sorting ([67345cf](https://github.com/MetaCubeX/metacubexd/commit/67345cf1e34eb309fa0a1ba5eb1bbfe441fc61fd))
* **proxy:** show more proxy nodes on larger screen ([5277c7b](https://github.com/MetaCubeX/metacubexd/commit/5277c7bbc99f7407ef195b8c2508332efbc59a07))
### Bug Fixes
* **logs:** inconsistent gap between search input and table ([477fb22](https://github.com/MetaCubeX/metacubexd/commit/477fb2233a56e5d3a015a27292580eac0267b7d8))
* **logs:** inconsistent padding ([3076487](https://github.com/MetaCubeX/metacubexd/commit/30764873a834f04dd7855a69b03cfb9c68f549fd))
## [1.96.0](https://github.com/MetaCubeX/metacubexd/compare/v1.95.0...v1.96.0) (2023-09-16)
### Features
* **connection:** update tag badges layout ([b7a0407](https://github.com/MetaCubeX/metacubexd/commit/b7a0407e42b4433766bda53c4dd7866dfe7c1cb3))
### Performance Improvements
* batch signals ([0015468](https://github.com/MetaCubeX/metacubexd/commit/0015468077c146e1d7526863873b3af16f01fccc))
* batch signals ([e04ec71](https://github.com/MetaCubeX/metacubexd/commit/e04ec712ea5582d32dc723cc1ebcef4d192ab185))
## [1.95.0](https://github.com/MetaCubeX/metacubexd/compare/v1.94.0...v1.95.0) (2023-09-16)
### Features
* **config:** update page layout ([95200a5](https://github.com/MetaCubeX/metacubexd/commit/95200a56b8902e443f5e8d2c8fa38ff6c6e28c2a))
* **config:** update page layout ([2be357e](https://github.com/MetaCubeX/metacubexd/commit/2be357e73c51c52709fe8b911b9e595ed281a7fa))
* **config:** update page layout, limit the max width of the container ([d059ea8](https://github.com/MetaCubeX/metacubexd/commit/d059ea85b3efd8c25e1ebed158b556df7eb2b5ae))
## [1.94.0](https://github.com/MetaCubeX/metacubexd/compare/v1.93.2...v1.94.0) (2023-09-16)
### Features
* **connections:** tag client source ip with name, closes [#181](https://github.com/MetaCubeX/metacubexd/issues/181) ([e538f9d](https://github.com/MetaCubeX/metacubexd/commit/e538f9d057570871cac0ac94aeae9b40c99d0217))
## [1.93.2](https://github.com/MetaCubeX/metacubexd/compare/v1.93.1...v1.93.2) (2023-09-16)
### Bug Fixes
* **log:** log level value not selected ([3fbed0d](https://github.com/MetaCubeX/metacubexd/commit/3fbed0d69482da005bfa109eed9acae203a08ce2))
## [1.93.1](https://github.com/MetaCubeX/metacubexd/compare/v1.93.0...v1.93.1) (2023-09-16)
### Bug Fixes
* **proxy:** dont hideUnAvailableProxies in proxy providers ([be6edd9](https://github.com/MetaCubeX/metacubexd/commit/be6edd914a32b05125337fd113d571f6066b0265))
## [1.93.0](https://github.com/MetaCubeX/metacubexd/compare/v1.92.0...v1.93.0) (2023-09-15)
### Features
* **config:** reload config file, flush fake-ip data ([6ffd1a8](https://github.com/MetaCubeX/metacubexd/commit/6ffd1a860538f82ef92e2fb97ed19d67e4452ad7))
## [1.92.0](https://github.com/MetaCubeX/metacubexd/compare/v1.91.0...v1.92.0) (2023-09-15)
### Features
* drop ForTwoColumns renderer ([353b5eb](https://github.com/MetaCubeX/metacubexd/commit/353b5ebff3457c4a804836ee1428efeb7f8ad633))
### Bug Fixes
* proxy providers grid layout ([6d242a4](https://github.com/MetaCubeX/metacubexd/commit/6d242a44579522c65e086a5c5c89824329643c0b))
* **proxy:** tabs won't be shown in proxies page when proxy provider list is empty ([6c5662a](https://github.com/MetaCubeX/metacubexd/commit/6c5662a7a9a99ccd6b6d570a8da983fa37effc0b))
## [1.91.0](https://github.com/MetaCubeX/metacubexd/compare/v1.90.0...v1.91.0) (2023-09-15)
### Features
* **proxy:** hideUnAvailableProxies, closes [#200](https://github.com/MetaCubeX/metacubexd/issues/200) ([9e556d9](https://github.com/MetaCubeX/metacubexd/commit/9e556d959eb6ad801be30570118c942d9c31dc03))
* **rules:** renderInTwoColumns ([6743ebd](https://github.com/MetaCubeX/metacubexd/commit/6743ebde8f5e13f32b27f913c620629517f63da2))
* settings per-page ([d776260](https://github.com/MetaCubeX/metacubexd/commit/d77626058af9a3d22f22e410918d2aafb3f2ea46))
### Bug Fixes
* failed to resolve import solid-infinite-scroll ([ebc95fe](https://github.com/MetaCubeX/metacubexd/commit/ebc95fe58592a6f187f733ea1da3b56d738a7406))
* logMaxRows value ([b3c924e](https://github.com/MetaCubeX/metacubexd/commit/b3c924e33bdca68fed59092c107cc38324a418d0))
## [1.90.0](https://github.com/MetaCubeX/metacubexd/compare/v1.89.0...v1.90.0) (2023-09-13)
### Features
* **connections:** shorten the tab names ([1a37a10](https://github.com/MetaCubeX/metacubexd/commit/1a37a106f8c421829854ffcb75b64b7885b00005))
## [1.89.0](https://github.com/MetaCubeX/metacubexd/compare/v1.88.1...v1.89.0) (2023-09-12)
### Features
* **config:** select tunnel mode ([34ea1f1](https://github.com/MetaCubeX/metacubexd/commit/34ea1f1c5657d466c360b1b1771571b06f67e423))
### Bug Fixes
* **overview:** error when reading length ([#188](https://github.com/MetaCubeX/metacubexd/issues/188)) ([9f227c9](https://github.com/MetaCubeX/metacubexd/commit/9f227c9c2c38718bf540bb5c9c5959a647d00af2))
## [1.88.1](https://github.com/MetaCubeX/metacubexd/compare/v1.88.0...v1.88.1) (2023-09-10)
### Bug Fixes
* **Dockerfile:** lock node alpine version ([6f5c1da](https://github.com/MetaCubeX/metacubexd/commit/6f5c1dade011dc0359366b832bbb0879e079e99f))
## [1.88.0](https://github.com/MetaCubeX/metacubexd/compare/v1.87.0...v1.88.0) (2023-09-10)
### Features
* **connections:** close filtered connections ([4f8c4b2](https://github.com/MetaCubeX/metacubexd/commit/4f8c4b2d15fdee6a546f0290b9c42c860aa334fb))
* **connections:** fuzzy filter ([9a144e8](https://github.com/MetaCubeX/metacubexd/commit/9a144e882dc7b33fd6a6e055b122533941a0361f))
* **connections:** keyed connection rows and cells ([e7eb3dd](https://github.com/MetaCubeX/metacubexd/commit/e7eb3dd331e0059ccf366f1411a1be208fec2a2a))
* **connections:** set CONNECTIONS_TABLE_MAX_CLOSED_ROWS to 200 ([b34ceb3](https://github.com/MetaCubeX/metacubexd/commit/b34ceb334d1b0df41d720ea471c9af7b694ae202))
* **connections:** use Index instead of key ([07f461f](https://github.com/MetaCubeX/metacubexd/commit/07f461f1105f3bbb1322ea4d246227a313ce02a4))
* **logs:** use Index instead of For ([669492f](https://github.com/MetaCubeX/metacubexd/commit/669492f22c2d72ab9c6c18d1630bd0179f0f933b))
## [1.87.0](https://github.com/MetaCubeX/metacubexd/compare/v1.86.1...v1.87.0) (2023-09-10)
### Features
* minor improvements ([f4788bb](https://github.com/MetaCubeX/metacubexd/commit/f4788bb4634d73bac8ad0030849b0f60f971a610))
## [1.86.1](https://github.com/MetaCubeX/metacubexd/compare/v1.86.0...v1.86.1) (2023-09-10)
### Bug Fixes
* **proxies:** format proxy type ([1c61df8](https://github.com/MetaCubeX/metacubexd/commit/1c61df8b8b39fd587393bdaf8ee52c2da12e89ad))
## [1.86.0](https://github.com/MetaCubeX/metacubexd/compare/v1.85.0...v1.86.0) (2023-09-09)
### Features
* **logs:** fixes [#194](https://github.com/MetaCubeX/metacubexd/issues/194) ([b000f91](https://github.com/MetaCubeX/metacubexd/commit/b000f919fc1db24ba03ab3eb4a327e8687564330))
* **setup:** prefix endpoint url with current url protocol if missing ([e935aed](https://github.com/MetaCubeX/metacubexd/commit/e935aedfcf3629d038c084dc3491fb07bac1e54b))
## [1.85.0](https://github.com/MetaCubeX/metacubexd/compare/v1.84.0...v1.85.0) (2023-09-09)
### Features
* **rules:** tab style for rules page [#163](https://github.com/MetaCubeX/metacubexd/issues/163) ([#187](https://github.com/MetaCubeX/metacubexd/issues/187)) ([a9aa8e5](https://github.com/MetaCubeX/metacubexd/commit/a9aa8e540e975d27ed7a843f1e1b9ba3abcc02bd))
## [1.84.0](https://github.com/MetaCubeX/metacubexd/compare/v1.83.2...v1.84.0) (2023-09-08)
### Features
- **connections:** make connections table sorting state persisted ([6957cb4](https://github.com/MetaCubeX/metacubexd/commit/6957cb4ddf9616ab6eade404098d4b84c9ed2a97))
- **connections:** show connection detail on click of a button ([daf1682](https://github.com/MetaCubeX/metacubexd/commit/daf168208e6a1e7bb4ab921ee5b235c926f6a13a))
- **logs:** add i18n translation support ([965ec50](https://github.com/MetaCubeX/metacubexd/commit/965ec502aceeb206c0067e36a61f755a31002bc4))
## [1.83.2](https://github.com/MetaCubeX/metacubexd/compare/v1.83.1...v1.83.2) (2023-09-08)
### Bug Fixes
- **proxies:** disconnect after proxy is changed ([a8cb75f](https://github.com/MetaCubeX/metacubexd/commit/a8cb75f29da3c4616237ec4918c92df91854f83b))
## [1.83.1](https://github.com/MetaCubeX/metacubexd/compare/v1.83.0...v1.83.1) (2023-09-08)
### Bug Fixes
- **connections:** close conns ([f3423ab](https://github.com/MetaCubeX/metacubexd/commit/f3423abe921a470fc3ef1b9e6076643efcc799e0))
## [1.83.0](https://github.com/MetaCubeX/metacubexd/compare/v1.82.0...v1.83.0) (2023-09-08)
### Features
- **proxies:** auto close connections when proxy selected ([15d20cd](https://github.com/MetaCubeX/metacubexd/commit/15d20cde4ad514d45a54845f18fb9b401a681208))
### Bug Fixes
- **button:** btn disable ([59a7273](https://github.com/MetaCubeX/metacubexd/commit/59a727366536588a8451ea40d305711dbfc6b36e))
- **connections:** limit number of conns ([5263710](https://github.com/MetaCubeX/metacubexd/commit/5263710d30e08efdfcd976236f2eab76fa085264))
- **connections:** table close conn ([d0c7c15](https://github.com/MetaCubeX/metacubexd/commit/d0c7c1569d8138724218d67c08195747be9492a0))
- **connections:** ws error when switch end point ([b04b20f](https://github.com/MetaCubeX/metacubexd/commit/b04b20f8f01d4cfdd9f41591e03d675e1512b911))
## [1.82.0](https://github.com/MetaCubeX/metacubexd/compare/v1.81.1...v1.82.0) (2023-09-07)
### Features
- add a gradient color to logo text ([4da1720](https://github.com/MetaCubeX/metacubexd/commit/4da1720e550355efdef147cae3c3cfbf31e319b7))
- **proxy:** add more adapter types to be processed by formatProxyType ([8245268](https://github.com/MetaCubeX/metacubexd/commit/82452688c93ca9df8be225f0e46cf9760e297fcf))
## [1.81.1](https://github.com/MetaCubeX/metacubexd/compare/v1.81.0...v1.81.1) (2023-09-07)
### Bug Fixes
- **proxy:** fixes [#174](https://github.com/MetaCubeX/metacubexd/issues/174) ([c0b0489](https://github.com/MetaCubeX/metacubexd/commit/c0b04892c8a1eb93a8d8ac25c3c2d35da45c81e6))
- **setup:** fixes [#175](https://github.com/MetaCubeX/metacubexd/issues/175) ([8242706](https://github.com/MetaCubeX/metacubexd/commit/8242706290668f91578562d70e616a82ed5ce00c))
## [1.81.0](https://github.com/MetaCubeX/metacubexd/compare/v1.80.0...v1.81.0) (2023-09-07)
### Features
- **connections:** update connections page top area ([64659b2](https://github.com/MetaCubeX/metacubexd/commit/64659b26f110a4c0271c584a892cc7943bb2cf41))
- new logo color ([e31c1eb](https://github.com/MetaCubeX/metacubexd/commit/e31c1eb9e031bed4254596e593b71374dbefcd5d))
### Bug Fixes
- **conns:** input width ([62ff9c3](https://github.com/MetaCubeX/metacubexd/commit/62ff9c3f09131072585e5ebb5ccf515a0bc75a60))
## [1.80.0](https://github.com/MetaCubeX/metacubexd/compare/v1.79.0...v1.80.0) (2023-09-07)
### Features
- hidden scrollbar for mobile ([2ce3016](https://github.com/MetaCubeX/metacubexd/commit/2ce30162752be27337c8e6dce7a81ee7bea6d6dd))
### Bug Fixes
- **connections:** padding fix ([8cea622](https://github.com/MetaCubeX/metacubexd/commit/8cea622251b93308a94c6ade29199a6f01dbf72d))
- tab style for conns and proxies ([842ce9e](https://github.com/MetaCubeX/metacubexd/commit/842ce9e00fe5a3f9e117cafb6c181602ef618c94))
## [1.79.0](https://github.com/MetaCubeX/metacubexd/compare/v1.78.1...v1.79.0) (2023-09-07)
### Features
- **proxies:** default proxies and remove all tab ([66c7935](https://github.com/MetaCubeX/metacubexd/commit/66c793550ab4ef3c85d24422fe58dec644deb6d4))
- update LogoText ([8d62c9f](https://github.com/MetaCubeX/metacubexd/commit/8d62c9f510de7268a3c0ead5e47a31fe938c196d))
### Bug Fixes
- btn size for mobile ([f729761](https://github.com/MetaCubeX/metacubexd/commit/f72976154df067d8c3fab3eda220dd386329b9be))
- **rules:** request token error ([44739f6](https://github.com/MetaCubeX/metacubexd/commit/44739f6962792a5ae42684ee8e1edcb8a9a4216b))
## [1.78.1](https://github.com/MetaCubeX/metacubexd/compare/v1.78.0...v1.78.1) (2023-09-06)
### Bug Fixes
- unselectable ProxyNodeCard also has cursor-pointer applied ([d5112f2](https://github.com/MetaCubeX/metacubexd/commit/d5112f23da449e4cb74c9abc49d84f34a0859758))
## [1.78.0](https://github.com/MetaCubeX/metacubexd/compare/v1.77.0...v1.78.0) (2023-09-06)
### Features
- customize scrollbar ([c27b192](https://github.com/MetaCubeX/metacubexd/commit/c27b19259bff444b21fd84502fa638fe8ac13c7b))
## [1.77.0](https://github.com/MetaCubeX/metacubexd/compare/v1.76.1...v1.77.0) (2023-09-06)
### Features
- **proxy:** keep tab at the top ([1bb36a1](https://github.com/MetaCubeX/metacubexd/commit/1bb36a1bd618e41863baac694c2ff115d364dbd8))
- update the overall look of the app ([08ed610](https://github.com/MetaCubeX/metacubexd/commit/08ed61045f199b0f2721e4a82c208f68ee70d476))
### Bug Fixes
- btn disable && var name ([6c88062](https://github.com/MetaCubeX/metacubexd/commit/6c880628ec6b3a32452b3d64c858e27f2e1c7b65))
## [1.76.1](https://github.com/MetaCubeX/metacubexd/compare/v1.76.0...v1.76.1) (2023-09-06)
### Bug Fixes
- **Connections:** disable grouping for time&speed ([#160](https://github.com/MetaCubeX/metacubexd/issues/160)) ([8398dff](https://github.com/MetaCubeX/metacubexd/commit/8398dff02d1bb254d806901f0a9ce49105be6d52))
## [1.76.0](https://github.com/MetaCubeX/metacubexd/compare/v1.75.1...v1.76.0) (2023-09-06)
### Features
- **proxy:** merge proxies and proxyProviders into one page ([93114b2](https://github.com/MetaCubeX/metacubexd/commit/93114b22a28b3e336b9fccbee4ec99b6eb8a4b61))
- **rules:** config for render rules in two columns ([7e2fe6a](https://github.com/MetaCubeX/metacubexd/commit/7e2fe6acb0d2bc879cd724cfda7d80924abd5d40))
### Bug Fixes
- **connections:** table max row 200 ([0d61b46](https://github.com/MetaCubeX/metacubexd/commit/0d61b460cc16aa1a7c60015afe9b526c38d57ffc))
- **proxy:** add small gap between update button and tabs ([ff7dab7](https://github.com/MetaCubeX/metacubexd/commit/ff7dab74b69b0527c721185fcdf94c005d7c5d3c))
- **request:** ws request url ([a13060a](https://github.com/MetaCubeX/metacubexd/commit/a13060a056c9ac87c21ce7f8706ee6a5ae23be18))
- **translation:** latencyTestTimeoutDuration ([2ebedcc](https://github.com/MetaCubeX/metacubexd/commit/2ebedcc1c97b675395cd0f55824bc11ee5af5fce))
## [1.75.1](https://github.com/MetaCubeX/metacubexd/compare/v1.75.0...v1.75.1) (2023-09-06)
### Bug Fixes
- **overview:** chart font size ([af70669](https://github.com/MetaCubeX/metacubexd/commit/af70669e7a035a6368e7620be7ed489db76648cd))
## [1.75.0](https://github.com/MetaCubeX/metacubexd/compare/v1.74.4...v1.75.0) (2023-09-06)
### Features
- **connections:** split source info into sourceIP and sourcePort ([2d61f45](https://github.com/MetaCubeX/metacubexd/commit/2d61f45372ac65a01aa9f51fe010cfdee53d9d46))
## [1.74.4](https://github.com/MetaCubeX/metacubexd/compare/v1.74.3...v1.74.4) (2023-09-06)
### Bug Fixes
- **config:** speedtest timeout config ([5f1e773](https://github.com/MetaCubeX/metacubexd/commit/5f1e77320d82fbc5e2abfc1ebee70e4194c0ce98))
## [1.74.3](https://github.com/MetaCubeX/metacubexd/compare/v1.74.2...v1.74.3) (2023-09-06)
### Bug Fixes
- **Dockerfile:** lock node version to 20.5-alpine ([c370a84](https://github.com/MetaCubeX/metacubexd/commit/c370a84b8cc6546729c1c0db88ff8f6bba2244a6))
- **proxies:** speedtest params error ([5f0fb23](https://github.com/MetaCubeX/metacubexd/commit/5f0fb23f15f7118a76e58c3a6b6e4d97533dcfdd))
## [1.74.2](https://github.com/MetaCubeX/metacubexd/compare/v1.74.1...v1.74.2) (2023-09-06)
### Bug Fixes
- tableSizeClassName not been generated by tailwindcss ([5f4f9a8](https://github.com/MetaCubeX/metacubexd/commit/5f4f9a8ae8d49c4ce9816860c04ffa0090f2ead0))
## [1.74.1](https://github.com/MetaCubeX/metacubexd/compare/v1.74.0...v1.74.1) (2023-09-05)
### Bug Fixes
- **logs:** table size ([3a2560f](https://github.com/MetaCubeX/metacubexd/commit/3a2560f15291a60ed509cf8c32f5c699cda957e5))
## [1.74.0](https://github.com/MetaCubeX/metacubexd/compare/v1.73.0...v1.74.0) (2023-09-05)
### Features
- **connections:** shrink the element sizes down ([0aa9779](https://github.com/MetaCubeX/metacubexd/commit/0aa97795246978003319684b1ea8a500f7b382ca))
## [1.73.0](https://github.com/MetaCubeX/metacubexd/compare/v1.72.1...v1.73.0) (2023-09-05)
### Features
- **connections:** pausing support ([6076619](https://github.com/MetaCubeX/metacubexd/commit/60766194858e0c88f5af2a5f4c5f21946a0429ff))
## [1.72.1](https://github.com/MetaCubeX/metacubexd/compare/v1.72.0...v1.72.1) (2023-09-05)
### Bug Fixes
- **connections:** limit closed connection items count below 100 ([f97f467](https://github.com/MetaCubeX/metacubexd/commit/f97f46720a5ded52aa6b592f11b1dca2579bb0b5))
## [1.72.0](https://github.com/MetaCubeX/metacubexd/compare/v1.71.0...v1.72.0) (2023-09-05)
### Features
- **connections:** add closed connections tab ([f0e32d9](https://github.com/MetaCubeX/metacubexd/commit/f0e32d93f6d5ffa52a721ede1003d2f7ee20d0bc))
## [1.71.0](https://github.com/MetaCubeX/metacubexd/compare/v1.70.0...v1.71.0) (2023-09-05)
### Features
- **connections:** connections table now supports grouping ([4c8b38f](https://github.com/MetaCubeX/metacubexd/commit/4c8b38f7563b6c8c38c8295ff0a30ec995c88257))
### Bug Fixes
- **proxy:** updateAllProvider, some of the proxy provider update may throw ([e09aac2](https://github.com/MetaCubeX/metacubexd/commit/e09aac2f4bd55bee816fb3d2a615dd4b14169607))
## [1.70.0](https://github.com/MetaCubeX/metacubexd/compare/v1.69.0...v1.70.0) (2023-09-05)
### Features
- **config:** add setting, requestTimeoutDuration ([d01d148](https://github.com/MetaCubeX/metacubexd/commit/d01d148cfffc785a9c57b021696db9e2b191eb1f))
## [1.69.0](https://github.com/MetaCubeX/metacubexd/compare/v1.68.0...v1.69.0) (2023-09-05)
### Features
- icon success color when updating ([6575e73](https://github.com/MetaCubeX/metacubexd/commit/6575e734392d63d959dacbb3b7ad733285828475))
- make some changes in response to community suggestions ([cc78d4e](https://github.com/MetaCubeX/metacubexd/commit/cc78d4e2b1adf729d38a63f6b147544b100f94a3))
- make some changes in response to community suggestions ([90ecb86](https://github.com/MetaCubeX/metacubexd/commit/90ecb866039b36b4fdd1bc420bd65ef10222cd23))
### Bug Fixes
- icon vertical align ([c6f3bee](https://github.com/MetaCubeX/metacubexd/commit/c6f3beec354f632584d77c4a5295203076410e21))
- rule provider word break ([e6facf1](https://github.com/MetaCubeX/metacubexd/commit/e6facf11758500ebddb4b534f5e843fd8f016476))
## [1.68.0](https://github.com/MetaCubeX/metacubexd/compare/v1.67.5...v1.68.0) (2023-09-05)
### Features
- a stable table ([e19e53d](https://github.com/MetaCubeX/metacubexd/commit/e19e53d0f68bfd30d4aff3a630796678d7a7e97d))
- table style ([0115daf](https://github.com/MetaCubeX/metacubexd/commit/0115daf014de9737de0a30ab07bde2c4b0337492))
### Bug Fixes
- speedtest icon align ([2caf0c4](https://github.com/MetaCubeX/metacubexd/commit/2caf0c41dd8f12855b79f894e0a6b8dddcf030f6))
## [1.67.5](https://github.com/MetaCubeX/metacubexd/compare/v1.67.4...v1.67.5) (2023-09-05)
### Bug Fixes
- build issue, `Cannot redefine property: File` ([b50c41e](https://github.com/MetaCubeX/metacubexd/commit/b50c41e3a588b843f7b5f35afd38b981fd0372b9))
## [1.67.4](https://github.com/MetaCubeX/metacubexd/compare/v1.67.3...v1.67.4) (2023-09-05)
### Bug Fixes
- update pnpm-lock.yaml ([0756f13](https://github.com/MetaCubeX/metacubexd/commit/0756f133a0bf8f750b4caa2f8d895038d3816611))
## [1.67.3](https://github.com/MetaCubeX/metacubexd/compare/v1.67.2...v1.67.3) (2023-09-05)
### Bug Fixes
- better toggle twemoji implementation ([13abde7](https://github.com/MetaCubeX/metacubexd/commit/13abde7a9d62874dcab75de0cbba58b7285257a7))
## [1.67.2](https://github.com/MetaCubeX/metacubexd/compare/v1.67.1...v1.67.2) (2023-09-04)
### Bug Fixes
- rollback to h-screen :) ([b68e0c6](https://github.com/MetaCubeX/metacubexd/commit/b68e0c62c09a727c9b55b59e26f81cc55fb5222b))
## [1.67.1](https://github.com/MetaCubeX/metacubexd/compare/v1.67.0...v1.67.1) (2023-09-04)
### Bug Fixes
- use min-h-screen-safe instead of h-screen ([6f366fe](https://github.com/MetaCubeX/metacubexd/commit/6f366fe836d1759e6847de19ec64c0f182b242cb))
## [1.67.0](https://github.com/MetaCubeX/metacubexd/compare/v1.66.0...v1.67.0) (2023-09-04)
### Features
- add padding-bottom safe-area ([a55ec6f](https://github.com/MetaCubeX/metacubexd/commit/a55ec6f7b18fa8638d673c9c7c493752ae3dbbb1))
## [1.66.0](https://github.com/MetaCubeX/metacubexd/compare/v1.65.0...v1.66.0) (2023-09-04)
### Features
- add safe area paddings ([9840141](https://github.com/MetaCubeX/metacubexd/commit/9840141bce39188094db12373d8c358da771ffd1))
### Bug Fixes
- **proxies:** group type does not show UDP ([f0fbe09](https://github.com/MetaCubeX/metacubexd/commit/f0fbe098381c2b33c84b2eb5427f9db4b227e364))
## [1.65.0](https://github.com/MetaCubeX/metacubexd/compare/v1.64.0...v1.65.0) (2023-09-04)
### Features
- **connections:** optimized search ([17d9ae4](https://github.com/MetaCubeX/metacubexd/commit/17d9ae487226401d73ddf363bd76cebe9977f1b5))
## [1.64.0](https://github.com/MetaCubeX/metacubexd/compare/v1.63.0...v1.64.0) (2023-09-04)
### Features
- **logs:** sticky table thead ([5affd5b](https://github.com/MetaCubeX/metacubexd/commit/5affd5b91e253b433fc36e4e6d21f3a631790d3a))
## [1.63.0](https://github.com/MetaCubeX/metacubexd/compare/v1.62.0...v1.63.0) (2023-09-04)
### Features
- **connections:** sticky table thead ([7f8f8a9](https://github.com/MetaCubeX/metacubexd/commit/7f8f8a98816464f63cc19fcc6147a490bb58050d))
- **overview:** reduce padding of the chart container ([3728cd2](https://github.com/MetaCubeX/metacubexd/commit/3728cd2e3289531fe61fd2da11e06aaa89a1a471))
## [1.62.0](https://github.com/MetaCubeX/metacubexd/compare/v1.61.0...v1.62.0) (2023-09-04)
### Features
- **overview:** update the overall look of the overview page ([c757850](https://github.com/MetaCubeX/metacubexd/commit/c757850c1a34b2757ab5ec89e850fe7c1976d533))
### Bug Fixes
- **proxies:** also display latency when PROXIES_PREVIEW_TYPE is DOTS ([347ebb6](https://github.com/MetaCubeX/metacubexd/commit/347ebb6f84e1f316bd4551ca0f079ad1050a4f01))
- rule size ([2ff31d4](https://github.com/MetaCubeX/metacubexd/commit/2ff31d40b1349c2213b999c57d9d7fe0ce9e0af4))
## [1.61.0](https://github.com/MetaCubeX/metacubexd/compare/v1.60.3...v1.61.0) (2023-09-04)
### Features
- config for render proxies in same page ([5bc540e](https://github.com/MetaCubeX/metacubexd/commit/5bc540eeffeefd289197283f7ebe491397f18376))
- time column in conns ([98f6293](https://github.com/MetaCubeX/metacubexd/commit/98f6293290ecedf2f3fde4baa24f60cedfaa0445))
## [1.60.3](https://github.com/MetaCubeX/metacubexd/compare/v1.60.2...v1.60.3) (2023-09-04)
### Bug Fixes
- auto login ([d7f3649](https://github.com/MetaCubeX/metacubexd/commit/d7f3649eba6e2a6475e5b96b9fff51e414ca47f1))
- **connections:** close column header translation result not updating ([5689034](https://github.com/MetaCubeX/metacubexd/commit/5689034f942145e35811bb8c2f063ba5632490da))
## [1.60.2](https://github.com/MetaCubeX/metacubexd/compare/v1.60.1...v1.60.2) (2023-09-04)
### Bug Fixes
- setup onsubmit secret ([30fd139](https://github.com/MetaCubeX/metacubexd/commit/30fd13946b0a42528330d06994803d811838979e))
## [1.60.1](https://github.com/MetaCubeX/metacubexd/compare/v1.60.0...v1.60.1) (2023-09-03)
### Bug Fixes
- **overview:** only enable flex-row layout on large screen ([be8b54a](https://github.com/MetaCubeX/metacubexd/commit/be8b54a0f620acab8afdf9039555d89f7bf74083))
## [1.60.0](https://github.com/MetaCubeX/metacubexd/compare/v1.59.1...v1.60.0) (2023-09-03)
### Features
- **connections:** move close connection button next to search input ([4b8175d](https://github.com/MetaCubeX/metacubexd/commit/4b8175d718211d139b4db17f9412b88608b089fe))
## [1.59.1](https://github.com/MetaCubeX/metacubexd/compare/v1.59.0...v1.59.1) (2023-09-03)
### Bug Fixes
- **connections:** disable sorting for close column ([98b5d62](https://github.com/MetaCubeX/metacubexd/commit/98b5d62c0208efbd1cf501e031240d41ab434fc0))
## [1.59.0](https://github.com/MetaCubeX/metacubexd/compare/v1.58.0...v1.59.0) (2023-09-03)
### Features
- display current version ([1765ada](https://github.com/MetaCubeX/metacubexd/commit/1765adacdad295101e3d0479841affcca8a9ec7e))
- **overview:** add background color to charts container ([bd83d11](https://github.com/MetaCubeX/metacubexd/commit/bd83d113b169662e369355e655cf179a7704d311))
## [1.58.0](https://github.com/MetaCubeX/metacubexd/compare/v1.57.3...v1.58.0) (2023-09-03)
### Features
- **connections:** copy to clipboard on right click ([58afb5f](https://github.com/MetaCubeX/metacubexd/commit/58afb5fce29173b8041b8a93788bba361f3c43cd))
## [1.57.3](https://github.com/MetaCubeX/metacubexd/compare/v1.57.2...v1.57.3) (2023-09-03)
### Bug Fixes
- **font:** adapt to apple fonts ([84c70b0](https://github.com/MetaCubeX/metacubexd/commit/84c70b0550e6e1da5adf2f69ea530c45ff14561a))
## [1.57.2](https://github.com/MetaCubeX/metacubexd/compare/v1.57.1...v1.57.2) (2023-09-03)
### Bug Fixes
- bar latency ([197b0c7](https://github.com/MetaCubeX/metacubexd/commit/197b0c7d4b34dafb293ce398c9a59b043b4330eb))
## [1.57.1](https://github.com/MetaCubeX/metacubexd/compare/v1.57.0...v1.57.1) (2023-09-02)
### Bug Fixes
- **latency:** invalid 0 latency scenario ([dd5f0f1](https://github.com/MetaCubeX/metacubexd/commit/dd5f0f14a070bb9a042bfab1a544a161d8187b58))
## [1.57.0](https://github.com/MetaCubeX/metacubexd/compare/v1.56.0...v1.57.0) (2023-09-02)
### Features
- **config:** make config option titles more noticeable ([b9eb92f](https://github.com/MetaCubeX/metacubexd/commit/b9eb92fc0bd6ef5ce0aa35fd1c7ee46dd028621a))
## [1.56.0](https://github.com/MetaCubeX/metacubexd/compare/v1.55.0...v1.56.0) (2023-09-02)
### Features
- **proxies:** sortProxiesByOrderingType ([635b8ba](https://github.com/MetaCubeX/metacubexd/commit/635b8ba966da12dfe3879e97472014150deec0df))
### Bug Fixes
- **proxies:** latency widget ([b511b88](https://github.com/MetaCubeX/metacubexd/commit/b511b88cd1d426ef3252ba0a0d9f28c4fb6ad826))
## [1.55.0](https://github.com/MetaCubeX/metacubexd/compare/v1.54.0...v1.55.0) (2023-09-02)
### Features
- add PROXIES_PREVIEW_TYPE - off ([fec6413](https://github.com/MetaCubeX/metacubexd/commit/fec64133fa8b5a3f14905d7910097d790314471f))
- relax https latency test range ([1b09fa3](https://github.com/MetaCubeX/metacubexd/commit/1b09fa39af2c5bfc9fd8374c9b2ec53e59595184))
## [1.54.0](https://github.com/MetaCubeX/metacubexd/compare/v1.53.0...v1.54.0) (2023-09-02)
### Features
- add buttons - updateGEODatabases, restart, upgrade ([8e56543](https://github.com/MetaCubeX/metacubexd/commit/8e5654332ddfce03399e24864b856fed882480c3))
## [1.53.0](https://github.com/MetaCubeX/metacubexd/compare/v1.52.1...v1.53.0) (2023-09-02)
### Features
- config for render in two col ([5d43ea0](https://github.com/MetaCubeX/metacubexd/commit/5d43ea0b5f48b2d84984bdf94dcef87d8e780432))
### Bug Fixes
- btn animate ([a29e638](https://github.com/MetaCubeX/metacubexd/commit/a29e638c1e02fc8fb25ac1806d9e9e9d9af288f9))
## [1.52.1](https://github.com/MetaCubeX/metacubexd/compare/v1.52.0...v1.52.1) (2023-09-02)
### Bug Fixes
- update proxies ([467d0bc](https://github.com/MetaCubeX/metacubexd/commit/467d0bcce2bd7e0764c847ae3a8231a6ddcf034b))
## [1.52.0](https://github.com/MetaCubeX/metacubexd/compare/v1.51.1...v1.52.0) (2023-09-02)
### Features
- auto switch theme ([0bbc661](https://github.com/MetaCubeX/metacubexd/commit/0bbc661e93698312e162e0b8391aea20b553c9a9))
## [1.51.1](https://github.com/MetaCubeX/metacubexd/compare/v1.51.0...v1.51.1) (2023-09-02)
### Bug Fixes
- rules page && dns query for mobile ([86fd0d7](https://github.com/MetaCubeX/metacubexd/commit/86fd0d7d9db1aec5841f1b2e4931b98e789e5a93))
## [1.51.0](https://github.com/MetaCubeX/metacubexd/compare/v1.50.0...v1.51.0) (2023-09-02)
### Features
- reset visible ([350fd56](https://github.com/MetaCubeX/metacubexd/commit/350fd56bc98b25dadf5675b4ecdff323565afe97))
- rules provider pages ([65346ca](https://github.com/MetaCubeX/metacubexd/commit/65346ca743bf823da841d73c1f31022b1292c53a))
### Bug Fixes
- update proxy provider api may throw ([ad46745](https://github.com/MetaCubeX/metacubexd/commit/ad46745329343dea2fe69e0b3b3561cab6b54436))
## [1.50.0](https://github.com/MetaCubeX/metacubexd/compare/v1.49.0...v1.50.0) (2023-09-02)
### Features
- separate provider page ([419d3e3](https://github.com/MetaCubeX/metacubexd/commit/419d3e33f31a80c21e2ba16864bcad70d9dd4665))
### Bug Fixes
- better update provider all ([68ef377](https://github.com/MetaCubeX/metacubexd/commit/68ef377f4bf8690cf3bd371d945e11fd5dcf9b50))
## [1.49.0](https://github.com/MetaCubeX/metacubexd/compare/v1.48.0...v1.49.0) (2023-09-02)
### Features
- auto close all connections before switch proxy ([b79f10f](https://github.com/MetaCubeX/metacubexd/commit/b79f10f528e5a501f8d2b2e3d7e66eae820a00dd))
- collapse proxies and provider ([8757924](https://github.com/MetaCubeX/metacubexd/commit/875792432a7d9ea495b0d76c22dd3876bef4c34a))
## [1.48.0](https://github.com/MetaCubeX/metacubexd/compare/v1.47.0...v1.48.0) (2023-09-02)
### Features
- try defalut host and secret && adapt openclash ([fa612a1](https://github.com/MetaCubeX/metacubexd/commit/fa612a12d362a66f9506a7010d209f2bb7967b4b))
## [1.47.0](https://github.com/MetaCubeX/metacubexd/compare/v1.46.0...v1.47.0) (2023-09-02)
### Features
- config for delay test ([e92ce75](https://github.com/MetaCubeX/metacubexd/commit/e92ce752251b107423ff945a10a23abce867c5b3))
## [1.46.0](https://github.com/MetaCubeX/metacubexd/compare/v1.45.2...v1.46.0) (2023-09-02)
### Features
- preview by auto ([3257791](https://github.com/MetaCubeX/metacubexd/commit/3257791dec6851cb02cb627cbba469bf93d9ad90))
### Bug Fixes
- init scroll from 30 && revert delay map ([c260db1](https://github.com/MetaCubeX/metacubexd/commit/c260db13df031152d02bcd5328da3b373d3b8741))
- show option ([1351119](https://github.com/MetaCubeX/metacubexd/commit/135111914d58a5817781b3d657e0803f20e9e5e7))
## [1.45.2](https://github.com/MetaCubeX/metacubexd/compare/v1.45.1...v1.45.2) (2023-09-02)
### Bug Fixes
- tool tip for mobile ([b0bac89](https://github.com/MetaCubeX/metacubexd/commit/b0bac89488b6f3a0b9fbe32f74f3ae0f0593cbe7))
## [1.45.1](https://github.com/MetaCubeX/metacubexd/compare/v1.45.0...v1.45.1) (2023-09-02)
### Bug Fixes
- dns query component layout ([2464896](https://github.com/MetaCubeX/metacubexd/commit/2464896a4635cf04377485316695807519104cd7))
## [1.45.0](https://github.com/MetaCubeX/metacubexd/compare/v1.44.1...v1.45.0) (2023-09-02)
### Features
- **connections:** reset the ordering of columns ([9d3706f](https://github.com/MetaCubeX/metacubexd/commit/9d3706f39013b0c58860872a95b0c9266628e93c))
- i18n for conn table ([33fa191](https://github.com/MetaCubeX/metacubexd/commit/33fa191cfed4e05aeb5bb94aaa7ead792ec8a736))
## [1.44.1](https://github.com/MetaCubeX/metacubexd/compare/v1.44.0...v1.44.1) (2023-09-02)
### Bug Fixes
- bar and delay reactive ([9ed0540](https://github.com/MetaCubeX/metacubexd/commit/9ed05401a891bf02af90541e08ec9358ada73cff))
## [1.44.0](https://github.com/MetaCubeX/metacubexd/compare/v1.43.0...v1.44.0) (2023-09-02)
### Features
- preview by bar ([4d29e7c](https://github.com/MetaCubeX/metacubexd/commit/4d29e7c16e64278f2172af4e00b34cf4b10a50ee))
## [1.43.0](https://github.com/MetaCubeX/metacubexd/compare/v1.42.1...v1.43.0) (2023-09-02)
### Features
- choose halloween as default theme ([a1dee05](https://github.com/MetaCubeX/metacubexd/commit/a1dee058255946a5d9e45f72773e9a1260f8bc51))
## [1.42.1](https://github.com/MetaCubeX/metacubexd/compare/v1.42.0...v1.42.1) (2023-09-02)
### Bug Fixes
- bytesize in subs info ([4aa7c24](https://github.com/MetaCubeX/metacubexd/commit/4aa7c24ba4d56c49d25d55e69389c966872f5356))
## [1.42.0](https://github.com/MetaCubeX/metacubexd/compare/v1.41.2...v1.42.0) (2023-09-01)
### Features
- adjust table border radius ([9a9b1b4](https://github.com/MetaCubeX/metacubexd/commit/9a9b1b4abef693d48dfa79c5cccd6f808d98e7ac))
## [1.41.2](https://github.com/MetaCubeX/metacubexd/compare/v1.41.1...v1.41.2) (2023-09-01)
### Bug Fixes
- header tooltip on menu items instead of inner links ([2619b4f](https://github.com/MetaCubeX/metacubexd/commit/2619b4fd2f2e1f2921e5503196c11644d6e21708))
## [1.41.1](https://github.com/MetaCubeX/metacubexd/compare/v1.41.0...v1.41.1) (2023-09-01)
### Bug Fixes
- some themes is overriding global font-family ([c8a41bd](https://github.com/MetaCubeX/metacubexd/commit/c8a41bd29f5b60dc79c6fc623c27d1fbba700408))
## [1.41.0](https://github.com/MetaCubeX/metacubexd/compare/v1.40.0...v1.41.0) (2023-09-01)
### Features
- publish gh-pages to a custom domain ([4b548fb](https://github.com/MetaCubeX/metacubexd/commit/4b548fb219581ad7fe7eea56eb52aeb082ab5bb9))
## [1.40.0](https://github.com/MetaCubeX/metacubexd/compare/v1.39.0...v1.40.0) (2023-09-01)
### Features
- **i18n:** setup page i18n support ([07ee55d](https://github.com/MetaCubeX/metacubexd/commit/07ee55d8d9a9551d1de3da5836436673433d4ba2))
## [1.39.0](https://github.com/MetaCubeX/metacubexd/compare/v1.38.0...v1.39.0) (2023-09-01)
### Features
- **i18n:** add i18n support for navs ([3c7f01a](https://github.com/MetaCubeX/metacubexd/commit/3c7f01aefc4567886c4610aaf0753d037eeaf838))
- **i18n:** add more i18n resources ([b3172da](https://github.com/MetaCubeX/metacubexd/commit/b3172da302ddf53a33f6ca092f11eac153401feb))
- **i18n:** add toggle i18n button ([5620011](https://github.com/MetaCubeX/metacubexd/commit/56200111bd3dcb147e41dbcec2be6a7481b90f67))
### Bug Fixes
- default lang ([508e19a](https://github.com/MetaCubeX/metacubexd/commit/508e19a247e90a7a83d1f386851eb13d19e2b58a))
## [1.38.0](https://github.com/MetaCubeX/metacubexd/compare/v1.37.0...v1.38.0) (2023-09-01)
### Features
- **logs:** render logs in table ([7eee024](https://github.com/MetaCubeX/metacubexd/commit/7eee024cdc2c831a75710d62e842ae648818dec8))
## [1.37.0](https://github.com/MetaCubeX/metacubexd/compare/v1.36.1...v1.37.0) (2023-09-01)
### Features
- render in two line ([1807340](https://github.com/MetaCubeX/metacubexd/commit/18073407a6a096ef03433e5f8e4fafd2ebfc27c4))
## [1.36.1](https://github.com/MetaCubeX/metacubexd/compare/v1.36.0...v1.36.1) (2023-09-01)
### Bug Fixes
- card click ([25cbdad](https://github.com/MetaCubeX/metacubexd/commit/25cbdad8bc28021131dcadd4339b1b77942e6f71))
## [1.36.0](https://github.com/MetaCubeX/metacubexd/compare/v1.35.1...v1.36.0) (2023-09-01)
### Features
- infinite scroll for proxy card ([2584223](https://github.com/MetaCubeX/metacubexd/commit/258422353f340ae93cab791802a7ae54ae6c8046))
## [1.35.1](https://github.com/MetaCubeX/metacubexd/compare/v1.35.0...v1.35.1) (2023-09-01)
### Bug Fixes
- collapse render ([b26e033](https://github.com/MetaCubeX/metacubexd/commit/b26e03373fcc2516cf21acc51756dc2237406000))
## [1.35.0](https://github.com/MetaCubeX/metacubexd/compare/v1.34.0...v1.35.0) (2023-09-01)
### Features
- add PWA support ([a8c9c21](https://github.com/MetaCubeX/metacubexd/commit/a8c9c218985cd3cc470589aa3699a47bf280fd5e))
## [1.34.0](https://github.com/MetaCubeX/metacubexd/compare/v1.33.1...v1.34.0) (2023-09-01)
### Features
- **stats:** flexible size stat on large screen ([f5866dc](https://github.com/MetaCubeX/metacubexd/commit/f5866dcd9dfcdf7d5377d34070d0315500f65742))
## [1.33.1](https://github.com/MetaCubeX/metacubexd/compare/v1.33.0...v1.33.1) (2023-09-01)
### Bug Fixes
- cannot import module from connection ([3b774b9](https://github.com/MetaCubeX/metacubexd/commit/3b774b942358ffb2afa78e119093c2e696fda578))
- **connections:** destination and host ([bbfd514](https://github.com/MetaCubeX/metacubexd/commit/bbfd5142a3bb8ac2ea1888befaff64c7b522010e))
- import path ([13ff26c](https://github.com/MetaCubeX/metacubexd/commit/13ff26c9d3340c68483423d3adfa5dac31f25aed))
## [1.33.0](https://github.com/MetaCubeX/metacubexd/compare/v1.32.0...v1.33.0) (2023-09-01)
### Features
- speed test dot for proxy ([e9c6666](https://github.com/MetaCubeX/metacubexd/commit/e9c6666606c511be4f54582bb73d9bdcb3afd417))
### Bug Fixes
- delay dot class import ([09a8ede](https://github.com/MetaCubeX/metacubexd/commit/09a8ede9690b0159e5becb294db51379ad21bc90))
## [1.32.0](https://github.com/MetaCubeX/metacubexd/compare/v1.31.0...v1.32.0) (2023-09-01)
### Features
- add a dns query form ([b42bba7](https://github.com/MetaCubeX/metacubexd/commit/b42bba784a11156cb7964f25de840197dedceee7))
## [1.31.0](https://github.com/MetaCubeX/metacubexd/compare/v1.30.0...v1.31.0) (2023-09-01)
### Features
- show subscription usage in a progress bar ([24bfadd](https://github.com/MetaCubeX/metacubexd/commit/24bfaddfc80f0373c181ea873fdc39885f67fe58))
- subs info ([45da766](https://github.com/MetaCubeX/metacubexd/commit/45da766ed006ef51312206c17c9d65b9aa4a274b))
### Bug Fixes
- process not shown ([f5d65eb](https://github.com/MetaCubeX/metacubexd/commit/f5d65eb018f8d2b706d99118e828cf47822cd435))
## [1.30.0](https://github.com/MetaCubeX/metacubexd/compare/v1.29.0...v1.30.0) (2023-09-01)
### Features
- fira sans font ([a00c28e](https://github.com/MetaCubeX/metacubexd/commit/a00c28e601ac587a68500084a76f28e503bde706))
### Bug Fixes
- default listen port ([75e7c64](https://github.com/MetaCubeX/metacubexd/commit/75e7c64007e006ab1b502de4940bae1a4f3cf048))
## [1.29.0](https://github.com/MetaCubeX/metacubexd/compare/v1.28.3...v1.29.0) (2023-09-01)
### Features
- infinite scroll for rules ([a05c548](https://github.com/MetaCubeX/metacubexd/commit/a05c54854a3ffb1b9d079e14f9d48c0d2e79d2f3))
### Bug Fixes
- set proxy both from proxies and provider ([2549bfe](https://github.com/MetaCubeX/metacubexd/commit/2549bfe89cc5dd97ff2eb17d2d66ec659f5f046c))
## [1.28.3](https://github.com/MetaCubeX/metacubexd/compare/v1.28.2...v1.28.3) (2023-09-01)
### Bug Fixes
- proxy from proxies ([42d9e7b](https://github.com/MetaCubeX/metacubexd/commit/42d9e7bc9f19169d9928157b97a948205ca9cff6))
## [1.28.2](https://github.com/MetaCubeX/metacubexd/compare/v1.28.1...v1.28.2) (2023-09-01)
### Bug Fixes
- card info color theme ([80d5138](https://github.com/MetaCubeX/metacubexd/commit/80d5138423312f4c11cd864a01fa353526cde127))
## [1.28.1](https://github.com/MetaCubeX/metacubexd/compare/v1.28.0...v1.28.1) (2023-09-01)
### Bug Fixes
- card color theme ([4e5c8bf](https://github.com/MetaCubeX/metacubexd/commit/4e5c8bfe8d525c0857b113447b690d48c953a56b))
## [1.28.0](https://github.com/MetaCubeX/metacubexd/compare/v1.27.1...v1.28.0) (2023-09-01)
### Features
- add twemoji font ([e4a89fc](https://github.com/MetaCubeX/metacubexd/commit/e4a89fc89ecf17420f15c88ea4c30d6ed62ba92a))
## [1.27.1](https://github.com/MetaCubeX/metacubexd/compare/v1.27.0...v1.27.1) (2023-08-31)
### Bug Fixes
- border color ([c2cb607](https://github.com/MetaCubeX/metacubexd/commit/c2cb607a452b15da3fbfe9c1d2f5007f8b9fbcb6))
## [1.27.0](https://github.com/MetaCubeX/metacubexd/compare/v1.26.0...v1.27.0) (2023-08-31)
### Features
- sortable column ([4c4011a](https://github.com/MetaCubeX/metacubexd/commit/4c4011a617a5589da13dafcf357b9caef7bf5b32))
## [1.26.0](https://github.com/MetaCubeX/metacubexd/compare/v1.25.1...v1.26.0) (2023-08-31)
### Features
- columns hide ([cb9970c](https://github.com/MetaCubeX/metacubexd/commit/cb9970c29eae947d85e2ba196f92db360fd5add9))
## [1.25.1](https://github.com/MetaCubeX/metacubexd/compare/v1.25.0...v1.25.1) (2023-08-31)
### Bug Fixes
- **connections:** update the look of close button ([eee81e6](https://github.com/MetaCubeX/metacubexd/commit/eee81e62585605794c6f77b99b5c82b0e3d03774))
## [1.25.0](https://github.com/MetaCubeX/metacubexd/compare/v1.24.0...v1.25.0) (2023-08-31)
### Features
- update secret when changed ([#48](https://github.com/MetaCubeX/metacubexd/issues/48)) ([935908e](https://github.com/MetaCubeX/metacubexd/commit/935908e0c1f4885a89a27008d5d7f6cd5e52ff68))
## [1.24.0](https://github.com/MetaCubeX/metacubexd/compare/v1.23.0...v1.24.0) (2023-08-31)
### Features
- dayjs ([b4fc1b4](https://github.com/MetaCubeX/metacubexd/commit/b4fc1b4c983cebe7a52dd08f4b2b49dd6eb6a17d))
- health check for provider ([03dda3c](https://github.com/MetaCubeX/metacubexd/commit/03dda3ccad684a326022022c7c83155ee9d32410))
## [1.23.0](https://github.com/MetaCubeX/metacubexd/compare/v1.22.0...v1.23.0) (2023-08-31)
### Features
- config port && hide id in table by default ([e0f5c46](https://github.com/MetaCubeX/metacubexd/commit/e0f5c46a2eb1876b7d412b773139c752414888e4))
- update provider && rule pages style ([c0fee9d](https://github.com/MetaCubeX/metacubexd/commit/c0fee9d9298f807fb6380e72d49166609e9ac262))
## [1.22.0](https://github.com/MetaCubeX/metacubexd/compare/v1.21.0...v1.22.0) (2023-08-30)
### Features
- hide drawer on menu click ([7ce001c](https://github.com/MetaCubeX/metacubexd/commit/7ce001caa11c7aa5c177957777708316e731231d))
## [1.21.0](https://github.com/MetaCubeX/metacubexd/compare/v1.20.0...v1.21.0) (2023-08-30)
### Features
- hide scrollbar, update overlay menu ([9919cca](https://github.com/MetaCubeX/metacubexd/commit/9919cca00e4315d0efd4dc0e8dcba9287de79f9b))
- update header ([7a32ec4](https://github.com/MetaCubeX/metacubexd/commit/7a32ec4fe37fca26642e44402ef4309453c252c4))
- update proxy layout ([b60c855](https://github.com/MetaCubeX/metacubexd/commit/b60c85566cbe4704dffe065a9e41c9c5c83c9365))
## [1.20.0](https://github.com/MetaCubeX/metacubexd/compare/v1.19.0...v1.20.0) (2023-08-30)
### Features
- render protocl and udp ([44df379](https://github.com/MetaCubeX/metacubexd/commit/44df379fd6a18ef7d855dac91897480421e494e5))
- truncate proxy node name ([6db7ced](https://github.com/MetaCubeX/metacubexd/commit/6db7ceddac226e81e4009d4e333f73bac985bd67))
- update proxy layout ([83b0de8](https://github.com/MetaCubeX/metacubexd/commit/83b0de831a6d6eedc1c2b8ad74de1416752dd835))
### Bug Fixes
- card protocol text style ([4a03662](https://github.com/MetaCubeX/metacubexd/commit/4a03662bbd3a64a481bca96f0c81c78efd8bab7e))
## [1.19.0](https://github.com/MetaCubeX/metacubexd/compare/v1.18.0...v1.19.0) (2023-08-30)
### Features
- proxy page style ([#47](https://github.com/MetaCubeX/metacubexd/issues/47)) ([51f7121](https://github.com/MetaCubeX/metacubexd/commit/51f712157fac4d727d7dedbb6f67428f05515e06))
## [1.18.0](https://github.com/MetaCubeX/metacubexd/compare/v1.17.0...v1.18.0) (2023-08-30)
### Features
- **connections:** reverse chains ([43edb25](https://github.com/MetaCubeX/metacubexd/commit/43edb25c10e7ead51591b62ffe8e128275803c16))
- prevent duplicate endpoints from being stored ([#45](https://github.com/MetaCubeX/metacubexd/issues/45)) ([e966288](https://github.com/MetaCubeX/metacubexd/commit/e96628845b2efc32d978f273c9e245a4e83c2246))
## [1.17.0](https://github.com/MetaCubeX/metacubexd/compare/v1.16.0...v1.17.0) (2023-08-30)
### Features
- redirect illegal path to overview ([#41](https://github.com/MetaCubeX/metacubexd/issues/41)) ([a114b35](https://github.com/MetaCubeX/metacubexd/commit/a114b35c1abcc5eb3b53c497ab689f9eb138deb7))
## [1.16.0](https://github.com/MetaCubeX/metacubexd/compare/v1.15.0...v1.16.0) (2023-08-30)
### Features
- route to override immediately after checking ([#39](https://github.com/MetaCubeX/metacubexd/issues/39)) ([b178161](https://github.com/MetaCubeX/metacubexd/commit/b178161dbd108f4ae9ed59352d0d0450113051f0))
### Bug Fixes
- proxies title height ([b64cfa3](https://github.com/MetaCubeX/metacubexd/commit/b64cfa3f9a774143ceab492c26c67215dad6b195))
## [1.15.0](https://github.com/MetaCubeX/metacubexd/compare/v1.14.2...v1.15.0) (2023-08-30)
### Features
- speed test ([#38](https://github.com/MetaCubeX/metacubexd/issues/38)) ([a57741b](https://github.com/MetaCubeX/metacubexd/commit/a57741baf6a2b435c5349fa64adcec7210910a78))
### Bug Fixes
- support sing-box ([#36](https://github.com/MetaCubeX/metacubexd/issues/36)) ([4eeb345](https://github.com/MetaCubeX/metacubexd/commit/4eeb3458ccf389dda2a6e883877355da55149b66))
## [1.14.2](https://github.com/MetaCubeX/metacubexd/compare/v1.14.1...v1.14.2) (2023-08-30)
### Bug Fixes
- drop linux/arm/v7 ([f03c6fd](https://github.com/MetaCubeX/metacubexd/commit/f03c6fdf4cb5ff0916165c75c66ba54abb68c2d8))
## [1.14.1](https://github.com/MetaCubeX/metacubexd/compare/v1.14.0...v1.14.1) (2023-08-30)
### Bug Fixes
- support child path to fix router issue ([1091234](https://github.com/MetaCubeX/metacubexd/commit/10912347032f02e87c6754bb109510e1873a429f))
## [1.14.0](https://github.com/MetaCubeX/metacubexd/compare/v1.13.2...v1.14.0) (2023-08-29)
### Features
- **docker:** multi-platform support ([c7a8af4](https://github.com/MetaCubeX/metacubexd/commit/c7a8af420f35b1681680df48a4713e0639b586a5))
### Bug Fixes
- **dependabot:** package-ecosystem field is not a valid value ([567ffa8](https://github.com/MetaCubeX/metacubexd/commit/567ffa889463183a6a849af8fc03dd6de06fe81e))
## [1.13.2](https://github.com/MetaCubeX/metacubexd/compare/v1.13.1...v1.13.2) (2023-08-29)
### Bug Fixes
- remove permissions definition ([154d780](https://github.com/MetaCubeX/metacubexd/commit/154d7809f65f1b9794c4586219b4566161573538))
## [1.13.1](https://github.com/MetaCubeX/metacubexd/compare/v1.13.0...v1.13.1) (2023-08-29)
### Bug Fixes
- repository name should be lowercase ([5d5b9f2](https://github.com/MetaCubeX/metacubexd/commit/5d5b9f290e8dbc236cd70ba476a5769c0fbda25e))
## [1.13.0](https://github.com/MetaCubeX/metacubexd/compare/v1.12.0...v1.13.0) (2023-08-29)
### Features
- publish docker image ([7d5b3cd](https://github.com/MetaCubeX/metacubexd/commit/7d5b3cddccbb3eba86af2367d163664d2c8e8ba4))
## [1.12.0](https://github.com/MetaCubeX/metacubexd/compare/v1.11.0...v1.12.0) (2023-08-29)
### Features
- **footer:** drop footer ([fc0fbf9](https://github.com/MetaCubeX/metacubexd/commit/fc0fbf9daef45054a3f5e13e64e472823a6fe072))
- **header:** responsive layout ([2d9daa9](https://github.com/MetaCubeX/metacubexd/commit/2d9daa96cc3bd0b947cd7b34dafcb56eca91cfbe))
## [1.11.0](https://github.com/MetaCubeX/metacubexd/compare/v1.10.0...v1.11.0) (2023-08-29)
### Features
- **connections:** fallback host column to destinationIP ([4e5f8d2](https://github.com/MetaCubeX/metacubexd/commit/4e5f8d2b3d6c6dc7519f8a12b1060ecf2bb32771))
- show downlaod and upload speed in per-second form ([49bf6c7](https://github.com/MetaCubeX/metacubexd/commit/49bf6c7e2d76c7de0bf9cb6f538a369460080628))
## [1.10.0](https://github.com/MetaCubeX/metacubexd/compare/v1.9.0...v1.10.0) (2023-08-29)
### Features
- calculate download speed and upload speed ([6a1c1ab](https://github.com/MetaCubeX/metacubexd/commit/6a1c1abcbceffbc91c70a18e689a778be51c951c))
- update connection columns ([7336948](https://github.com/MetaCubeX/metacubexd/commit/7336948ad3a5c422bd20b46e99ae056f8b4b53d9))
## [1.9.0](https://github.com/MetaCubeX/metacubexd/compare/v1.8.1...v1.9.0) (2023-08-29)
### Features
- proxy selectable ([#19](https://github.com/MetaCubeX/metacubexd/issues/19)) ([6b08020](https://github.com/MetaCubeX/metacubexd/commit/6b0802023d3eeb41a76e7a3ee1087c5321a19100))
## [1.8.1](https://github.com/MetaCubeX/metacubexd/compare/v1.8.0...v1.8.1) (2023-08-29)
### Bug Fixes
- charts data slice ([#17](https://github.com/MetaCubeX/metacubexd/issues/17)) ([35f8042](https://github.com/MetaCubeX/metacubexd/commit/35f80426ce083265ef469bf0d20b0ba219a37495))
## [1.8.0](https://github.com/MetaCubeX/metacubexd/compare/v1.7.0...v1.8.0) (2023-08-29)
### Features
- proxy point && delay ([#15](https://github.com/MetaCubeX/metacubexd/issues/15)) ([341cdd0](https://github.com/MetaCubeX/metacubexd/commit/341cdd0113e80c21c267fd3f7286e3a90a1cc414))
## [1.7.0](https://github.com/MetaCubeX/metacubexd/compare/v1.6.0...v1.7.0) (2023-08-28)
### Features
- add footer ([e4bfb3f](https://github.com/MetaCubeX/metacubexd/commit/e4bfb3f2a8e22ce2e32f4cc5a4493a40e81b6f5a))
- small size table ([9b56558](https://github.com/MetaCubeX/metacubexd/commit/9b56558f84a644fa38c87916c5761b380518c910))
## [1.6.0](https://github.com/MetaCubeX/metacubexd/compare/v1.5.0...v1.6.0) (2023-08-28)
### Features
- update header layout ([07c82e8](https://github.com/MetaCubeX/metacubexd/commit/07c82e87d83b8117ad603857723b995f37a058f6))
### Bug Fixes
- theme icon height ([#13](https://github.com/MetaCubeX/metacubexd/issues/13)) ([2889de2](https://github.com/MetaCubeX/metacubexd/commit/2889de205f1f9f862021fed96a808bb69b207f5d))
## [1.5.0](https://github.com/MetaCubeX/metacubexd/compare/v1.4.0...v1.5.0) (2023-08-28)
### Features
- rules page ([#10](https://github.com/MetaCubeX/metacubexd/issues/10)) ([457b498](https://github.com/MetaCubeX/metacubexd/commit/457b49827dd38b61e63fd73e90a3736c956b4157))
## [1.4.0](https://github.com/MetaCubeX/metacubexd/compare/v1.3.0...v1.4.0) (2023-08-27)
### Features
- **connections:** add sorting and close connection support ([45c9740](https://github.com/MetaCubeX/metacubexd/commit/45c9740d3bee08e8cf874c3d866b9c9c3249a26e))
## [1.3.0](https://github.com/MetaCubeX/metacubexd/compare/v1.2.0...v1.3.0) (2023-08-27)
### Features
- show vertical stats on small screen devices ([841161a](https://github.com/MetaCubeX/metacubexd/commit/841161a0da9f753bff3d9754b29fc0a653c97af9))
## [1.2.0](https://github.com/MetaCubeX/metacubexd/compare/v1.1.0...v1.2.0) (2023-08-27)
### Features
- update proxies page layout ([54e5e1f](https://github.com/MetaCubeX/metacubexd/commit/54e5e1fc2407eff607b6225a957a13258164584c))
## [1.1.0](https://github.com/MetaCubeX/metacubexd/compare/v1.0.3...v1.1.0) (2023-08-27)
### Features
- add bg color and text color for stats ([c273169](https://github.com/MetaCubeX/metacubexd/commit/c27316967b8685e600a3af9dd7698498f838bdda))
## [1.0.3](https://github.com/MetaCubeX/metacubexd/compare/v1.0.2...v1.0.3) (2023-08-27)
### Bug Fixes
- publish zip instead of assets ([f7ee4f7](https://github.com/MetaCubeX/metacubexd/commit/f7ee4f7faa362afa60487f90b2ddae051f85b535))
## [1.0.2](https://github.com/MetaCubeX/metacubexd/compare/v1.0.1...v1.0.2) (2023-08-27)
### Bug Fixes
- release artifacts ([3bab638](https://github.com/MetaCubeX/metacubexd/commit/3bab638f502efb6aedd1372feed829d95c3fd806))
## [1.0.1](https://github.com/MetaCubeX/metacubexd/compare/v1.0.0...v1.0.1) (2023-08-27)
### Bug Fixes
- publish issue ([a3897da](https://github.com/MetaCubeX/metacubexd/commit/a3897da0f631c12612ff8eb945cad5c9ab6eef24))
## 1.0.0 (2023-08-27)
### Features
- use hash router instead of browser ([#1](https://github.com/MetaCubeX/metacubexd/issues/1)) ([b9ab6a9](https://github.com/MetaCubeX/metacubexd/commit/b9ab6a924487a241719928ae00c3965efcbcc71b))
### Bug Fixes
- layout issue on mobile platform ([35a02e4](https://github.com/MetaCubeX/metacubexd/commit/35a02e4174194efd09a3d1ac98dfd1817828e0f7))
- setup failed when secret is required ([b660847](https://github.com/MetaCubeX/metacubexd/commit/b660847efee6e00461017244adaa63b1e7c2230a))
Caddyfile
New file
@@ -0,0 +1,7 @@
:80 {
  file_server
  root * .
  try_files {path} /index.html
}
Dockerfile
New file
@@ -0,0 +1,21 @@
FROM docker.io/node:alpine as builder
WORKDIR /build
COPY . .
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
RUN pnpm install
RUN pnpm build
FROM docker.io/caddy:alpine
EXPOSE 80
WORKDIR /srv
COPY --from=builder /build/dist/. .
COPY Caddyfile .
CMD ["caddy", "run"]
LICENSE
New file
@@ -0,0 +1,21 @@
MIT License Copyright (c) 2023 MetaCubeX
Permission is hereby granted, free of
charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice
(including the next paragraph) shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
docs/config.yaml
New file
@@ -0,0 +1,140 @@
mixed-port: 7890
geodata-mode: true
tcp-concurrent: true
allow-lan: true
bind-address: '*'
mode: rule
log-level: info
ipv6: true
external-controller: 0.0.0.0:9090
global-client-fingerprint: random
profile:
  store-selected: true
tun:
  enable: true
  device: meta
  stack: system
  auto-route: true
  auto-redir: true
sniffer:
  enable: true
  force-dns-mapping: true
  parse-pure-ip: true
  override-destination: true
  sniff:
    TLS:
    HTTP:
dns:
  enable: true
  listen: :53
  enhanced-mode: redir-host
  proxy-server-nameserver:
    - https://dns.alidns.com/dns-query
    - https://doh.pub/dns-query
rule-providers:
  reject:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/reject.txt'
    path: ./ruleset/reject.yaml
    interval: 86400
  icloud:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/icloud.txt'
    path: ./ruleset/icloud.yaml
    interval: 86400
  apple:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/apple.txt'
    path: ./ruleset/apple.yaml
    interval: 86400
  google:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/google.txt'
    path: ./ruleset/google.yaml
    interval: 86400
  proxy:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/proxy.txt'
    path: ./ruleset/proxy.yaml
    interval: 86400
  direct:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/direct.txt'
    path: ./ruleset/direct.yaml
    interval: 86400
  private:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/private.txt'
  gfw:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/gfw.txt'
    path: ./ruleset/gfw.yaml
    interval: 86400
  tld-not-cn:
    type: http
    behavior: domain
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/tld-not-cn.txt'
    path: ./ruleset/tld-not-cn.yaml
    interval: 86400
  telegramcidr:
    type: http
    behavior: ipcidr
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/telegramcidr.txt'
    path: ./ruleset/telegramcidr.yaml
    interval: 86400
  cncidr:
    type: http
    behavior: ipcidr
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/cncidr.txt'
    path: ./ruleset/cncidr.yaml
    interval: 86400
  lancidr:
    type: http
    behavior: ipcidr
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/lancidr.txt'
    path: ./ruleset/lancidr.yaml
    interval: 86400
  applications:
    type: http
    behavior: classical
    url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/applications.txt'
    path: ./ruleset/applications.yaml
    interval: 86400
rules:
  - RULE-SET,private,DIRECT
  - RULE-SET,reject,REJECT
  - RULE-SET,icloud,DIRECT
  - RULE-SET,apple,DIRECT
  - RULE-SET,direct,DIRECT
  - RULE-SET,lancidr,DIRECT
  - RULE-SET,cncidr,DIRECT
  - GEOIP,LAN,DIRECT
  - GEOIP,CN,DIRECT
  - MATCH,PROXY
docs/docker-compose.yml
New file
@@ -0,0 +1,20 @@
version: '3'
services:
  metacubexd:
    container_name: metacubexd
    image: ghcr.io/metacubex/metacubexd
    restart: always
    ports:
      - '80:80'
  # optional
  meta:
    container_name: meta
    image: docker.io/metacubex/clash-meta:Alpha
    restart: always
    network_mode: host
    cap_add:
      - NET_ADMIN
    volumes:
      - ./config.yaml:/root/.config/clash
docs/preview-connections.webp
Binary files differ
docs/preview-overview.webp
Binary files differ
index.html
New file
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport"
    content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
  <meta name="theme-color" content="#000000" />
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
  <link rel="apple-touch-icon" href="/pwa-192x192.png" />
  <title>metacubexd</title>
</head>
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <button id="btn_play" onclick="javascript:callCSharpMethod()">测试(From JS to C#)</button>
  <div id="root" style="display: contents"></div>
  <script>
    // 测试在Web中调用C#的方法
    function callCSharpMethod() {
      console.log("a test from web page js:")
      window.backendSharp.showAlertMsg("来自JS的调用");
    }
    // 测试C#调用JS的方法,只传递一个普通的字符串
    function displayMessage(message) {
      alert(message);
    }
  </script>
  <script src="/src/main.tsx" type="module"></script>
</body>
</html>
package.json
New file
@@ -0,0 +1,77 @@
{
  "name": "metacubexd",
  "version": "1.124.2",
  "description": "Clash.Meta Dashboard, The Official One, XD",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "build": "vite build",
    "dev": "vite --host 0.0.0.0",
    "format": "prettier -w .",
    "lint": "eslint --fix .",
    "prepare": "husky install",
    "pwa-assets-generator": "pwa-assets-generator",
    "serve": "vite preview"
  },
  "dependencies": {
    "@commitlint/config-conventional": "^17.7.0",
    "@felte/solid": "^1.2.11",
    "@felte/validator-zod": "^1.0.17",
    "@fontsource/fira-sans": "^5.0.15",
    "@solid-primitives/clipboard": "^1.5.7",
    "@solid-primitives/context": "^0.2.1",
    "@solid-primitives/event-listener": "^2.3.0",
    "@solid-primitives/i18n": "^2.0.0",
    "@solid-primitives/keyed": "^1.2.0",
    "@solid-primitives/media": "^2.2.5",
    "@solid-primitives/resize-observer": "^2.0.22",
    "@solid-primitives/storage": "^2.1.1",
    "@solid-primitives/timer": "^1.3.7",
    "@solid-primitives/websocket": "^1.1.0",
    "@solidjs/router": "^0.8.3",
    "@tabler/icons-solidjs": "^2.39.0",
    "@tanstack/match-sorter-utils": "^8.8.4",
    "@tanstack/solid-table": "^8.10.7",
    "@tanstack/solid-virtual": "3.0.0-beta.65",
    "@tanstack/virtual-core": "3.0.0-beta.65",
    "@thisbeyond/solid-dnd": "^0.7.4",
    "@types/byte-size": "^8.1.0",
    "@types/lodash": "^4.14.199",
    "@types/node": "^20.8.6",
    "@types/uuid": "^9.0.5",
    "@typescript-eslint/eslint-plugin": "^6.7.5",
    "@typescript-eslint/parser": "^6.7.5",
    "@vite-pwa/assets-generator": "^0.0.10",
    "apexcharts": "^3.43.0",
    "autoprefixer": "^10.4.16",
    "byte-size": "^8.1.1",
    "commitlint": "^17.7.2",
    "daisyui": "^3.9.2",
    "dayjs": "^1.11.10",
    "eslint": "^8.51.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.1",
    "husky": "^8.0.3",
    "is-ip": "^5.0.1",
    "ky": "^1.0.1",
    "lint-staged": "^14.0.1",
    "lodash": "^4.17.21",
    "match-sorter": "^6.3.1",
    "prettier": "^3.0.3",
    "prettier-plugin-organize-imports": "^3.2.3",
    "prettier-plugin-tailwindcss": "^0.5.6",
    "solid-apexcharts": "^0.3.2",
    "solid-js": "^1.8.1",
    "solid-toast": "^0.5.0",
    "sort-package-json": "^2.6.0",
    "tailwind-merge": "^1.14.0",
    "tailwindcss": "^3.3.3",
    "tailwindcss-safe-area": "^0.4.1",
    "typescript": "^5.2.2",
    "uuid": "^9.0.1",
    "vite": "^4.4.11",
    "vite-plugin-pwa": "^0.16.5",
    "vite-plugin-solid": "^2.7.1",
    "zod": "^3.22.4"
  }
}
pnpm-lock.yaml
New file
Diff too large
postcss.config.js
New file
@@ -0,0 +1,6 @@
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
public/apple-touch-icon-180x180.png
public/config.yml
New file
Diff too large
public/favicon.ico
public/favicon.svg
New file
@@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0.00 0.00 200.00 200.00">
    <g stroke-width="2.00" fill="none" stroke-linecap="butt">
        <path stroke="#babcbd" vector-effect="non-scaling-stroke" d="
  M 40.95 140.48
  Q 68.55 157.30 96.63 173.25
  C 102.24 176.44 107.25 175.99 112.94 172.72
  Q 137.94 158.32 162.95 143.97
  Q 165.51 142.50 167.13 140.70
  A 1.36 1.36 0.0 0 0 167.49 139.78
  C 167.62 114.80 168.11 94.50 166.94 76.07
  C 166.62 71.04 164.79 67.55 160.95 64.72"
        />
        <path stroke="#edefef" vector-effect="non-scaling-stroke" d="
  M 160.95 64.72
  C 158.30 59.86 148.11 55.99 143.18 53.08
  C 131.17 46.00 119.07 39.12 106.93 32.27
  C 102.78 29.92 100.95 27.45 95.80 30.54
  Q 86.26 36.27 72.74 43.77
  C 65.00 48.06 55.94 53.83 47.28 58.44
  C 36.47 64.19 37.03 68.23 37.02 79.49
  Q 36.99 107.48 37.00 135.54
  A 1.33 1.30 24.1 0 0 37.35 136.43
  L 40.95 140.48"
        />
        <path stroke="#cdcdcd" vector-effect="non-scaling-stroke" d="
  M 160.95 64.72
  C 162.26 67.10 163.25 68.73 163.25 71.58
  Q 163.26 102.51 163.09 133.43
  Q 163.07 137.05 160.83 138.33
  Q 133.44 154.04 106.07 169.70
  C 102.53 171.72 96.73 171.58 93.59 169.67
  C 81.84 162.53 70.33 156.39 57.98 148.94
  Q 49.77 143.99 40.95 140.48"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 155.05 66.47
  A 0.30 0.30 0.0 0 0 155.05 65.95
  L 100.11 34.53
  A 0.30 0.30 0.0 0 0 99.81 34.53
  L 44.98 65.96
  A 0.30 0.30 0.0 0 0 44.98 66.48
  L 99.82 97.91
  A 0.30 0.30 0.0 0 0 100.12 97.91
  L 155.05 66.47"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 97.83 102.22
  A 0.32 0.32 0.0 0 0 97.67 101.94
  L 42.48 70.37
  A 0.32 0.32 0.0 0 0 42.00 70.65
  L 42.00 133.51
  A 0.32 0.32 0.0 0 0 42.16 133.79
  L 97.04 165.13
  A 0.32 0.32 0.0 0 0 97.52 164.85
  L 97.83 102.22"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 102.35 101.92
  A 0.32 0.32 0.0 0 0 102.19 102.20
  L 102.47 164.86
  A 0.32 0.32 0.0 0 0 102.95 165.14
  L 157.78 133.85
  A 0.32 0.32 0.0 0 0 157.94 133.57
  L 157.72 70.85
  A 0.32 0.32 0.0 0 0 157.24 70.57
  L 102.35 101.92"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 101.2539 41.9921
  A 1.17 1.17 0.0 0 0 100.0859 40.8201
  L 100.0459 40.8200
  A 1.17 1.17 0.0 0 0 98.8739 41.9880
  L 98.7861 92.2679
  A 1.17 1.17 0.0 0 0 99.9541 93.4399
  L 99.9941 93.4400
  A 1.17 1.17 0.0 0 0 101.1661 92.2720
  L 101.2539 41.9921"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 93.8121 104.2576
  A 1.09 1.09 0.0 0 0 92.3238 103.8561
  L 48.3895 129.1194
  A 1.09 1.09 0.0 0 0 47.9879 130.6077
  L 48.0079 130.6424
  A 1.09 1.09 0.0 0 0 49.4962 131.0439
  L 93.4305 105.7806
  A 1.09 1.09 0.0 0 0 93.8321 104.2923
  L 93.8121 104.2576"
        />
        <path stroke="#848484" vector-effect="non-scaling-stroke" d="
  M 152.0194 130.6077
  A 1.09 1.09 0.0 0 0 151.6178 129.1194
  L 107.6488 103.8361
  A 1.09 1.09 0.0 0 0 106.1606 104.2377
  L 106.1406 104.2723
  A 1.09 1.09 0.0 0 0 106.5422 105.7606
  L 150.5112 131.0439
  A 1.09 1.09 0.0 0 0 151.9994 130.6423
  L 152.0194 130.6077"
        />
    </g>
    <path fill="#dadedf" d="
  M 0.00 0.00
  L 200.00 0.00
  L 200.00 200.00
  L 0.00 200.00
  L 0.00 0.00
  Z
  M 40.95 140.48
  Q 68.55 157.30 96.63 173.25
  C 102.24 176.44 107.25 175.99 112.94 172.72
  Q 137.94 158.32 162.95 143.97
  Q 165.51 142.50 167.13 140.70
  A 1.36 1.36 0.0 0 0 167.49 139.78
  C 167.62 114.80 168.11 94.50 166.94 76.07
  C 166.62 71.04 164.79 67.55 160.95 64.72
  C 158.30 59.86 148.11 55.99 143.18 53.08
  C 131.17 46.00 119.07 39.12 106.93 32.27
  C 102.78 29.92 100.95 27.45 95.80 30.54
  Q 86.26 36.27 72.74 43.77
  C 65.00 48.06 55.94 53.83 47.28 58.44
  C 36.47 64.19 37.03 68.23 37.02 79.49
  Q 36.99 107.48 37.00 135.54
  A 1.33 1.30 24.1 0 0 37.35 136.43
  L 40.95 140.48
  Z"
    />
    <path fill="#ffffff" d="
  M 160.95 64.72
  C 162.26 67.10 163.25 68.73 163.25 71.58
  Q 163.26 102.51 163.09 133.43
  Q 163.07 137.05 160.83 138.33
  Q 133.44 154.04 106.07 169.70
  C 102.53 171.72 96.73 171.58 93.59 169.67
  C 81.84 162.53 70.33 156.39 57.98 148.94
  Q 49.77 143.99 40.95 140.48
  L 37.35 136.43
  A 1.33 1.30 24.1 0 1 37.00 135.54
  Q 36.99 107.48 37.02 79.49
  C 37.03 68.23 36.47 64.19 47.28 58.44
  C 55.94 53.83 65.00 48.06 72.74 43.77
  Q 86.26 36.27 95.80 30.54
  C 100.95 27.45 102.78 29.92 106.93 32.27
  C 119.07 39.12 131.17 46.00 143.18 53.08
  C 148.11 55.99 158.30 59.86 160.95 64.72
  Z
  M 155.05 66.47
  A 0.30 0.30 0.0 0 0 155.05 65.95
  L 100.11 34.53
  A 0.30 0.30 0.0 0 0 99.81 34.53
  L 44.98 65.96
  A 0.30 0.30 0.0 0 0 44.98 66.48
  L 99.82 97.91
  A 0.30 0.30 0.0 0 0 100.12 97.91
  L 155.05 66.47
  Z
  M 97.83 102.22
  A 0.32 0.32 0.0 0 0 97.67 101.94
  L 42.48 70.37
  A 0.32 0.32 0.0 0 0 42.00 70.65
  L 42.00 133.51
  A 0.32 0.32 0.0 0 0 42.16 133.79
  L 97.04 165.13
  A 0.32 0.32 0.0 0 0 97.52 164.85
  L 97.83 102.22
  Z
  M 102.35 101.92
  A 0.32 0.32 0.0 0 0 102.19 102.20
  L 102.47 164.86
  A 0.32 0.32 0.0 0 0 102.95 165.14
  L 157.78 133.85
  A 0.32 0.32 0.0 0 0 157.94 133.57
  L 157.72 70.85
  A 0.32 0.32 0.0 0 0 157.24 70.57
  L 102.35 101.92
  Z"
    />
    <path fill="#080808" d="
  M 155.05 66.47
  L 100.12 97.91
  A 0.30 0.30 0.0 0 1 99.82 97.91
  L 44.98 66.48
  A 0.30 0.30 0.0 0 1 44.98 65.96
  L 99.81 34.53
  A 0.30 0.30 0.0 0 1 100.11 34.53
  L 155.05 65.95
  A 0.30 0.30 0.0 0 1 155.05 66.47
  Z
  M 101.2539 41.9921
  A 1.17 1.17 0.0 0 0 100.0859 40.8201
  L 100.0459 40.8200
  A 1.17 1.17 0.0 0 0 98.8739 41.9880
  L 98.7861 92.2679
  A 1.17 1.17 0.0 0 0 99.9541 93.4399
  L 99.9941 93.4400
  A 1.17 1.17 0.0 0 0 101.1661 92.2720
  L 101.2539 41.9921
  Z"
    />
    <rect fill="#ffffff" x="-1.19" y="-26.31" transform="translate(100.02,67.13) rotate(0.1)" width="2.38"
          height="52.62" rx="1.17"/>
    <path fill="#9a9a9b" d="
  M 160.95 64.72
  C 164.79 67.55 166.62 71.04 166.94 76.07
  C 168.11 94.50 167.62 114.80 167.49 139.78
  A 1.36 1.36 0.0 0 1 167.13 140.70
  Q 165.51 142.50 162.95 143.97
  Q 137.94 158.32 112.94 172.72
  C 107.25 175.99 102.24 176.44 96.63 173.25
  Q 68.55 157.30 40.95 140.48
  Q 49.77 143.99 57.98 148.94
  C 70.33 156.39 81.84 162.53 93.59 169.67
  C 96.73 171.58 102.53 171.72 106.07 169.70
  Q 133.44 154.04 160.83 138.33
  Q 163.07 137.05 163.09 133.43
  Q 163.26 102.51 163.25 71.58
  C 163.25 68.73 162.26 67.10 160.95 64.72
  Z"
    />
    <path fill="#080808" d="
  M 97.83 102.22
  L 97.52 164.85
  A 0.32 0.32 0.0 0 1 97.04 165.13
  L 42.16 133.79
  A 0.32 0.32 0.0 0 1 42.00 133.51
  L 42.00 70.65
  A 0.32 0.32 0.0 0 1 42.48 70.37
  L 97.67 101.94
  A 0.32 0.32 0.0 0 1 97.83 102.22
  Z
  M 93.8121 104.2576
  A 1.09 1.09 0.0 0 0 92.3238 103.8561
  L 48.3895 129.1194
  A 1.09 1.09 0.0 0 0 47.9879 130.6077
  L 48.0079 130.6424
  A 1.09 1.09 0.0 0 0 49.4962 131.0439
  L 93.4305 105.7806
  A 1.09 1.09 0.0 0 0 93.8321 104.2923
  L 93.8121 104.2576
  Z"
    />
    <path fill="#080808" d="
  M 102.35 101.92
  L 157.24 70.57
  A 0.32 0.32 0.0 0 1 157.72 70.85
  L 157.94 133.57
  A 0.32 0.32 0.0 0 1 157.78 133.85
  L 102.95 165.14
  A 0.32 0.32 0.0 0 1 102.47 164.86
  L 102.19 102.20
  A 0.32 0.32 0.0 0 1 102.35 101.92
  Z
  M 152.0194 130.6077
  A 1.09 1.09 0.0 0 0 151.6178 129.1194
  L 107.6488 103.8361
  A 1.09 1.09 0.0 0 0 106.1606 104.2377
  L 106.1406 104.2723
  A 1.09 1.09 0.0 0 0 106.5422 105.7606
  L 150.5112 131.0439
  A 1.09 1.09 0.0 0 0 151.9994 130.6423
  L 152.0194 130.6077
  Z"
    />
    <rect fill="#ffffff" x="-26.43" y="-1.11" transform="translate(70.91,117.45) rotate(-29.9)" width="52.86"
          height="2.22" rx="1.09"/>
    <rect fill="#ffffff" x="-26.45" y="-1.11" transform="translate(129.08,117.44) rotate(29.9)" width="52.90"
          height="2.22" rx="1.09"/>
</svg>
public/maskable-icon-512x512.png
public/pwa-192x192.png
public/pwa-512x512.png
public/pwa-64x64.png
pwa-assets.config.ts
New file
@@ -0,0 +1,9 @@
import {
  defineConfig,
  minimalPreset as preset,
} from '@vite-pwa/assets-generator/config'
export default defineConfig({
  preset,
  images: ['public/favicon.svg'],
})
src/App.tsx
New file
@@ -0,0 +1,81 @@
import { usePrefersDark } from '@solid-primitives/media'
import { Navigate, Route, Routes } from '@solidjs/router'
import { Show, createEffect, lazy } from 'solid-js'
import { Toaster } from 'solid-toast'
import { twMerge } from 'tailwind-merge'
import { Header } from '~/components'
import { ROUTES } from '~/constants'
import { I18nProvider, locale } from '~/i18n'
import {
  WsMsg,
  autoSwitchTheme,
  curTheme,
  endpoint,
  favDayTheme,
  favNightTheme,
  setCurTheme,
  setLatestConnectionMsg,
  useTwemoji,
  useWsRequest,
} from '~/signals'
const Setup = lazy(() => import('~/pages/Setup'))
const Overview = lazy(() => import('~/pages/Overview'))
const Connections = lazy(() => import('~/pages/Connections'))
const Logs = lazy(() => import('~/pages/Logs'))
const Proxies = lazy(() => import('~/pages/Proxies'))
const Rules = lazy(() => import('~/pages/Rules'))
const Config = lazy(() => import('~/pages/Config'))
const ProtectedResources = () => {
  const latestConnectionMsg = useWsRequest<WsMsg>('connections')
  createEffect(() => setLatestConnectionMsg(latestConnectionMsg()))
  return null
}
export const App = () => {
  const prefersDark = usePrefersDark()
  createEffect(() => {
    if (autoSwitchTheme())
      setCurTheme(prefersDark() ? favNightTheme() : favDayTheme())
  })
  return (
    <I18nProvider locale={locale()}>
      <div
        class={twMerge(
          'relative flex h-screen flex-col overscroll-y-none subpixel-antialiased p-safe',
          useTwemoji() ? 'font-twemoji' : 'font-no-twemoji',
        )}
        data-theme={curTheme()}
      >
        <Header />
        <div class="flex-1 overflow-y-auto p-2 sm:p-4">
          <Routes>
            <Show when={endpoint()}>
              <Route path={ROUTES.Overview} component={Overview} />
              <Route path={ROUTES.Proxies} component={Proxies} />
              <Route path={ROUTES.Rules} component={Rules} />
              <Route path={ROUTES.Conns} component={Connections} />
              <Route path={ROUTES.Log} component={Logs} />
              <Route path={ROUTES.Config} component={Config} />
              <Route path="*" element={<Navigate href={ROUTES.Overview} />} />
            </Show>
            <Route path={endpoint() ? ROUTES.Setup : '*'} component={Setup} />
          </Routes>
          <Show when={endpoint()}>
            <ProtectedResources />
          </Show>
        </div>
        <Toaster position="bottom-center" />
      </div>
    </I18nProvider>
  )
}
src/apis/index.ts
New file
@@ -0,0 +1,253 @@
import ky from 'ky'
import { ResourceActions, createSignal } from 'solid-js'
import { toast } from 'solid-toast'
import { useRequest } from '~/signals'
import {
  BackendVersion,
  Config,
  Proxy,
  ProxyProvider,
  Rule,
  RuleProvider,
} from '~/types'
export const closeAllConnectionsAPI = () => {
  const request = useRequest()
  return request.delete('connections')
}
export const closeSingleConnectionAPI = (id: string) => {
  const request = useRequest()
  return request.delete(`connections/${id}`)
}
export const [reloadingConfigFile, setReloadingConfigFile] = createSignal(false)
export const [updatingGEODatabases, setUpdatingGEODatabases] =
  createSignal(false)
export const [flushingFakeIPData, setFlushingFakeIPData] = createSignal(false)
export const [upgradingBackend, setUpgradingBackend] = createSignal(false)
export const [restartingBackend, setRestartingBackend] = createSignal(false)
export const reloadConfigFileAPI = async () => {
  const request = useRequest()
  setReloadingConfigFile(true)
  try {
    await request.put('configs', {
      searchParams: { force: true },
      json: { path: '', payload: '' },
    })
  } catch {}
  setReloadingConfigFile(false)
}
export const flushFakeIPDataAPI = async () => {
  const request = useRequest()
  setFlushingFakeIPData(true)
  try {
    await request.post('cache/fakeip/flush')
  } catch {}
  setFlushingFakeIPData(false)
}
export const updateGEODatabasesAPI = async () => {
  const request = useRequest()
  setUpdatingGEODatabases(true)
  try {
    await request.post('configs/geo')
  } catch {}
  setUpdatingGEODatabases(false)
}
export const upgradeBackendAPI = async () => {
  const request = useRequest()
  setUpgradingBackend(true)
  try {
    await request.post('upgrade')
  } catch {}
  setUpgradingBackend(false)
}
export const restartBackendAPI = async () => {
  const request = useRequest()
  setRestartingBackend(true)
  try {
    await request.post('restart')
  } catch {}
  setRestartingBackend(false)
}
export const fetchBackendConfigAPI = () => {
  const request = useRequest()
  return request.get('configs').json<Config>()
}
export const updateBackendConfigAPI = async (
  key: keyof Config,
  value: Partial<Config[keyof Config]>,
  refetch: ResourceActions<Config | undefined>['refetch'],
) => {
  try {
    const request = useRequest()
    await request.patch('configs', { json: { [key]: value } }).json<Config>()
    await refetch()
  } catch (err) {
    toast.error((err as Error).message)
  }
}
export const fetchBackendVersionAPI = async () => {
  const request = useRequest()
  const { version } = await request.get('version').json<BackendVersion>()
  return version
}
export const fetchProxyProvidersAPI = () => {
  const request = useRequest()
  return request
    .get('providers/proxies')
    .json<{ providers: Record<string, ProxyProvider> }>()
}
export const fetchProxiesAPI = () => {
  const request = useRequest()
  return request.get('proxies').json<{ proxies: Record<string, Proxy> }>()
}
export const updateProxyProviderAPI = (providerName: string) => {
  const request = useRequest()
  return request.put(`providers/proxies/${providerName}`)
}
export const proxyProviderHealthCheckAPI = (providerName: string) => {
  const request = useRequest()
  return request
    .get(`providers/proxies/${providerName}/healthcheck`, {
      timeout: 5 * 1000,
    })
    .json<Record<string, number>>()
}
export const selectProxyInGroupAPI = (groupName: string, proxyName: string) => {
  const request = useRequest()
  return request.put(`proxies/${groupName}`, {
    body: JSON.stringify({
      name: proxyName,
    }),
  })
}
export const proxyLatencyTestAPI = (
  proxyName: string,
  provider: string,
  url: string,
  timeout: number,
) => {
  const request = useRequest()
  if (provider !== '') {
    return proxyProviderHealthCheckAPI(provider).then((latencyMap) => ({
      delay: latencyMap[proxyName],
    }))
  }
  return request
    .get(`proxies/${proxyName}/delay`, {
      searchParams: {
        url,
        timeout,
      },
    })
    .json<{ delay: number }>()
}
export const proxyGroupLatencyTestAPI = (
  groupName: string,
  url: string,
  timeout: number,
) => {
  const request = useRequest()
  return request
    .get(`group/${groupName}/delay`, {
      searchParams: {
        url,
        timeout,
      },
    })
    .json<Record<string, number>>()
}
export const fetchRulesAPI = () => {
  const request = useRequest()
  return request.get('rules').json<{ rules: Record<string, Rule> }>()
}
export const fetchRuleProvidersAPI = () => {
  const request = useRequest()
  return request
    .get('providers/rules')
    .json<{ providers: Record<string, RuleProvider> }>()
}
export const updateRuleProviderAPI = (providerName: string) => {
  const request = useRequest()
  return request.put(`providers/rules/${providerName}`)
}
export const isUpdateAvailableAPI = async (versionResponse: string) => {
  const match = /(alpha|beta|meta)-?(\w+)/.exec(versionResponse)
  if (!match) {
    return false
  }
  const channel = match[1],
    version = match[2]
  if (channel === 'meta') {
    const { assets } = await ky
      .get('https://api.github.com/repos/MetaCubeX/Clash.Meta/releases/latest')
      .json<{
        assets: {
          name: string
        }[]
      }>()
    const alreadyLatest = assets.some(({ name }) => name.includes(version))
    return !alreadyLatest
  }
  if (channel === 'alpha') {
    const { assets } = await ky
      .get(
        'https://api.github.com/repos/MetaCubeX/Clash.Meta/releases/tags/Prerelease-Alpha',
      )
      .json<{
        assets: {
          name: string
        }[]
      }>()
    const alreadyLatest = assets.some(({ name }) => name.includes(version))
    return !alreadyLatest
  }
  return false
}
src/assets/Twemoji.Mozilla.ttf
Binary files differ
src/components/Button.tsx
New file
@@ -0,0 +1,46 @@
import { JSX, ParentComponent, Show, splitProps } from 'solid-js'
import { twMerge } from 'tailwind-merge'
interface ButtonBaseProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
  loading?: boolean
  disabled?: boolean
}
interface ButtonWithIconProps extends ButtonBaseProps {
  icon: JSX.Element
  children?: JSX.Element
}
interface ButtonWithoutIconProps extends ButtonBaseProps {
  icon?: JSX.Element
  children: JSX.Element
}
export const Button: ParentComponent<
  ButtonWithIconProps | ButtonWithoutIconProps
> = (props) => {
  const [local, others] = splitProps(props, ['class', 'loading', 'icon'])
  return (
    <button
      class={twMerge(
        'btn flex items-center',
        local.loading ? 'btn-disabled' : local.class,
      )}
      {...others}
    >
      <Show when={local.loading}>
        <div class="loading loading-spinner" />
      </Show>
      <span
        class="truncate rounded-none"
        classList={{
          'flex-1': !local.icon,
        }}
      >
        {props.icon || props.children}
      </span>
    </button>
  )
}
src/components/Collapse.tsx
New file
@@ -0,0 +1,51 @@
import { children, JSX, ParentComponent, Show } from 'solid-js'
import { twMerge } from 'tailwind-merge'
type Props = {
  title: JSX.Element
  isOpen?: boolean
  onCollapse: (collapsed: boolean) => void
}
export const Collapse: ParentComponent<Props> = (props) => {
  const { title, onCollapse } = props
  const getCollapseClassName = () => {
    const openedClassName = 'collapse-open'
    const closedClassName = 'collapse-close'
    return props.isOpen ? openedClassName : closedClassName
  }
  const getCollapseContentClassName = () => {
    const openedClassName = 'opacity-100'
    const closedClassName = 'opacity-0'
    return props.isOpen ? openedClassName : closedClassName
  }
  return (
    <div
      class={twMerge(
        getCollapseClassName(),
        'collapse collapse-arrow select-none border-secondary bg-base-200 shadow-md',
      )}
    >
      <div
        class="collapse-title pr-4 text-xl font-medium after:!top-8"
        onClick={() => onCollapse(!props.isOpen)}
      >
        {title}
      </div>
      <div
        class={twMerge(
          getCollapseContentClassName(),
          'collapse-content grid grid-cols-2 gap-2 transition-opacity duration-1000 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
        )}
      >
        <Show when={props.isOpen}>{children(() => props.children)()}</Show>
      </div>
    </div>
  )
}
src/components/ConfigTitle.tsx
New file
@@ -0,0 +1,14 @@
import { children, ParentComponent } from 'solid-js'
export const ConfigTitle: ParentComponent<{ withDivider?: boolean }> = (
  props,
) => (
  <div
    class="py-2 text-center text-lg font-semibold"
    classList={{
      divider: props.withDivider,
    }}
  >
    {children(() => props.children)()}
  </div>
)
src/components/ConnectionsSettingsModal.tsx
New file
@@ -0,0 +1,271 @@
import { createForm } from '@felte/solid'
import { validator } from '@felte/validator-zod'
import { IconNetwork, IconX } from '@tabler/icons-solidjs'
import type {
  DragEventHandler,
  Draggable,
  Droppable,
} from '@thisbeyond/solid-dnd'
import {
  DragDropProvider,
  DragDropSensors,
  DragOverlay,
  SortableProvider,
  closestCenter,
  createSortable,
  useDragDropContext,
} from '@thisbeyond/solid-dnd'
import { uniq } from 'lodash'
import { Component, For, Index, Show, createSignal } from 'solid-js'
import { z } from 'zod'
import { Button, ConfigTitle, Modal } from '~/components'
import {
  CONNECTIONS_TABLE_ACCESSOR_KEY,
  CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER,
  CONNECTIONS_TABLE_INITIAL_COLUMN_VISIBILITY,
  TAILWINDCSS_SIZE,
} from '~/constants'
import { useI18n } from '~/i18n'
import {
  allConnections,
  clientSourceIPTags,
  connectionsTableSize,
  setClientSourceIPTags,
  setConnectionsTableSize,
} from '~/signals'
import {
  ConnectionsTableColumnOrder,
  ConnectionsTableColumnVisibility,
} from '~/types'
const TagClientSourceIPWithNameForm: Component = () => {
  const schema = z.object({
    tagName: z.string().nonempty(),
    sourceIP: z.string().nonempty(),
  })
  const [t] = useI18n()
  const { form, reset } = createForm<z.infer<typeof schema>>({
    extend: validator({ schema }),
    onSubmit: ({ tagName, sourceIP }) => {
      setClientSourceIPTags((tags) => {
        if (
          tags.some(
            (tag) => tag.tagName === tagName || tag.sourceIP === sourceIP,
          )
        ) {
          return tags
        }
        return [...tags, { tagName, sourceIP }]
      })
      reset()
    },
  })
  return (
    <form use:form={form}>
      <div class="join flex">
        <select name="sourceIP" class="select join-item select-bordered">
          <option />
          <Index
            each={uniq(
              allConnections().map(({ metadata: { sourceIP } }) => sourceIP),
            )
              .sort()
              .filter(
                (sourceIP) =>
                  !clientSourceIPTags().some(
                    ({ sourceIP: tagSourceIP }) => tagSourceIP === sourceIP,
                  ),
              )}
          >
            {(sourceIP) => (
              <option class="badge" value={sourceIP()}>
                {sourceIP()}
              </option>
            )}
          </Index>
        </select>
        <input
          name="tagName"
          class="input join-item input-bordered min-w-0 flex-1"
          placeholder="name"
        />
        <Button type="submit" class="join-item">
          {t('tag')}
        </Button>
      </div>
    </form>
  )
}
export const ConnectionsSettingsModal = (props: {
  ref?: (el: HTMLDialogElement) => void
  order: ConnectionsTableColumnOrder
  visible: ConnectionsTableColumnVisibility
  onOrderChange: (value: ConnectionsTableColumnOrder) => void
  onVisibleChange: (value: ConnectionsTableColumnVisibility) => void
}) => {
  const [t] = useI18n()
  const [activeKey, setActiveKey] =
    createSignal<CONNECTIONS_TABLE_ACCESSOR_KEY | null>(null)
  const onDragStart = ({ draggable }: { draggable: Draggable }) =>
    setActiveKey(draggable.id as CONNECTIONS_TABLE_ACCESSOR_KEY)
  const onDragEnd = ({
    draggable,
    droppable,
  }: {
    draggable: Draggable
    droppable: Droppable
  }) => {
    if (draggable && droppable) {
      const currentItems = props.order
      const fromIndex = currentItems.indexOf(
        draggable.id as CONNECTIONS_TABLE_ACCESSOR_KEY,
      )
      const toIndex = currentItems.indexOf(
        droppable.id as CONNECTIONS_TABLE_ACCESSOR_KEY,
      )
      if (fromIndex !== toIndex) {
        const updatedItems = currentItems.slice()
        updatedItems.splice(toIndex, 0, ...updatedItems.splice(fromIndex, 1))
        props.onOrderChange(updatedItems)
      }
    }
  }
  const FormRow: Component<{
    key: CONNECTIONS_TABLE_ACCESSOR_KEY
  }> = ({ key }) => {
    const sortable = createSortable(key)
    const [state] = useDragDropContext()!
    return (
      <div
        use:sortable
        class="sortable"
        classList={{
          'opacity-25': sortable.isActiveDraggable,
          'transition-transform': !!state.active.draggable,
        }}
      >
        <div class="flex cursor-grab justify-between py-2">
          <span class="select-none">{t(key)}</span>
          <input
            type="checkbox"
            class="toggle"
            checked={props.visible[key]}
            onChange={(e) => {
              props.onVisibleChange({
                ...props.visible,
                [key]: e.target.checked,
              })
            }}
          />
        </div>
      </div>
    )
  }
  return (
    <Modal
      ref={(el) => props.ref?.(el)}
      icon={<IconNetwork size={24} />}
      title={t('connectionsSettings')}
      action={
        <Button
          class="btn-neutral btn-sm"
          onClick={() => {
            props.onOrderChange(CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER)
            props.onVisibleChange(CONNECTIONS_TABLE_INITIAL_COLUMN_VISIBILITY)
          }}
        >
          {t('reset')}
        </Button>
      }
    >
      <div class="flex flex-col gap-4">
        <div>
          <ConfigTitle withDivider>{t('tableSize')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={connectionsTableSize()}
            onChange={(e) =>
              setConnectionsTableSize(e.target.value as TAILWINDCSS_SIZE)
            }
          >
            <For each={Object.values(TAILWINDCSS_SIZE)}>
              {(value) => <option value={value}>{t(value)}</option>}
            </For>
          </select>
        </div>
        <div>
          <ConfigTitle withDivider>
            {t('tagClientSourceIPWithName')}
          </ConfigTitle>
          <div class="flex flex-col gap-4">
            <TagClientSourceIPWithNameForm />
            <div class="flex flex-col gap-2">
              <For each={clientSourceIPTags()}>
                {({ tagName, sourceIP }) => (
                  <div class="badge badge-primary w-full items-center justify-between gap-2 py-4">
                    <span class="truncate">
                      {tagName} ({sourceIP})
                    </span>
                    <Button
                      class="btn-circle btn-ghost btn-xs"
                      onClick={() =>
                        setClientSourceIPTags((tags) =>
                          tags.filter((tag) => tag.tagName !== tagName),
                        )
                      }
                      icon={<IconX size={12} />}
                    />
                  </div>
                )}
              </For>
            </div>
          </div>
        </div>
        <div>
          <ConfigTitle withDivider>{t('sort')}</ConfigTitle>
          <DragDropProvider
            onDragStart={onDragStart}
            onDragEnd={onDragEnd as DragEventHandler}
            collisionDetector={closestCenter}
          >
            <DragDropSensors />
            <SortableProvider ids={props.order}>
              <For each={props.order}>{(key) => <FormRow key={key} />}</For>
            </SortableProvider>
            <DragOverlay>
              <Show when={activeKey()}>
                <div>{t(activeKey()!)}</div>
              </Show>
            </DragOverlay>
          </DragDropProvider>
        </div>
      </div>
    </Modal>
  )
}
src/components/ConnectionsTableDetailsModal.tsx
New file
@@ -0,0 +1,34 @@
import { IconNetwork } from '@tabler/icons-solidjs'
import { Component, Show } from 'solid-js'
import { Modal } from '~/components'
import { useI18n } from '~/i18n'
import { allConnections } from '~/signals'
export const ConnectionsTableDetailsModal: Component<{
  ref?: (el: HTMLDialogElement) => void
  selectedConnectionID?: string
}> = (props) => {
  const [t] = useI18n()
  return (
    <Modal
      ref={(el) => props.ref?.(el)}
      icon={<IconNetwork size={24} />}
      title={t('connectionsDetails')}
    >
      <Show when={props.selectedConnectionID}>
        <pre>
          <code>
            {JSON.stringify(
              allConnections().find(
                ({ id }) => id === props.selectedConnectionID,
              ),
              null,
              2,
            )}
          </code>
        </pre>
      </Show>
    </Modal>
  )
}
src/components/Header.tsx
New file
@@ -0,0 +1,159 @@
import { A, useLocation } from '@solidjs/router'
import {
  IconFileStack,
  IconGlobe,
  IconHome,
  IconMenu,
  IconNetwork,
  IconPalette,
  IconRuler,
  IconSettings,
} from '@tabler/icons-solidjs'
import { For, ParentComponent, Show, createSignal } from 'solid-js'
import { LogoText } from '~/components'
import { ROUTES, themes } from '~/constants'
import { useI18n } from '~/i18n'
import { setCurTheme } from '~/signals'
const Nav: ParentComponent<{ href: string; tooltip: string }> = ({
  href,
  tooltip,
  children,
}) => (
  <li class="tooltip tooltip-bottom" data-tip={tooltip}>
    <A class="rounded-box" href={href}>
      {children}
    </A>
  </li>
)
const ThemeSwitcher = () => (
  <div class="drawer drawer-end w-auto sm:ml-auto">
    <input id="themes" type="checkbox" class="drawer-toggle" />
    <div class="drawer-content flex items-center">
      <label
        for="themes"
        class="btn btn-circle btn-primary drawer-button btn-sm"
      >
        <IconPalette />
      </label>
    </div>
    <div class="drawer-side">
      <label for="themes" class="drawer-overlay" />
      <ul class="menu rounded-l-box gap-2 bg-base-300 p-2">
        <For each={themes}>
          {(theme) => (
            <li
              data-theme={theme}
              class="btn btn-xs"
              onClick={() => setCurTheme(theme)}
            >
              {theme}
            </li>
          )}
        </For>
      </ul>
    </div>
  </div>
)
export const Header = () => {
  const [t] = useI18n()
  const navs = () => [
    {
      href: ROUTES.Overview,
      name: t('overview'),
      icon: <IconHome />,
    },
    {
      href: ROUTES.Proxies,
      name: t('proxies'),
      icon: <IconGlobe />,
    },
    {
      href: ROUTES.Rules,
      name: t('rules'),
      icon: <IconRuler />,
    },
    {
      href: ROUTES.Conns,
      name: t('connections'),
      icon: <IconNetwork />,
    },
    {
      href: ROUTES.Log,
      name: t('logs'),
      icon: <IconFileStack />,
    },
    {
      href: ROUTES.Config,
      name: t('config'),
      icon: <IconSettings />,
    },
  ]
  const location = useLocation()
  const [openedDrawer, setOpenedDrawer] = createSignal(false)
  return (
    <ul class="navbar sticky inset-x-0 top-0 z-50 flex w-auto items-center justify-center bg-base-300 px-4 shadow-lg">
      <div class="navbar-start gap-4">
        <div class="drawer w-auto lg:hidden">
          <input
            id="navs"
            type="checkbox"
            class="drawer-toggle"
            onChange={(e) => setOpenedDrawer(e.target.checked)}
            checked={openedDrawer()}
          />
          <div class="drawer-content flex items-center">
            <label for="navs" class="btn btn-circle drawer-button btn-sm">
              <IconMenu />
            </label>
          </div>
          <div class="drawer-side">
            <label for="navs" class="drawer-overlay" />
            <ul class="menu rounded-r-box min-h-full w-2/5 gap-2 bg-base-300 pt-20">
              <For each={navs()}>
                {({ href, name }) => (
                  <li onClick={() => setOpenedDrawer(false)}>
                    <A href={href}>{name}</A>
                  </li>
                )}
              </For>
            </ul>
          </div>
        </div>
        <LogoText />
      </div>
      <Show when={location.pathname !== ROUTES.Setup}>
        <div class="navbar-center hidden lg:flex">
          <ul class="menu menu-horizontal menu-lg gap-2 p-0">
            <For each={navs()}>
              {({ href, name, icon }) => (
                <Nav href={href} tooltip={name}>
                  {icon}
                </Nav>
              )}
            </For>
          </ul>
        </div>
      </Show>
      <div class="navbar-end">
        <div class="flex items-center gap-2">
          <ThemeSwitcher />
        </div>
      </div>
    </ul>
  )
}
src/components/Latency.tsx
New file
@@ -0,0 +1,37 @@
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
import { LATENCY_QUALITY_MAP_HTTP } from '~/constants'
import { useI18n } from '~/i18n'
import { latencyQualityMap, useProxies } from '~/signals'
export const Latency = (props: { name?: string }) => {
  const [t] = useI18n()
  const { latencyMap } = useProxies()
  const [textClassName, setTextClassName] = createSignal('')
  const latency = createMemo(() => {
    return latencyMap()[props.name!]
  })
  createEffect(() => {
    setTextClassName('text-success')
    if (latency() > latencyQualityMap().HIGH) {
      setTextClassName('text-error')
    } else if (latency() > latencyQualityMap().MEDIUM) {
      setTextClassName('text-warning')
    }
  })
  return (
    <Show
      when={
        typeof latency() === 'number' &&
        latency() !== LATENCY_QUALITY_MAP_HTTP.NOT_CONNECTED
      }
    >
      <span class={`whitespace-nowrap text-xs ${textClassName()}`}>
        {latency()}
        {t('ms')}
      </span>
    </Show>
  )
}
src/components/LogoText.tsx
New file
@@ -0,0 +1,22 @@
import { A } from '@solidjs/router'
import { endpoint } from '~/signals'
export const LogoText = () => (
  <div class="text-md flex items-center gap-1 whitespace-nowrap font-bold uppercase sm:text-xl">
    <A
      class="bg-gradient-to-br from-primary to-secondary bg-clip-text text-transparent"
      href={endpoint() ? '/' : '/setup'}
    >
      metacube
    </A>
    <span>(</span>
    <a
      class="text-primary transition-transform hover:rotate-90 hover:scale-125"
      href="https://github.com/metacubex/metacubexd"
      target="_blank"
    >
      xd
    </a>
    <span>)</span>
  </div>
)
src/components/LogsSettingsModal.tsx
New file
@@ -0,0 +1,85 @@
import { IconFileStack } from '@tabler/icons-solidjs'
import { Component, For } from 'solid-js'
import { ConfigTitle, Modal } from '~/components'
import {
  LOGS_TABLE_MAX_ROWS_LIST,
  LOG_LEVEL,
  TAILWINDCSS_SIZE,
} from '~/constants'
import { useI18n } from '~/i18n'
import {
  logLevel,
  logMaxRows,
  logsTableSize,
  setLogLevel,
  setLogMaxRows,
  setLogsTableSize,
} from '~/signals'
export const LogsSettingsModal: Component<{
  ref?: (el: HTMLDialogElement) => void
}> = (props) => {
  const [t] = useI18n()
  return (
    <Modal
      ref={(el) => props.ref?.(el)}
      icon={<IconFileStack size={24} />}
      title={t('logsSettings')}
    >
      <div class="flex flex-col gap-4">
        <div>
          <ConfigTitle withDivider>{t('tableSize')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={logsTableSize()}
            onChange={(e) =>
              setLogsTableSize(e.target.value as TAILWINDCSS_SIZE)
            }
          >
            <For each={Object.values(TAILWINDCSS_SIZE)}>
              {(value) => <option value={value}>{t(value)}</option>}
            </For>
          </select>
        </div>
        <div>
          <ConfigTitle withDivider>{t('logLevel')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={logLevel()}
            onChange={(e) => setLogLevel(e.target.value as LOG_LEVEL)}
          >
            <For
              each={[
                LOG_LEVEL.Info,
                LOG_LEVEL.Error,
                LOG_LEVEL.Warning,
                LOG_LEVEL.Debug,
                LOG_LEVEL.Silent,
              ]}
            >
              {(level) => <option value={level}>{t(level)}</option>}
            </For>
          </select>
        </div>
        <div>
          <ConfigTitle withDivider>{t('logMaxRows')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={logMaxRows()}
            onChange={(e) => setLogMaxRows(parseInt(e.target.value))}
          >
            <For each={LOGS_TABLE_MAX_ROWS_LIST}>
              {(rows) => <option value={rows}>{rows}</option>}
            </For>
          </select>
        </div>
      </div>
    </Modal>
  )
}
src/components/Modal.tsx
New file
@@ -0,0 +1,53 @@
import { IconX } from '@tabler/icons-solidjs'
import { JSX, ParentComponent, Show, children } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { Button } from '~/components'
type Props = {
  ref?: (el: HTMLDialogElement) => void
  icon?: JSX.Element
  title?: JSX.Element
  action?: JSX.Element
}
const actionClassName =
  'sticky bottom-0 z-50 flex items-center justify-end bg-base-100 bg-opacity-80 p-4 backdrop-blur'
export const Modal: ParentComponent<Props> = (props) => {
  let dialogRef: HTMLDialogElement | undefined
  return (
    <dialog
      ref={(el) => (dialogRef = el) && props.ref?.(el)}
      class="modal modal-bottom sm:modal-middle"
    >
      <div class="modal-box p-0" onContextMenu={(e) => e.preventDefault()}>
        <div class={twMerge(actionClassName, 'top-0 justify-between')}>
          <div class="flex items-center gap-4 text-xl font-bold">
            {props.icon}
            <span>{props.title}</span>
          </div>
          <Button
            class="btn-circle btn-sm"
            onClick={() => dialogRef?.close()}
            icon={<IconX size={20} />}
          />
        </div>
        <div class="p-4">{children(() => props.children)()}</div>
        <Show when={props.action}>
          <div class={actionClassName}>
            <div class="flex justify-end gap-2">{props.action}</div>
          </div>
        </Show>
      </div>
      <form method="dialog" class="modal-backdrop">
        <button />
      </form>
    </dialog>
  )
}
src/components/ProxiesSettingsModal.tsx
New file
@@ -0,0 +1,122 @@
import { IconGlobe } from '@tabler/icons-solidjs'
import { Component, For } from 'solid-js'
import { ConfigTitle, Modal } from '~/components'
import { PROXIES_ORDERING_TYPE, PROXIES_PREVIEW_TYPE } from '~/constants'
import { useI18n } from '~/i18n'
import {
  autoCloseConns,
  hideUnAvailableProxies,
  latencyTestTimeoutDuration,
  proxiesOrderingType,
  proxiesPreviewType,
  setAutoCloseConns,
  setHideUnAvailableProxies,
  setLatencyTestTimeoutDuration,
  setProxiesOrderingType,
  setProxiesPreviewType,
  setUrlForLatencyTest,
  urlForLatencyTest,
} from '~/signals'
export const ProxiesSettingsModal: Component<{
  ref?: (el: HTMLDialogElement) => void
}> = (props) => {
  const [t] = useI18n()
  return (
    <Modal
      ref={(el) => props.ref?.(el)}
      icon={<IconGlobe size={24} />}
      title={t('proxiesSettings')}
    >
      <div class="flex flex-col gap-4">
        <div>
          <ConfigTitle withDivider>{t('autoCloseConns')}</ConfigTitle>
          <div class="flex w-full justify-center">
            <input
              class="toggle"
              type="checkbox"
              checked={autoCloseConns()}
              onChange={(e) => setAutoCloseConns(e.target.checked)}
            />
          </div>
        </div>
        <div class="flex flex-col">
          <ConfigTitle withDivider>{t('urlForLatencyTest')}</ConfigTitle>
          <input
            class="input input-bordered w-full"
            value={urlForLatencyTest()}
            onChange={(e) => setUrlForLatencyTest(e.target.value)}
          />
        </div>
        <div>
          <ConfigTitle withDivider>
            {t('latencyTestTimeoutDuration')} ({t('ms')})
          </ConfigTitle>
          <input
            type="number"
            class="input input-bordered w-full"
            value={latencyTestTimeoutDuration()}
            onChange={(e) =>
              setLatencyTestTimeoutDuration(Number(e.target.value))
            }
          />
        </div>
        <div>
          <ConfigTitle withDivider>{t('proxiesSorting')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={proxiesOrderingType()}
            onChange={(e) =>
              setProxiesOrderingType(e.target.value as PROXIES_ORDERING_TYPE)
            }
          >
            <For each={Object.values(PROXIES_ORDERING_TYPE)}>
              {(value) => (
                <option class="flex items-center gap-2" value={value}>
                  {t(value)}
                </option>
              )}
            </For>
          </select>
        </div>
        <div>
          <ConfigTitle withDivider>{t('hideUnAvailableProxies')}</ConfigTitle>
          <div class="flex w-full justify-center">
            <input
              class="toggle"
              type="checkbox"
              checked={hideUnAvailableProxies()}
              onChange={(e) => setHideUnAvailableProxies(e.target.checked)}
            />
          </div>
        </div>
        <div>
          <ConfigTitle withDivider>{t('proxiesPreviewType')}</ConfigTitle>
          <select
            class="select select-bordered w-full"
            value={proxiesPreviewType()}
            onChange={(e) =>
              setProxiesPreviewType(e.target.value as PROXIES_PREVIEW_TYPE)
            }
          >
            <For each={Object.values(PROXIES_PREVIEW_TYPE)}>
              {(value) => <option value={value}>{t(value)}</option>}
            </For>
          </select>
        </div>
      </div>
    </Modal>
  )
}
src/components/ProxyNodeCard.tsx
New file
@@ -0,0 +1,76 @@
import { IconBrandSpeedtest } from '@tabler/icons-solidjs'
import { createMemo, Show } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { Button, Latency } from '~/components'
import { filterSpecialProxyType, formatProxyType } from '~/helpers'
import { useProxies } from '~/signals'
export const ProxyNodeCard = (props: {
  proxyName: string
  isSelected?: boolean
  onClick?: () => void
}) => {
  const { proxyLatencyTest, proxyLatencyTestingMap } = useProxies()
  const { proxyName, isSelected, onClick } = props
  const { proxyNodeMap } = useProxies()
  const proxyNode = createMemo(() => proxyNodeMap()[proxyName])
  const specialType = () =>
    filterSpecialProxyType(proxyNode()?.type)
      ? proxyNode()?.xudp
        ? 'xudp'
        : proxyNode()?.udp
        ? 'udp'
        : null
      : null
  return (
    <div
      class={twMerge(
        'card card-bordered tooltip-bottom flex flex-col gap-1 border-neutral-focus bg-neutral p-2 text-neutral-content shadow-lg',
        isSelected && 'border-primary bg-primary-content text-primary',
        onClick && 'cursor-pointer',
      )}
      onClick={onClick}
      title={proxyName}
    >
      <div class="flex items-center justify-between gap-2">
        <span class="truncate text-left text-sm">{proxyName}</span>
        <Button
          class="btn-circle btn-ghost h-auto min-h-0 w-auto"
          icon={
            <IconBrandSpeedtest
              size={20}
              class={twMerge(
                proxyLatencyTestingMap()[proxyName] &&
                  'animate-pulse text-success',
              )}
            />
          }
          onClick={(e) => {
            e.stopPropagation()
            void proxyLatencyTest(proxyName, proxyNode().provider)
          }}
        />
      </div>
      <div class="flex items-center justify-between gap-1">
        <div
          class={twMerge(
            'truncate text-xs text-slate-500',
            isSelected && 'text-primary',
          )}
        >
          {formatProxyType(proxyNode()?.type)}
          <Show when={specialType()}>{` :: ${specialType()}`}</Show>
        </div>
        <div class="text-xs">
          <Latency name={props.proxyName} />
        </div>
      </div>
    </div>
  )
}
src/components/ProxyNodePreview.tsx
New file
@@ -0,0 +1,51 @@
import { createMemo, Match, Show, Switch } from 'solid-js'
import { ProxyPreviewBar, ProxyPreviewDots } from '~/components'
import { PROXIES_PREVIEW_TYPE } from '~/constants'
import { proxiesPreviewType } from '~/signals'
export const ProxyNodePreview = (props: {
  proxyNameList: string[]
  now?: string
}) => {
  const off = () => proxiesPreviewType() === PROXIES_PREVIEW_TYPE.OFF
  const isSmallGroup = createMemo(() => props.proxyNameList.length <= 10)
  const isShowBar = createMemo(() => {
    const type = proxiesPreviewType()
    return (
      type === PROXIES_PREVIEW_TYPE.BAR ||
      (type === PROXIES_PREVIEW_TYPE.Auto && !isSmallGroup())
    )
  })
  const isShowDots = createMemo(() => {
    const type = proxiesPreviewType()
    return (
      type === PROXIES_PREVIEW_TYPE.DOTS ||
      (type === PROXIES_PREVIEW_TYPE.Auto && isSmallGroup())
    )
  })
  return (
    <Show when={!off()}>
      <Switch>
        <Match when={isShowBar()}>
          <ProxyPreviewBar
            proxyNameList={props.proxyNameList}
            now={props.now}
          />
        </Match>
        <Match when={isShowDots()}>
          <ProxyPreviewDots
            proxyNameList={props.proxyNameList}
            now={props.now}
          />
        </Match>
      </Switch>
    </Show>
  )
}
src/components/ProxyPreviewBar.tsx
New file
@@ -0,0 +1,75 @@
import { createMemo } from 'solid-js'
import { Latency } from '~/components'
import { latencyQualityMap, useProxies } from '~/signals'
export const ProxyPreviewBar = (props: {
  proxyNameList: string[]
  now?: string
}) => {
  const { latencyMap } = useProxies()
  const latencyList = createMemo(() =>
    props.proxyNameList.map((name) => latencyMap()[name]),
  )
  const all = createMemo(() => latencyList().length)
  const good = createMemo(
    () =>
      latencyList().filter(
        (latency) =>
          latency > latencyQualityMap().NOT_CONNECTED &&
          latency <= latencyQualityMap().MEDIUM,
      ).length,
  )
  const middle = createMemo(
    () =>
      latencyList().filter(
        (latency) =>
          latency > latencyQualityMap().MEDIUM &&
          latency <= latencyQualityMap().HIGH,
      ).length,
  )
  const slow = createMemo(
    () =>
      latencyList().filter((latency) => latency > latencyQualityMap().HIGH)
        .length,
  )
  const notConnected = createMemo(
    () =>
      latencyList().filter(
        (latency) => latency === latencyQualityMap().NOT_CONNECTED,
      ).length,
  )
  return (
    <div class="flex items-center gap-2 py-2">
      <div class="flex flex-1 overflow-hidden rounded-2xl">
        <div
          class="h-2 bg-success"
          style={{
            width: `${(good() * 100) / all()}%`, // cant use tw class, otherwise dynamic classname won't be generated
          }}
        />
        <div
          class="h-2 bg-warning"
          style={{
            width: `${(middle() * 100) / all()}%`,
          }}
        />
        <div
          class="h-2 bg-error"
          style={{
            width: `${(slow() * 100) / all()}%`,
          }}
        />
        <div
          class="h-2 bg-neutral"
          style={{
            width: `${(notConnected() * 100) / all()}%`,
          }}
        />
      </div>
      <Latency name={props.now} />
    </div>
  )
}
src/components/ProxyPreviewDots.tsx
New file
@@ -0,0 +1,72 @@
import { For } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { Latency } from '~/components'
import { latencyQualityMap, useProxies } from '~/signals'
const LatencyDots = (props: {
  name: string
  latency?: number
  selected: boolean
}) => {
  let dotClassName = props.selected
    ? 'bg-white border-4 border-success'
    : 'bg-success'
  if (
    typeof props.latency !== 'number' ||
    props.latency === latencyQualityMap().NOT_CONNECTED
  ) {
    dotClassName = props.selected
      ? 'bg-white border-4 border-neutral'
      : 'bg-neutral'
  } else if (props.latency > latencyQualityMap().HIGH) {
    dotClassName = props.selected
      ? 'bg-white border-4 border-error'
      : 'bg-error'
  } else if (props.latency > latencyQualityMap().MEDIUM) {
    dotClassName = props.selected
      ? 'bg-white border-4 border-warning'
      : 'bg-warning'
  }
  return (
    <div
      class={twMerge('h-4 w-4 rounded-full', dotClassName)}
      title={props.name}
    />
  )
}
export const ProxyPreviewDots = (props: {
  proxyNameList: string[]
  now?: string
}) => {
  const { latencyMap } = useProxies()
  return (
    <div class="flex items-center gap-2 py-2">
      <div class="flex flex-1 flex-wrap items-center gap-1">
        <For
          each={props.proxyNameList.map((name): [string, number] => [
            name,
            latencyMap()[name],
          ])}
        >
          {([name, latency]) => {
            const isSelected = props.now === name
            return (
              <LatencyDots
                name={name}
                latency={latency}
                selected={isSelected}
              />
            )
          }}
        </For>
      </div>
      <Latency name={props.now} />
    </div>
  )
}
src/components/RenderInTwoColumns.tsx
New file
@@ -0,0 +1,26 @@
import { createWindowSize } from '@solid-primitives/resize-observer'
import { ParentComponent, Show, children, createMemo } from 'solid-js'
export const RenderInTwoColumns: ParentComponent = (props) => {
  const resolvedChildren = children(() => props.children)
  const windowSize = createWindowSize()
  // 640 is sm size in default tailwindcss breakpoint
  const showTwoColumns = createMemo(() => windowSize.width >= 640)
  const leftColumns = createMemo(() =>
    resolvedChildren.toArray().filter((_, index) => index % 2 === 0),
  )
  const rightColumns = createMemo(() =>
    resolvedChildren.toArray().filter((_, index) => index % 2 === 1),
  )
  return (
    <div class="flex flex-col gap-2 sm:flex-row">
      <Show when={showTwoColumns()} fallback={props.children}>
        <div class="flex flex-1 flex-col gap-2">{leftColumns()}</div>
        <div class="flex flex-1 flex-col gap-2">{rightColumns()}</div>
      </Show>
    </div>
  )
}
src/components/SubscriptionInfo.tsx
New file
@@ -0,0 +1,63 @@
import byteSize from 'byte-size'
import dayjs from 'dayjs'
import { toFinite } from 'lodash'
import { useI18n } from '~/i18n'
import type { SubscriptionInfo as ISubscriptionInfo } from '~/types'
const getSubscriptionsInfo = (subscriptionInfo: ISubscriptionInfo) => {
  const { Download = 0, Upload = 0, Total = 0, Expire = 0 } = subscriptionInfo
  const total = byteSize(Total, { units: 'iec' })
  const used = byteSize(Download + Upload, {
    units: 'iec',
  })
  const percentage = toFinite((((Download + Upload) / Total) * 100).toFixed(2))
  const expirePrefix = () => {
    const [t] = useI18n()
    return t('expire')
  }
  const expireStr = () => {
    const [t] = useI18n()
    if (Expire === 0) {
      return t('noExpire')
    }
    return dayjs(Expire * 1000).format('YYYY-MM-DD')
  }
  return {
    total,
    used,
    percentage,
    expirePrefix,
    expireStr,
  }
}
export const SubscriptionInfo = (props: {
  subscriptionInfo?: ISubscriptionInfo
}) => {
  if (!props.subscriptionInfo) {
    return
  }
  const info = getSubscriptionsInfo(props.subscriptionInfo)
  return (
    <>
      <progress class="progress" value={info.percentage} max="100" />
      <div class="text-sm text-slate-500">
        {`${info.used}`} / {`${info.total}`} ( {info.percentage}% )
      </div>
      <div class="text-sm text-slate-500">
        {info.expirePrefix()}: {info.expireStr()}
      </div>
    </>
  )
}
src/components/index.ts
New file
@@ -0,0 +1,17 @@
export * from './Button'
export * from './Collapse'
export * from './ConfigTitle'
export * from './ConnectionsSettingsModal'
export * from './ConnectionsTableDetailsModal'
export * from './Header'
export * from './Latency'
export * from './LogoText'
export * from './LogsSettingsModal'
export * from './Modal'
export * from './ProxiesSettingsModal'
export * from './ProxyNodeCard'
export * from './ProxyNodePreview'
export * from './ProxyPreviewBar'
export * from './ProxyPreviewDots'
export * from './RenderInTwoColumns'
export * from './SubscriptionInfo'
src/constants/index.ts
New file
@@ -0,0 +1,165 @@
import { ApexOptions } from 'apexcharts'
import byteSize from 'byte-size'
export const themes = [
  'light',
  'dark',
  'cupcake',
  'bumblebee',
  'emerald',
  'corporate',
  'synthwave',
  'retro',
  'cyberpunk',
  'valentine',
  'halloween',
  'garden',
  'forest',
  'aqua',
  'lofi',
  'pastel',
  'fantasy',
  'wireframe',
  'black',
  'luxury',
  'dracula',
  'cmyk',
  'autumn',
  'business',
  'acid',
  'lemonade',
  'night',
  'coffee',
  'winter',
] as const
export enum ROUTES {
  Overview = '/overview',
  Proxies = '/proxies',
  Rules = '/rules',
  Conns = '/conns',
  Log = '/logs',
  Config = '/config',
  Setup = '/setup',
}
export const CHART_MAX_XAXIS = 10
export const DEFAULT_CHART_OPTIONS: ApexOptions = {
  title: { align: 'center', style: { color: 'gray', fontSize: '16px' } },
  chart: {
    toolbar: { show: false },
    zoom: { enabled: false },
    animations: { easing: 'linear' },
  },
  noData: { text: 'Loading...' },
  legend: {
    showForSingleSeries: true,
    fontSize: '16px',
    labels: { colors: 'gray' },
    itemMargin: { horizontal: 32 },
  },
  dataLabels: { enabled: false },
  grid: { yaxis: { lines: { show: false } } },
  stroke: { curve: 'smooth' },
  tooltip: { enabled: false },
  xaxis: {
    range: CHART_MAX_XAXIS,
    labels: { show: false },
    axisTicks: { show: false },
  },
  yaxis: {
    labels: {
      style: { colors: 'gray', fontSize: '13px' },
      formatter: (val) => byteSize(val).toString(),
    },
  },
}
export enum LATENCY_QUALITY_MAP_HTTP {
  NOT_CONNECTED = -1,
  MEDIUM = 200,
  HIGH = 500,
}
export enum LATENCY_QUALITY_MAP_HTTPS {
  NOT_CONNECTED = -1,
  MEDIUM = 800,
  HIGH = 1500,
}
export enum PROXIES_PREVIEW_TYPE {
  OFF = 'off',
  DOTS = 'dots',
  BAR = 'bar',
  Auto = 'auto',
}
export enum PROXIES_ORDERING_TYPE {
  NATURAL = 'orderNatural',
  LATENCY_ASC = 'orderLatency_asc',
  LATENCY_DESC = 'orderLatency_desc',
  NAME_ASC = 'orderName_asc',
  NAME_DESC = 'orderName_desc',
}
export enum LANG {
  EN = 'en-US',
  ZH = 'zh-CN',
}
export enum CONNECTIONS_TABLE_ACCESSOR_KEY {
  Details = 'details',
  Close = 'close',
  ID = 'ID',
  Type = 'type',
  Process = 'process',
  Host = 'host',
  SniffHost = 'sniffHost',
  Rule = 'rules',
  Chains = 'chains',
  DlSpeed = 'dlSpeed',
  ULSpeed = 'ulSpeed',
  Download = 'dl',
  Upload = 'ul',
  ConnectTime = 'connectTime',
  SourceIP = 'sourceIP',
  SourcePort = 'sourcePort',
  Destination = 'destination',
}
export const CONNECTIONS_TABLE_MAX_CLOSED_ROWS = 200
export const CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER = Object.values(
  CONNECTIONS_TABLE_ACCESSOR_KEY,
)
export const CONNECTIONS_TABLE_INITIAL_COLUMN_VISIBILITY = {
  ...Object.fromEntries(
    CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER.map((i) => [i, true]),
  ),
  [CONNECTIONS_TABLE_ACCESSOR_KEY.ID]: false,
}
export enum TAILWINDCSS_SIZE {
  XS = 'xs',
  SM = 'sm',
  MD = 'md',
  LG = 'lg',
}
export enum MODE_OPTIONS {
  Global = 'global',
  Rule = 'rule',
  Direct = 'direct',
}
export enum LOG_LEVEL {
  Info = 'info',
  Error = 'error',
  Warning = 'warning',
  Debug = 'debug',
  Silent = 'silent',
}
export const LOGS_TABLE_MAX_ROWS_LIST = [200, 300, 500, 800, 1000]
export const DEFAULT_LOGS_TABLE_MAX_ROWS = LOGS_TABLE_MAX_ROWS_LIST[0]
src/helpers/global.ts
New file
@@ -0,0 +1,31 @@
import { createSignal } from 'solid-js'
export const transformEndpointURL = (url: string) =>
  /^https?/.test(url) ? url : `${window.location.protocol}//${url}`
export const useStringBooleanMap = () => {
  const [map, setMap] = createSignal<Record<string, boolean>>({})
  const set = (name: string, value: boolean) => {
    setMap({
      ...map(),
      [name]: value,
    })
  }
  const setWithCallback = async (
    name: string,
    callback: () => Promise<void>,
  ) => {
    set(name, true)
    try {
      await callback()
    } catch {}
    set(name, false)
  }
  return {
    map,
    set,
    setWithCallback,
  }
}
src/helpers/index.ts
New file
@@ -0,0 +1,2 @@
export * from './global'
export * from './proxies'
src/helpers/proxies.ts
New file
@@ -0,0 +1,86 @@
import { PROXIES_ORDERING_TYPE } from '~/constants'
import { latencyQualityMap } from '~/signals'
export const formatProxyType = (type = '') => {
  const t = type.toLowerCase()
  if (t.includes('shadowsocks')) {
    return t.replace('shadowsocks', 'ss') // for both ss and ssr
  }
  if (t === 'hysteria') {
    return 'hy'
  }
  if (t === 'wireguard') {
    return 'wg'
  }
  return t
}
export const filterSpecialProxyType = (type = '') => {
  const t = type.toLowerCase()
  const conditions = [
    'selector',
    'direct',
    'reject',
    'urltest',
    'loadbalance',
    'fallback',
    'relay',
  ]
  return !conditions.includes(t)
}
export const sortProxiesByOrderingType = (
  proxyNames: string[],
  proxyLatencyMap: Record<string, number>,
  orderingType: PROXIES_ORDERING_TYPE,
) => {
  if (orderingType === PROXIES_ORDERING_TYPE.NATURAL) {
    return proxyNames
  }
  return proxyNames.sort((a, b) => {
    const prevLatency = proxyLatencyMap[a]
    const nextLatency = proxyLatencyMap[b]
    switch (orderingType) {
      case PROXIES_ORDERING_TYPE.LATENCY_ASC:
        if (prevLatency === latencyQualityMap().NOT_CONNECTED) return 1
        if (nextLatency === latencyQualityMap().NOT_CONNECTED) return -1
        return prevLatency - nextLatency
      case PROXIES_ORDERING_TYPE.LATENCY_DESC:
        if (prevLatency === latencyQualityMap().NOT_CONNECTED) return 1
        if (nextLatency === latencyQualityMap().NOT_CONNECTED) return -1
        return nextLatency - prevLatency
      case PROXIES_ORDERING_TYPE.NAME_ASC:
        return a.localeCompare(b)
      case PROXIES_ORDERING_TYPE.NAME_DESC:
        return b.localeCompare(a)
      default:
        return 0
    }
  })
}
export const filterProxiesByAvailability = (
  proxyNames: string[],
  proxyLatencyMap: Record<string, number>,
  enabled?: boolean,
) =>
  enabled
    ? proxyNames.filter(
        (name) => proxyLatencyMap[name] !== latencyQualityMap().NOT_CONNECTED,
      )
    : proxyNames
src/i18n/dict.ts
New file
@@ -0,0 +1,11 @@
import { Flatten } from '@solid-primitives/i18n'
import { LANG } from '~/constants'
import en from './en'
import zh from './zh'
export type Dict = Flatten<typeof en>
export default {
  [LANG.EN]: en,
  [LANG.ZH]: zh,
}
src/i18n/en.ts
New file
@@ -0,0 +1,111 @@
export default {
  add: 'Add',
  overview: 'Overview',
  proxies: 'Proxies',
  proxiesSettings: 'Proxies Settings',
  rules: 'Rules',
  connections: 'Connections',
  connectionsSettings: 'Connections Settings',
  connectionsDetails: 'Connections Details',
  logs: 'Logs',
  logsSettings: 'Logs Settings',
  config: 'Config',
  upload: 'Upload',
  download: 'Download',
  uploadTotal: 'Upload Total',
  downloadTotal: 'Download Total',
  activeConnections: 'Active Connections',
  memoryUsage: 'Memory Usage',
  traffic: 'Traffic',
  memory: 'Memory',
  down: 'Down',
  up: 'Up',
  proxyProviders: 'Proxy Providers',
  ruleProviders: 'Rule Providers',
  search: 'Search',
  ID: 'ID',
  type: 'Type',
  name: 'Name',
  process: 'Process',
  host: 'Host',
  sniffHost: 'Sniff Host',
  chains: 'Chains',
  connectTime: 'Time',
  dlSpeed: 'DL Speed',
  ulSpeed: 'UL Speed',
  dl: 'DL',
  ul: 'UL',
  sourceIP: 'Source IP',
  sourcePort: 'Source Port',
  destination: 'Destination',
  close: 'Close',
  reset: 'Reset',
  dnsQuery: 'DNS Query',
  dots: 'Dots',
  bar: 'Bar',
  auto: 'Auto',
  off: 'Off',
  proxiesPreviewType: 'Proxies preview type',
  urlForLatencyTest: 'URL for latency test',
  autoCloseConns: 'Automatically Close connections',
  useTwemoji: 'Use Twemoji Mozilla Font',
  autoSwitchTheme: 'Automatically switch theme',
  favDayTheme: 'Favorite light theme',
  favNightTheme: 'Favorite dark theme',
  renderInTwoColumns: 'Render in two columns',
  updateGEODatabases: 'Update GEO Databases',
  restartCore: 'Restart Core',
  upgradeCore: 'Upgrade Core',
  proxiesSorting: 'Proxies Sorting',
  orderNatural: 'Original order in config file',
  orderLatency_asc: 'By latency from low to high',
  orderLatency_desc: 'By latency from high to low',
  orderName_asc: 'By name alphabetically (A-Z)',
  orderName_desc: 'By name alphabetically (Z-A)',
  ms: 'ms',
  updated: 'Updated',
  tableSize: 'Table size',
  logLevel: 'Log Level',
  info: 'info',
  silent: 'silent',
  debug: 'debug',
  warning: 'warning',
  error: 'error',
  logMaxRows: 'Log Maximum Reserved Rows',
  xs: 'Extra small size',
  sm: 'Small size',
  md: 'Normal size',
  lg: 'Large size',
  switchEndpoint: 'Switch Endpoint',
  switchLanguage: 'Switch Language',
  latencyTestTimeoutDuration: 'Latency Test Timeout Duration',
  all: 'All',
  sequence: 'Sequence',
  payload: 'Payload',
  details: 'Details',
  endpointURL: 'Endpoint URL',
  secret: 'Secret',
  global: 'Global',
  rule: 'Rule',
  direct: 'Direct',
  active: 'Active',
  closed: 'Closed',
  sort: 'Sort',
  hideUnAvailableProxies: 'Hide UnAvailable Proxies',
  reloadConfig: 'Reload Config',
  flushFakeIP: 'Flush Fake-IP',
  tagClientSourceIPWithName: 'Tag Client Source IP With Name',
  tag: 'Tag',
  coreConfig: 'Core Config',
  xdConfig: 'XD Config',
  version: 'Version',
  expire: 'Expire',
  noExpire: 'Null',
  enableTunDevice: 'Enable TUN Device',
  tunModeStack: 'TUN Mode Stack',
  tunDeviceName: 'TUN Device Name',
  interfaceName: 'Interface Name',
  en: 'English',
  zh: 'Chinese',
  port: '{{ name }} Port',
}
src/i18n/index.tsx
New file
@@ -0,0 +1,27 @@
import { createContextProvider } from '@solid-primitives/context'
import * as i18n from '@solid-primitives/i18n'
import { makePersisted } from '@solid-primitives/storage'
import { createSignal } from 'solid-js'
import { LANG } from '~/constants'
import dict, { Dict } from './dict'
export const [locale, setLocale] = makePersisted(
  createSignal<LANG>(
    Reflect.has(dict, navigator.language)
      ? (navigator.language as LANG)
      : LANG.EN,
  ),
  {
    name: 'lang',
    storage: localStorage,
  },
)
export const [I18nProvider, useMaybeI18n] = createContextProvider<
  [i18n.Translator<Dict>],
  { locale: LANG }
>((props) => [
  i18n.translator(() => i18n.flatten(dict[props.locale]), i18n.resolveTemplate),
])
export const useI18n = () => useMaybeI18n()!
src/i18n/zh.ts
New file
@@ -0,0 +1,113 @@
import { Dict } from '~/i18n/dict'
export default {
  add: '添加',
  overview: '概览',
  proxies: '代理',
  proxiesSettings: '代理设置',
  rules: '规则',
  connections: '连接',
  connectionsSettings: '连接设置',
  connectionsDetails: '连接详情',
  logs: '日志',
  logsSettings: '日志设置',
  config: '配置',
  upload: '上传',
  download: '下载',
  uploadTotal: '上传总量',
  downloadTotal: '下载总量',
  activeConnections: '活动连接',
  memoryUsage: '内存使用情况',
  traffic: '流量',
  memory: '内存',
  down: '下载',
  up: '上传',
  proxyProviders: '代理提供者',
  ruleProviders: '规则提供者',
  search: '搜索',
  ID: 'ID',
  type: '类型',
  name: '名字',
  process: '进程',
  host: '主机',
  sniffHost: '嗅探域名',
  chains: '链路',
  connectTime: '连接时间',
  dlSpeed: '下载速度',
  ulSpeed: '上传速度',
  dl: '下载量',
  ul: '上传量',
  sourceIP: '源地址',
  sourcePort: '源端口',
  destination: '目标地址',
  close: '关闭',
  reset: '重置',
  dnsQuery: 'DNS 查询',
  dots: '点阵',
  bar: '条形',
  auto: '自适应',
  off: '关闭',
  proxiesPreviewType: '节点组预览样式',
  urlForLatencyTest: '测速链接',
  autoCloseConns: '自动断开连接',
  useTwemoji: '使用 Twemoji Mozilla 字体',
  autoSwitchTheme: '自动切换主题',
  favDayTheme: '浅色主题偏好',
  favNightTheme: '深色主题偏好',
  renderInTwoColumns: '双列渲染',
  updateGEODatabases: '更新 GEO 数据库',
  restartCore: '重启核心',
  upgradeCore: '更新核心',
  proxiesSorting: '节点排序',
  orderNatural: '原配置文件中的排序',
  orderLatency_asc: '按延迟从低到高',
  orderLatency_desc: '按延迟从高到低',
  orderName_asc: '按名称字母排序 (A-Z)',
  orderName_desc: '按名称字母排序 (Z-A)',
  ms: '毫秒',
  updated: '更新于',
  tableSize: '表格大小',
  logLevel: '日志等级',
  info: '信息',
  silent: '静默',
  debug: '调试',
  warning: '警告',
  error: '错误',
  logMaxRows: '日志最大保留行数',
  xs: '超小尺寸',
  sm: '小尺寸',
  md: '正常尺寸',
  lg: '超大尺寸',
  switchEndpoint: '切换后端',
  switchLanguage: '切换语言',
  latencyTestTimeoutDuration: '测速超时时间',
  all: '全部',
  sequence: '序列号',
  payload: '内容',
  details: '详情',
  endpointURL: '后端地址',
  secret: '密钥',
  global: '全局',
  rule: '规则',
  direct: '直连',
  active: '活动',
  closed: '已关闭',
  sort: '排序',
  hideUnAvailableProxies: '隐藏不可用节点',
  reloadConfig: '重载配置',
  flushFakeIP: '清空 Fake-IP',
  tagClientSourceIPWithName: '为客户端源 IP 地址添加名称标记',
  tag: '标记',
  coreConfig: '核心配置',
  xdConfig: 'XD 配置',
  version: '版本',
  expire: '到期时间',
  noExpire: '不限时',
  enableTunDevice: '开启 TUN 转发',
  tunModeStack: 'TUN 模式堆栈',
  tunDeviceName: 'TUN 设备名称',
  interfaceName: '接口名称',
  en: '英文',
  zh: '中文',
  port: '{{ name }} 端口',
} satisfies Dict
src/index.css
New file
@@ -0,0 +1,43 @@
@import '@fontsource/fira-sans';
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
@font-face {
  font-family: 'Twemoji Mozilla';
  src: url('assets/Twemoji.Mozilla.ttf') format('truetype');
}
:root {
  scrollbar-width: thin;
  scrollbar-color: transparent transparent;
}
::-webkit-scrollbar {
  background: transparent;
  display: none;
}
::-webkit-scrollbar:vertical {
  width: 6px;
}
::-webkit-scrollbar:horizontal {
  height: 6px;
}
@media (width >= 640px) {
  :root {
    scrollbar-color: hsl(var(--p)) transparent;
  }
  ::-webkit-scrollbar {
    display: block;
  }
  ::-webkit-scrollbar-thumb {
    @apply rounded-box bg-primary;
  }
}
src/main.tsx
New file
@@ -0,0 +1,20 @@
/* @refresh reload */
import '~/index.css'
import { Router, hashIntegration } from '@solidjs/router'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import { render } from 'solid-js/web'
import { App } from '~/App'
dayjs.extend(relativeTime)
render(
  () => (
    <Router source={hashIntegration()}>
      <App />
    </Router>
  ),
  document.getElementById('root')!,
)
src/pages/Config.tsx
New file
@@ -0,0 +1,510 @@
import { createForm } from '@felte/solid'
import { validator } from '@felte/validator-zod'
import { useNavigate } from '@solidjs/router'
import {
  For,
  Show,
  createEffect,
  createResource,
  createSignal,
  onMount,
} from 'solid-js'
import { toast } from 'solid-toast'
import { z } from 'zod'
import {
  fetchBackendConfigAPI,
  fetchBackendVersionAPI,
  flushFakeIPDataAPI,
  flushingFakeIPData,
  isUpdateAvailableAPI,
  reloadConfigFileAPI,
  reloadingConfigFile,
  restartBackendAPI,
  restartingBackend,
  updateBackendConfigAPI,
  updateGEODatabasesAPI,
  updatingGEODatabases,
  upgradeBackendAPI,
  upgradingBackend,
} from '~/apis'
import { Button, ConfigTitle } from '~/components'
import { LANG, MODE_OPTIONS, ROUTES, themes } from '~/constants'
import { locale, setLocale, useI18n } from '~/i18n'
import {
  autoSwitchTheme,
  endpoint,
  favDayTheme,
  favNightTheme,
  setAutoSwitchTheme,
  setFavDayTheme,
  setFavNightTheme,
  setSelectedEndpoint,
  setUseTwemoji,
  useRequest,
  useTwemoji,
} from '~/signals'
import type { DNSQuery } from '~/types'
const dnsQueryFormSchema = z.object({
  name: z.string(),
  type: z.string(),
})
const DNSQueryForm = () => {
  const [t] = useI18n()
  const request = useRequest()
  const { form, isSubmitting } = createForm<z.infer<typeof dnsQueryFormSchema>>(
    {
      extend: validator({ schema: dnsQueryFormSchema }),
      onSubmit: (values) =>
        request
          .get('dns/query', {
            searchParams: { name: values.name, type: values.type },
          })
          .json<DNSQuery>()
          .then(({ Answer }) =>
            setDNSQueryResult(Answer?.map(({ data }) => data) || []),
          )
          .catch((err) => toast.error(err.message)),
    },
  )
  const [DNSQueryResult, setDNSQueryResult] = createSignal<string[]>([])
  return (
    <div class="flex flex-col">
      <form use:form={form} class="flex gap-2 sm:flex-row">
        <input
          type="search"
          name="name"
          class="input input-bordered min-w-0 flex-1"
          placeholder="google.com"
        />
        <div class="flex items-center gap-2">
          <select name="type" class="select select-bordered">
            <option>A</option>
            <option>AAAA</option>
            <option>MX</option>
          </select>
          <Button type="submit" class="btn-primary" loading={isSubmitting()}>
            {t('dnsQuery')}
          </Button>
        </div>
      </form>
      <Show when={DNSQueryResult().length > 0}>
        <div class="flex flex-col p-4">
          <For each={DNSQueryResult()}>
            {(item) => <div class="py-2">{item}</div>}
          </For>
        </div>
      </Show>
    </div>
  )
}
const configFormSchema = z.object({
  port: z.number(),
  'socks-port': z.number(),
  'redir-port': z.number(),
  'tproxy-port': z.number(),
  'mixed-port': z.number(),
})
const ConfigForm = () => {
  const [t] = useI18n()
  const navigate = useNavigate()
  const portList = [
    {
      label: () => t('port', { name: 'HTTP' }),
      key: 'port',
      onChange: (e: Event & { target: HTMLInputElement }) =>
        void updateBackendConfigAPI('port', Number(e.target.value), refetch),
    },
    {
      label: () => t('port', { name: 'Socks' }),
      key: 'socks-port',
      onChange: (e: Event & { target: HTMLInputElement }) =>
        void updateBackendConfigAPI(
          'socks-port',
          Number(e.target.value),
          refetch,
        ),
    },
    {
      label: () => t('port', { name: 'Redir' }),
      key: 'redir-port',
      onChange: (e: Event & { target: HTMLInputElement }) =>
        void updateBackendConfigAPI(
          'redir-port',
          Number(e.target.value),
          refetch,
        ),
    },
    {
      label: () => t('port', { name: 'TProxy' }),
      key: 'tproxy-port',
      onChange: (e: Event & { target: HTMLInputElement }) =>
        void updateBackendConfigAPI(
          'tproxy-port',
          Number(e.target.value),
          refetch,
        ),
    },
    {
      label: () => t('port', { name: 'Mixed' }),
      key: 'mixed-port',
      onChange: (e: Event & { target: HTMLInputElement }) =>
        void updateBackendConfigAPI(
          'mixed-port',
          Number(e.target.value),
          refetch,
        ),
    },
  ]
  const { form, setInitialValues, reset } = createForm<
    z.infer<typeof configFormSchema>
  >({ extend: validator({ schema: configFormSchema }) })
  const [configsData, { refetch }] = createResource(fetchBackendConfigAPI)
  createEffect(() => {
    const configs = configsData()
    if (configs) {
      setInitialValues(configs)
      reset()
    }
  })
  return (
    <div class="flex flex-col gap-4">
      <select
        class="select select-bordered"
        value={configsData()?.mode}
        onChange={(e) =>
          void updateBackendConfigAPI('mode', e.target.value, refetch)
        }
      >
        <option value={MODE_OPTIONS.Global}>{t('global')}</option>
        <option value={MODE_OPTIONS.Rule}>{t('rule')}</option>
        <option value={MODE_OPTIONS.Direct}>{t('direct')}</option>
      </select>
      <form class="grid grid-cols-3 gap-2 sm:grid-cols-5" use:form={form}>
        <For each={portList}>
          {(item) => (
            <div class="form-control">
              <label for={item.key} class="label">
                <span class="label-text">{item.label()}</span>
              </label>
              <input
                id={item.key}
                name={item.key}
                type="number"
                class="input input-bordered"
                placeholder={item.label()}
                onChange={item.onChange}
              />
            </div>
          )}
        </For>
      </form>
      <div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
        <div class="form-control">
          <label for="enable-tun-device" class="label gap-2">
            <span class="label-text">{t('enableTunDevice')}</span>
          </label>
          <input
            id="enable-tun-device"
            type="checkbox"
            class="toggle"
            checked={configsData()?.tun.enable}
            onChange={(e) =>
              void updateBackendConfigAPI(
                'tun',
                { enable: e.target.checked },
                refetch,
              )
            }
          />
        </div>
        <div class="form-control">
          <label for="tun-ip-stack" class="label gap-2">
            <span class="label-text">{t('tunModeStack')}</span>
          </label>
          <select
            id="tun-ip-stack"
            class="select select-bordered flex-1"
            value={configsData()?.tun.stack}
            onChange={(e) =>
              void updateBackendConfigAPI(
                'tun',
                { stack: e.target.value },
                refetch,
              )
            }
          >
            <option>gVisor</option>
            <option>System</option>
            <option>LWIP</option>
          </select>
        </div>
        <div class="form-control">
          <label for="device-name" class="label gap-2">
            <span class="label-text">{t('tunDeviceName')}</span>
          </label>
          <input
            id="device-name"
            class="input input-bordered min-w-0"
            value={configsData()?.tun.device}
            onChange={(e) =>
              void updateBackendConfigAPI(
                'tun',
                { device: e.target.value },
                refetch,
              )
            }
          />
        </div>
        <div class="form-control">
          <label for="interface-name" class="label gap-2">
            <span class="label-text">{t('interfaceName')}</span>
          </label>
          <input
            id="interface-name"
            class="input input-bordered min-w-0"
            value={configsData()?.['interface-name']}
            onChange={(e) =>
              void updateBackendConfigAPI(
                'interface-name',
                e.target.value,
                refetch,
              )
            }
          />
        </div>
      </div>
      <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
        <Button
          class="btn-primary"
          loading={reloadingConfigFile()}
          onClick={reloadConfigFileAPI}
        >
          {t('reloadConfig')}
        </Button>
        <Button
          class="btn-secondary"
          loading={updatingGEODatabases()}
          onClick={updateGEODatabasesAPI}
        >
          {t('updateGEODatabases')}
        </Button>
        <Button
          class="btn-accent"
          loading={flushingFakeIPData()}
          onClick={flushFakeIPDataAPI}
        >
          {t('flushFakeIP')}
        </Button>
        <Button
          class="btn-error"
          loading={upgradingBackend()}
          onClick={upgradeBackendAPI}
        >
          {t('upgradeCore')}
        </Button>
        <Button
          class="btn-warning"
          loading={restartingBackend()}
          onClick={restartBackendAPI}
        >
          {t('restartCore')}
        </Button>
        <Button
          class="btn-info"
          onClick={() => {
            setSelectedEndpoint('')
            navigate(ROUTES.Setup)
          }}
        >
          {t('switchEndpoint')}
        </Button>
      </div>
    </div>
  )
}
const ConfigForXd = () => {
  const [t] = useI18n()
  const languages = [
    {
      label: () => t('en'),
      value: LANG.EN,
    },
    {
      label: () => t('zh'),
      value: LANG.ZH,
    },
  ]
  return (
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
      <div class="flex flex-col gap-2">
        <div class="flex flex-col items-center">
          <ConfigTitle>{t('autoSwitchTheme')}</ConfigTitle>
          <input
            type="checkbox"
            class="toggle"
            checked={autoSwitchTheme()}
            onChange={(e) => setAutoSwitchTheme(e.target.checked)}
          />
        </div>
        <Show when={autoSwitchTheme()}>
          <div class="flex flex-col gap-2">
            <div class="flex flex-col">
              <ConfigTitle>{t('favDayTheme')}</ConfigTitle>
              <select
                class="select select-bordered"
                value={favDayTheme()}
                onChange={(e) =>
                  setFavDayTheme(e.target.value as (typeof themes)[number])
                }
              >
                <For each={themes}>
                  {(theme) => <option value={theme}>{theme}</option>}
                </For>
              </select>
            </div>
            <div class="flex flex-col">
              <ConfigTitle>{t('favNightTheme')}</ConfigTitle>
              <select
                class="select select-bordered"
                value={favNightTheme()}
                onChange={(e) =>
                  setFavNightTheme(e.target.value as (typeof themes)[number])
                }
              >
                <For each={themes}>
                  {(theme) => <option value={theme}>{theme}</option>}
                </For>
              </select>
            </div>
          </div>
        </Show>
      </div>
      <div class="flex flex-col gap-2">
        <div class="flex flex-col items-center">
          <ConfigTitle>{t('useTwemoji')}</ConfigTitle>
          <input
            type="checkbox"
            class="toggle"
            checked={useTwemoji()}
            onChange={(e) => setUseTwemoji(e.target.checked)}
          />
        </div>
        <div class="flex flex-col">
          <ConfigTitle>{t('switchLanguage')}</ConfigTitle>
          <select
            class="select select-bordered"
            onChange={(e) => setLocale(e.target.value as LANG)}
          >
            <For each={languages}>
              {(lang) => (
                <option selected={locale() === lang.value} value={lang.value}>
                  {lang.label()}
                </option>
              )}
            </For>
          </select>
        </div>
      </div>
    </div>
  )
}
const Versions = () => {
  const [backendVersion, setBackendVersion] = createSignal('')
  const [isUpdateAvailable, setIsUpdateAvailable] = createSignal(false)
  onMount(async () => {
    const version = await fetchBackendVersionAPI()
    setBackendVersion(version)
    setIsUpdateAvailable(await isUpdateAvailableAPI(version))
  })
  return (
    <div class="flex flex-col gap-2">
      <div class="grid grid-cols-2 gap-4">
        <kbd class="kbd">{import.meta.env.version}</kbd>
        <div class="relative">
          <Show when={isUpdateAvailable()}>
            <span class="absolute -right-1 -top-1 flex h-3 w-3">
              <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-info opacity-75" />
              <span class="inline-flex h-3 w-3 rounded-full bg-info" />
            </span>
          </Show>
          <kbd class="kbd w-full">{backendVersion()}</kbd>
        </div>
      </div>
      <h1 class="text-center text-lg font-bold">{endpoint()?.url}</h1>
    </div>
  )
}
export default () => {
  const [t] = useI18n()
  return (
    <div class="mx-auto flex max-w-screen-md flex-col gap-4">
      <ConfigTitle withDivider>{t('dnsQuery')}</ConfigTitle>
      <DNSQueryForm />
      <ConfigTitle withDivider>{t('coreConfig')}</ConfigTitle>
      <ConfigForm />
      <ConfigTitle withDivider>{t('xdConfig')}</ConfigTitle>
      <ConfigForXd />
      <ConfigTitle withDivider>{t('version')}</ConfigTitle>
      <Versions />
    </div>
  )
}
src/pages/Connections.tsx
New file
@@ -0,0 +1,542 @@
import { writeClipboard } from '@solid-primitives/clipboard'
import { makePersisted } from '@solid-primitives/storage'
import {
  IconInfoSmall,
  IconPlayerPause,
  IconPlayerPlay,
  IconSettings,
  IconSortAscending,
  IconSortDescending,
  IconX,
  IconZoomInFilled,
  IconZoomOutFilled,
} from '@tabler/icons-solidjs'
import { rankItem } from '@tanstack/match-sorter-utils'
import {
  ColumnDef,
  FilterFn,
  GroupingState,
  SortingState,
  createSolidTable,
  flexRender,
  getCoreRowModel,
  getExpandedRowModel,
  getFilteredRowModel,
  getGroupedRowModel,
  getSortedRowModel,
} from '@tanstack/solid-table'
import byteSize from 'byte-size'
import dayjs from 'dayjs'
import { uniq } from 'lodash'
import {
  For,
  Index,
  createEffect,
  createMemo,
  createSignal,
  on,
} from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { closeAllConnectionsAPI, closeSingleConnectionAPI } from '~/apis'
import {
  Button,
  ConnectionsSettingsModal,
  ConnectionsTableDetailsModal,
} from '~/components'
import { CONNECTIONS_TABLE_ACCESSOR_KEY } from '~/constants'
import { useI18n } from '~/i18n'
import {
  allConnections,
  clientSourceIPTags,
  connectionsTableColumnOrder,
  connectionsTableColumnVisibility,
  connectionsTableSize,
  formatTimeFromNow,
  setConnectionsTableColumnOrder,
  setConnectionsTableColumnVisibility,
  tableSizeClassName,
  useConnections,
} from '~/signals'
import type { Connection } from '~/types'
enum ActiveTab {
  activeConnections,
  closedConnections,
}
const fuzzyFilter: FilterFn<Connection> = (row, columnId, value, addMeta) => {
  // Rank the item
  const itemRank = rankItem(row.getValue(columnId), value)
  // Store the itemRank info
  addMeta({
    itemRank,
  })
  // Return if the item should be filtered in/out
  return itemRank.passed
}
export default () => {
  let connectionsSettingsModalRef: HTMLDialogElement | undefined
  let connectionsDetailsModalRef: HTMLDialogElement | undefined
  const [t] = useI18n()
  const [activeTab, setActiveTab] = createSignal(ActiveTab.activeConnections)
  const { activeConnections, closedConnections, paused, setPaused } =
    useConnections()
  const [globalFilter, setGlobalFilter] = createSignal('')
  const [selectedConnectionID, setSelectedConnectionID] = createSignal<string>()
  const columns: ColumnDef<Connection>[] = [
    {
      header: () => t('details'),
      enableGrouping: false,
      enableSorting: false,
      enableColumnFilter: false,
      enableGlobalFilter: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Details,
      cell: ({ row }) => (
        <div class="flex h-4 items-center">
          <Button
            class="btn-circle btn-xs"
            onClick={() => {
              setSelectedConnectionID(row.original.id)
              connectionsDetailsModalRef?.showModal()
            }}
            icon={<IconInfoSmall size="16" />}
          />
        </div>
      ),
    },
    {
      header: () => t('close'),
      enableGrouping: false,
      enableSorting: false,
      enableColumnFilter: false,
      enableGlobalFilter: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Close,
      cell: ({ row }) => (
        <div class="flex h-4 items-center">
          <Button
            class="btn-circle btn-xs"
            onClick={() => closeSingleConnectionAPI(row.original.id)}
            icon={<IconX size="16" />}
          />
        </div>
      ),
    },
    {
      header: () => t('ID'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.ID,
      accessorFn: (original) => original.id,
    },
    {
      header: () => t('type'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Type,
      accessorFn: (original) =>
        `${original.metadata.type}(${original.metadata.network})`,
    },
    {
      header: () => t('process'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Process,
      accessorFn: (original) =>
        original.metadata.process ||
        original.metadata.processPath.replace(/^.*[/\\](.*)$/, '$1') ||
        '-',
    },
    {
      header: () => t('host'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Host,
      accessorFn: (original) =>
        `${
          original.metadata.host
            ? original.metadata.host
            : original.metadata.destinationIP
        }:${original.metadata.destinationPort}`,
    },
    {
      header: () => t('sniffHost'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost,
      accessorFn: (original) => original.metadata.sniffHost || '-',
    },
    {
      header: () => t('rules'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Rule,
      accessorFn: (original) =>
        !original.rulePayload
          ? original.rule
          : `${original.rule} :: ${original.rulePayload}`,
    },
    {
      header: () => t('chains'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Chains,
      accessorFn: (original) => original.chains.slice().reverse().join(' :: '),
    },
    {
      header: () => t('connectTime'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime,
      accessorFn: (original) => formatTimeFromNow(original.start),
      sortingFn: (prev, next) =>
        dayjs(prev.original.start).valueOf() -
        dayjs(next.original.start).valueOf(),
    },
    {
      header: () => t('dlSpeed'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed,
      accessorFn: (original) => `${byteSize(original.downloadSpeed)}/s`,
      sortingFn: (prev, next) =>
        prev.original.downloadSpeed - next.original.downloadSpeed,
    },
    {
      header: () => t('ulSpeed'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.ULSpeed,
      accessorFn: (original) => `${byteSize(original.uploadSpeed)}/s`,
      sortingFn: (prev, next) =>
        prev.original.uploadSpeed - next.original.uploadSpeed,
    },
    {
      header: () => t('dl'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Download,
      accessorFn: (original) => byteSize(original.download),
      sortingFn: (prev, next) =>
        prev.original.download - next.original.download,
    },
    {
      header: () => t('ul'),
      enableGrouping: false,
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Upload,
      accessorFn: (original) => byteSize(original.upload),
      sortingFn: (prev, next) => prev.original.upload - next.original.upload,
    },
    {
      header: () => t('sourceIP'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP,
      accessorFn: (original) => {
        const tag = clientSourceIPTags().find(
          (tag) => tag.sourceIP === original.metadata.sourceIP,
        )
        return tag ? tag.tagName : original.metadata.sourceIP
      },
    },
    {
      header: () => t('sourcePort'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.SourcePort,
      accessorFn: (original) => original.metadata.sourcePort,
    },
    {
      header: () => t('destination'),
      accessorKey: CONNECTIONS_TABLE_ACCESSOR_KEY.Destination,
      accessorFn: (original) =>
        original.metadata.remoteDestination ||
        original.metadata.destinationIP ||
        original.metadata.host,
    },
  ]
  const [grouping, setGrouping] = createSignal<GroupingState>([])
  const [sorting, setSorting] = makePersisted(
    createSignal<SortingState>([
      { id: CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime, desc: true },
    ]),
    { name: 'connectionsTableSorting', storage: localStorage },
  )
  const table = createSolidTable({
    filterFns: {
      fuzzy: fuzzyFilter,
    },
    state: {
      get columnOrder() {
        return connectionsTableColumnOrder()
      },
      get grouping() {
        return grouping()
      },
      get sorting() {
        return sorting()
      },
      get columnVisibility() {
        return connectionsTableColumnVisibility()
      },
      get globalFilter() {
        return globalFilter()
      },
    },
    get data() {
      return activeTab() === ActiveTab.activeConnections
        ? activeConnections()
        : closedConnections()
    },
    sortDescFirst: true,
    enableHiding: true,
    columns,
    onGlobalFilterChange: setGlobalFilter,
    globalFilterFn: fuzzyFilter,
    onGroupingChange: setGrouping,
    onSortingChange: setSorting,
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getExpandedRowModel: getExpandedRowModel(),
    getGroupedRowModel: getGroupedRowModel(),
    getCoreRowModel: getCoreRowModel(),
  })
  const sourceIPHeader = table
    .getFlatHeaders()
    .find(({ id }) => id === CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP)
  const [sourceIPFilter, setSourceIPFilter] = createSignal('')
  createEffect(
    on(sourceIPFilter, () => {
      const tagged = clientSourceIPTags().find(
        (tag) => tag.sourceIP === sourceIPFilter(),
      )
      sourceIPHeader?.column.setFilterValue(
        tagged ? tagged.tagName : sourceIPFilter(),
      )
    }),
  )
  const tabs = createMemo(() => [
    {
      type: ActiveTab.activeConnections,
      name: t('active'),
      count: activeConnections().length,
    },
    {
      type: ActiveTab.closedConnections,
      name: t('closed'),
      count: closedConnections().length,
    },
  ])
  return (
    <div class="flex h-full flex-col gap-2">
      <div class="flex w-full flex-wrap items-center gap-2">
        <div class="flex items-center gap-2">
          <div class="tabs-boxed tabs gap-2">
            <Index each={tabs()}>
              {(tab) => (
                <button
                  class={twMerge(
                    activeTab() === tab().type && 'tab-active',
                    'tab tab-sm gap-2 sm:tab-md',
                  )}
                  onClick={() => setActiveTab(tab().type)}
                >
                  <span>{tab().name}</span>
                  <div class="badge badge-sm">{tab().count}</div>
                </button>
              )}
            </Index>
          </div>
          <select
            class="select select-bordered select-primary select-sm w-full max-w-full flex-1 sm:select-md"
            onChange={(e) => setSourceIPFilter(e.target.value)}
          >
            <option value="">{t('all')}</option>
            <Index
              each={uniq(
                allConnections().map(({ metadata: { sourceIP } }) => {
                  const tagged = clientSourceIPTags().find(
                    (tag) => tag.sourceIP === sourceIP,
                  )
                  return tagged ? tagged.tagName : sourceIP
                }),
              ).sort()}
            >
              {(sourceIP) => <option value={sourceIP()}>{sourceIP()}</option>}
            </Index>
          </select>
        </div>
        <div class="join flex flex-1 items-center md:flex-1">
          <input
            type="search"
            class="input join-item input-primary input-sm min-w-0 flex-1 sm:input-md"
            placeholder={t('search')}
            onInput={(e) => setGlobalFilter(e.target.value)}
          />
          <Button
            class="join-item btn-sm sm:btn-md"
            onClick={() => setPaused((paused) => !paused)}
            icon={paused() ? <IconPlayerPlay /> : <IconPlayerPause />}
          />
          <Button
            class="join-item btn-sm sm:btn-md"
            onClick={() => {
              if (table.getState().globalFilter) {
                table
                  .getFilteredRowModel()
                  .rows.forEach(({ id }) => closeSingleConnectionAPI(id))
              } else {
                closeAllConnectionsAPI()
              }
            }}
            icon={<IconX />}
          />
          <Button
            class="btn join-item btn-sm sm:btn-md"
            onClick={() => connectionsSettingsModalRef?.showModal()}
            icon={<IconSettings />}
          />
        </div>
      </div>
      <div class="overflow-x-auto whitespace-nowrap rounded-md bg-base-300">
        <table
          class={twMerge(
            tableSizeClassName(connectionsTableSize()),
            'table table-zebra relative rounded-none',
          )}
        >
          <thead class="sticky top-0 z-10 h-8">
            <For each={table.getHeaderGroups()}>
              {(headerGroup) => (
                <tr>
                  <For each={headerGroup.headers}>
                    {(header) => (
                      <th class="bg-base-200">
                        <div class={twMerge('flex items-center gap-2')}>
                          {header.column.getCanGroup() ? (
                            <button
                              class="cursor-pointer"
                              onClick={header.column.getToggleGroupingHandler()}
                            >
                              {header.column.getIsGrouped() ? (
                                <IconZoomOutFilled size={18} />
                              ) : (
                                <IconZoomInFilled size={18} />
                              )}
                            </button>
                          ) : null}
                          <div
                            class={twMerge(
                              header.column.getCanSort() &&
                                'cursor-pointer select-none',
                              'flex-1',
                            )}
                            onClick={header.column.getToggleSortingHandler()}
                          >
                            {flexRender(
                              header.column.columnDef.header,
                              header.getContext(),
                            )}
                          </div>
                          {{
                            asc: <IconSortAscending />,
                            desc: <IconSortDescending />,
                          }[header.column.getIsSorted() as string] ?? null}
                        </div>
                      </th>
                    )}
                  </For>
                </tr>
              )}
            </For>
          </thead>
          <tbody>
            <For each={table.getRowModel().rows}>
              {(row) => (
                <tr class="hover:!bg-primary hover:text-primary-content">
                  <For each={row.getVisibleCells()}>
                    {(cell) => {
                      return (
                        <td
                          class="py-2"
                          onContextMenu={(e) => {
                            e.preventDefault()
                            const value = cell.renderValue() as null | string
                            value && writeClipboard(value).catch(() => {})
                          }}
                        >
                          {cell.getIsGrouped() ? (
                            <button
                              class={twMerge(
                                row.getCanExpand()
                                  ? 'cursor-pointer'
                                  : 'cursor-normal',
                                'flex items-center gap-2',
                              )}
                              onClick={row.getToggleExpandedHandler()}
                            >
                              <div>
                                {row.getIsExpanded() ? (
                                  <IconZoomOutFilled size={18} />
                                ) : (
                                  <IconZoomInFilled size={18} />
                                )}
                              </div>
                              <div>
                                {flexRender(
                                  cell.column.columnDef.cell,
                                  cell.getContext(),
                                )}
                              </div>
                              <div>({row.subRows.length})</div>
                            </button>
                          ) : cell.getIsAggregated() ? (
                            flexRender(
                              cell.column.columnDef.aggregatedCell ??
                                cell.column.columnDef.cell,
                              cell.getContext(),
                            )
                          ) : cell.getIsPlaceholder() ? null : (
                            flexRender(
                              cell.column.columnDef.cell,
                              cell.getContext(),
                            )
                          )}
                        </td>
                      )
                    }}
                  </For>
                </tr>
              )}
            </For>
          </tbody>
        </table>
      </div>
      <ConnectionsSettingsModal
        ref={(el) => (connectionsSettingsModalRef = el)}
        order={connectionsTableColumnOrder()}
        visible={connectionsTableColumnVisibility()}
        onOrderChange={(data) => setConnectionsTableColumnOrder(data)}
        onVisibleChange={(data) =>
          setConnectionsTableColumnVisibility({ ...data })
        }
      />
      <ConnectionsTableDetailsModal
        ref={(el) => (connectionsDetailsModalRef = el)}
        selectedConnectionID={selectedConnectionID()}
      />
    </div>
  )
}
src/pages/Logs.tsx
New file
@@ -0,0 +1,224 @@
import { makePersisted } from '@solid-primitives/storage'
import {
  IconSettings,
  IconSortAscending,
  IconSortDescending,
} from '@tabler/icons-solidjs'
import { rankItem } from '@tanstack/match-sorter-utils'
import {
  ColumnDef,
  FilterFn,
  SortingState,
  createSolidTable,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
} from '@tanstack/solid-table'
import { For, Index, createEffect, createSignal } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { Button, LogsSettingsModal } from '~/components'
import { LOG_LEVEL } from '~/constants'
import { useI18n } from '~/i18n'
import { logsTableSize, tableSizeClassName, useWsRequest } from '~/signals'
import { logLevel, logMaxRows } from '~/signals/config'
import { Log } from '~/types'
type LogWithSeq = Log & { seq: number }
const fuzzyFilter: FilterFn<LogWithSeq> = (row, columnId, value, addMeta) => {
  // Rank the item
  const itemRank = rankItem(row.getValue(columnId), value)
  // Store the itemRank info
  addMeta({
    itemRank,
  })
  // Return if the item should be filtered in/out
  return itemRank.passed
}
export default () => {
  let logsSettingsModalRef: HTMLDialogElement | undefined
  const [t] = useI18n()
  let seq = 1
  const [logs, setLogs] = createSignal<LogWithSeq[]>([])
  const logsData = useWsRequest<Log>('logs', { level: logLevel() })
  createEffect(() => {
    const data = logsData()
    if (!data) {
      return
    }
    setLogs((logs) => [{ ...data, seq }, ...logs].slice(0, logMaxRows()))
    seq++
  })
  const [globalFilter, setGlobalFilter] = createSignal('')
  const [sorting, setSorting] = makePersisted(createSignal<SortingState>([]), {
    name: 'logsTableSorting',
    storage: localStorage,
  })
  const columns: ColumnDef<LogWithSeq>[] = [
    {
      header: t('sequence'),
      accessorFn: (row) => row.seq,
    },
    {
      header: t('type'),
      accessorFn: (row) => row.type,
      cell: ({ row }) => {
        const type = row.original.type as LOG_LEVEL
        let className = ''
        switch (type) {
          case LOG_LEVEL.Error:
            className = 'text-error'
            break
          case LOG_LEVEL.Warning:
            className = 'text-warning'
            break
          case LOG_LEVEL.Info:
            className = 'text-info'
            break
          case LOG_LEVEL.Debug:
            className = 'text-success'
            break
        }
        return <span class={className}>{`[${row.original.type}]`}</span>
      },
    },
    {
      header: t('payload'),
      accessorFn: (row) => row.payload,
    },
  ]
  const table = createSolidTable({
    filterFns: {
      fuzzy: fuzzyFilter,
    },
    state: {
      get globalFilter() {
        return globalFilter()
      },
      get sorting() {
        return sorting()
      },
    },
    get data() {
      return logs()
    },
    sortDescFirst: true,
    columns,
    onGlobalFilterChange: setGlobalFilter,
    onSortingChange: setSorting,
    globalFilterFn: fuzzyFilter,
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getCoreRowModel: getCoreRowModel(),
  })
  return (
    <div class="flex h-full flex-col gap-2">
      <div class="join w-full">
        <input
          type="search"
          class="input join-item input-primary input-sm flex-1 flex-shrink-0 sm:input-md"
          placeholder={t('search')}
          onInput={(e) => setGlobalFilter(e.target.value)}
        />
        <Button
          class="join-item btn-sm sm:btn-md"
          onClick={() => logsSettingsModalRef?.showModal()}
          icon={<IconSettings />}
        />
      </div>
      <div class="overflow-x-auto whitespace-nowrap rounded-md bg-base-300">
        <table
          class={twMerge(
            tableSizeClassName(logsTableSize()),
            'table relative rounded-none',
          )}
        >
          <thead class="sticky top-0 z-10">
            <Index each={table.getHeaderGroups()}>
              {(keyedHeaderGroup) => {
                const headerGroup = keyedHeaderGroup()
                return (
                  <tr>
                    <Index each={headerGroup.headers}>
                      {(keyedHeader) => {
                        const header = keyedHeader()
                        return (
                          <th class="bg-base-200">
                            <div class="flex items-center">
                              <div
                                class={twMerge(
                                  header.column.getCanSort() &&
                                    'cursor-pointer select-none',
                                  'flex-1',
                                )}
                                onClick={header.column.getToggleSortingHandler()}
                              >
                                {flexRender(
                                  header.column.columnDef.header,
                                  header.getContext(),
                                )}
                              </div>
                              {{
                                asc: <IconSortAscending />,
                                desc: <IconSortDescending />,
                              }[header.column.getIsSorted() as string] ?? null}
                            </div>
                          </th>
                        )
                      }}
                    </Index>
                  </tr>
                )
              }}
            </Index>
          </thead>
          <tbody>
            <For each={table.getRowModel().rows}>
              {(row) => (
                <tr class="hover:!bg-primary hover:text-primary-content">
                  <For each={row.getVisibleCells()}>
                    {(cell) => (
                      <td class="py-2">
                        {flexRender(
                          cell.column.columnDef.cell,
                          cell.getContext(),
                        )}
                      </td>
                    )}
                  </For>
                </tr>
              )}
            </For>
          </tbody>
        </table>
      </div>
      <LogsSettingsModal ref={(el) => (logsSettingsModalRef = el)} />
    </div>
  )
}
src/pages/Overview.tsx
New file
@@ -0,0 +1,135 @@
import { makeTimer } from '@solid-primitives/timer'
import type { ApexOptions } from 'apexcharts'
import byteSize from 'byte-size'
import { merge } from 'lodash'
import { SolidApexCharts } from 'solid-apexcharts'
import {
  JSX,
  ParentComponent,
  batch,
  children,
  createEffect,
  createMemo,
  createSignal,
} from 'solid-js'
import { CHART_MAX_XAXIS, DEFAULT_CHART_OPTIONS } from '~/constants'
import { useI18n } from '~/i18n'
import { latestConnectionMsg, useWsRequest } from '~/signals'
const TrafficWidget: ParentComponent<{ label: JSX.Element }> = (props) => (
  <div class="stat flex-1 place-items-center">
    <div class="stat-title text-primary-content">{props.label}</div>
    <div class="stat-value text-2xl text-primary-content lg:text-3xl">
      {children(() => props.children)()}
    </div>
  </div>
)
export default () => {
  const [t] = useI18n()
  const [traffics, setTraffics] = createSignal<{ down: number; up: number }[]>(
    [],
  )
  const [memories, setMemories] = createSignal<number[]>([])
  // https://github.com/apexcharts/apexcharts.js/blob/main/samples/source/line/realtime.xml
  // TODO: needs a better way
  makeTimer(
    () => {
      batch(() => {
        setTraffics((traffics) => traffics.slice(-CHART_MAX_XAXIS))
        setMemories((memo) => memo.slice(-CHART_MAX_XAXIS))
      })
    },
    // we shrink the chart data array size down every minute to prevent memory leaks
    60 * 1000,
    setInterval,
  )
  const traffic = useWsRequest<{ down: number; up: number }>('traffic')
  createEffect(() => {
    const t = traffic()
    if (t) setTraffics((traffics) => [...traffics, t])
  })
  const trafficChartOptions = createMemo<ApexOptions>(() =>
    merge({ title: { text: t('traffic') } }, DEFAULT_CHART_OPTIONS),
  )
  const trafficChartSeries = createMemo(() => [
    {
      name: t('down'),
      data: traffics().map((t) => t.down),
    },
    {
      name: t('up'),
      data: traffics().map((t) => t.up),
    },
  ])
  const memory = useWsRequest<{ inuse: number }>('memory')
  createEffect(() => {
    const m = memory()?.inuse
    if (m) setMemories((memories) => [...memories, m])
  })
  const memoryChartOptions = createMemo<ApexOptions>(() =>
    merge({ title: { text: t('memory') } }, DEFAULT_CHART_OPTIONS),
  )
  const memoryChartSeries = createMemo(() => [
    { name: t('memory'), data: memories() },
  ])
  return (
    <div class="flex flex-col gap-2">
      <div class="stats stats-vertical w-full grid-cols-2 bg-primary shadow lg:stats-horizontal lg:flex">
        <TrafficWidget label={t('upload')}>
          {byteSize(traffic()?.up || 0).toString()}/s
        </TrafficWidget>
        <TrafficWidget label={t('download')}>
          {byteSize(traffic()?.down || 0).toString()}/s
        </TrafficWidget>
        <TrafficWidget label={t('uploadTotal')}>
          {byteSize(latestConnectionMsg()?.uploadTotal || 0).toString()}
        </TrafficWidget>
        <TrafficWidget label={t('downloadTotal')}>
          {byteSize(latestConnectionMsg()?.downloadTotal || 0).toString()}
        </TrafficWidget>
        <TrafficWidget label={t('activeConnections')}>
          {latestConnectionMsg()?.connections?.length || 0}
        </TrafficWidget>
        <TrafficWidget label={t('memoryUsage')}>
          {byteSize(memory()?.inuse || 0).toString()}
        </TrafficWidget>
      </div>
      <div class="rounded-box flex flex-col gap-2 bg-base-300 py-4 lg:flex-row">
        <div class="flex-1">
          <SolidApexCharts
            type="area"
            options={trafficChartOptions()}
            series={trafficChartSeries()}
          />
        </div>
        <div class="flex-1">
          <SolidApexCharts
            type="line"
            options={memoryChartOptions()}
            series={memoryChartSeries()}
          />
        </div>
      </div>
    </div>
  )
}
src/pages/Proxies.tsx
New file
@@ -0,0 +1,328 @@
import {
  IconBrandSpeedtest,
  IconReload,
  IconSettings,
} from '@tabler/icons-solidjs'
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import {
  Button,
  Collapse,
  ProxiesSettingsModal,
  ProxyNodeCard,
  ProxyNodePreview,
  RenderInTwoColumns,
  SubscriptionInfo,
} from '~/components'
import {
  filterProxiesByAvailability,
  sortProxiesByOrderingType,
} from '~/helpers'
import { useI18n } from '~/i18n'
import {
  formatTimeFromNow,
  hideUnAvailableProxies,
  proxiesOrderingType,
  useProxies,
} from '~/signals'
enum ActiveTab {
  proxyProviders = 'proxyProviders',
  proxies = 'proxies',
}
export default () => {
  let proxiesSettingsModalRef: HTMLDialogElement | undefined
  const [t] = useI18n()
  const {
    fetchProxies,
    proxies,
    selectProxyInGroup,
    latencyMap,
    proxyProviders,
    updateProviderByProviderName,
    updateAllProvider,
    proxyGroupLatencyTest,
    proxyProviderLatencyTest,
    collapsedMap,
    setCollapsedMap,
    proxyGroupLatencyTestingMap,
    proxyProviderLatencyTestingMap,
    isAllProviderUpdating,
    updatingMap,
  } = useProxies()
  onMount(fetchProxies)
  const onProxyGroupLatencyTestClick = async (
    e: MouseEvent,
    groupName: string,
  ) => {
    e.stopPropagation()
    void proxyGroupLatencyTest(groupName)
  }
  const onProxyProviderLatencyTestClick = (
    e: MouseEvent,
    providerName: string,
  ) => {
    e.stopPropagation()
    void proxyProviderLatencyTest(providerName)
  }
  const onUpdateProxyProviderClick = (e: MouseEvent, providerName: string) => {
    e.stopPropagation()
    void updateProviderByProviderName(providerName)
  }
  const onUpdateAllProviderClick = async (e: MouseEvent) => {
    e.stopPropagation()
    void updateAllProvider()
  }
  const [activeTab, setActiveTab] = createSignal(ActiveTab.proxies)
  const tabs = () => [
    {
      type: ActiveTab.proxies,
      name: t('proxies'),
      count: proxies().length,
    },
    {
      type: ActiveTab.proxyProviders,
      name: t('proxyProviders'),
      count: proxyProviders().length,
    },
  ]
  return (
    <div class="flex h-full flex-col gap-2">
      <div class="flex items-center gap-2">
        <div class="tabs-boxed tabs gap-2">
          <For each={tabs()}>
            {(tab) => (
              <button
                class={twMerge(
                  activeTab() === tab.type && 'tab-active',
                  'tab tab-sm gap-2 px-2 sm:tab-md',
                )}
                onClick={() => setActiveTab(tab.type)}
              >
                <span>{tab.name}</span>
                <div class="badge badge-sm">{tab.count}</div>
              </button>
            )}
          </For>
        </div>
        <Show when={activeTab() === ActiveTab.proxyProviders}>
          <Button
            class="btn btn-circle btn-sm"
            disabled={isAllProviderUpdating()}
            onClick={(e) => onUpdateAllProviderClick(e)}
            icon={
              <IconReload
                class={twMerge(
                  isAllProviderUpdating() && 'animate-spin text-success',
                )}
              />
            }
          />
        </Show>
        <div class="ml-auto">
          <Button
            class="btn-circle btn-sm sm:btn-md"
            onClick={() => proxiesSettingsModalRef?.showModal()}
            icon={<IconSettings />}
          />
        </div>
      </div>
      <div class="flex-1 overflow-y-auto">
        <Show when={activeTab() === ActiveTab.proxies}>
          <RenderInTwoColumns>
            <For each={proxies()}>
              {(proxyGroup) => {
                const sortedProxyNames = createMemo(() =>
                  filterProxiesByAvailability(
                    sortProxiesByOrderingType(
                      proxyGroup.all ?? [],
                      latencyMap(),
                      proxiesOrderingType(),
                    ),
                    latencyMap(),
                    hideUnAvailableProxies(),
                  ),
                )
                const title = (
                  <>
                    <div class="flex items-center justify-between pr-8">
                      <div class="flex items-center gap-2">
                        <span>{proxyGroup.name}</span>
                        <div class="badge badge-sm">
                          {proxyGroup.all?.length}
                        </div>
                      </div>
                      <Button
                        class="btn-circle btn-sm"
                        disabled={
                          proxyGroupLatencyTestingMap()[proxyGroup.name]
                        }
                        onClick={(e) =>
                          onProxyGroupLatencyTestClick(e, proxyGroup.name)
                        }
                        icon={
                          <IconBrandSpeedtest
                            class={twMerge(
                              proxyGroupLatencyTestingMap()[proxyGroup.name] &&
                                'animate-pulse text-success',
                            )}
                          />
                        }
                      />
                    </div>
                    <div class="text-sm text-slate-500">
                      {proxyGroup.type}{' '}
                      {proxyGroup.now?.length > 0 && ` :: ${proxyGroup.now}`}
                    </div>
                    <Show when={!collapsedMap()[proxyGroup.name]}>
                      <ProxyNodePreview
                        proxyNameList={sortedProxyNames()}
                        now={proxyGroup.now}
                      />
                    </Show>
                  </>
                )
                return (
                  <Collapse
                    isOpen={collapsedMap()[proxyGroup.name]}
                    title={title}
                    onCollapse={(val) => setCollapsedMap(proxyGroup.name, val)}
                  >
                    <For each={sortedProxyNames()}>
                      {(proxyName) => (
                        <ProxyNodeCard
                          proxyName={proxyName}
                          isSelected={proxyGroup.now === proxyName}
                          onClick={() =>
                            void selectProxyInGroup(proxyGroup, proxyName)
                          }
                        />
                      )}
                    </For>
                  </Collapse>
                )
              }}
            </For>
          </RenderInTwoColumns>
        </Show>
        <Show when={activeTab() === ActiveTab.proxyProviders}>
          <RenderInTwoColumns>
            <For each={proxyProviders()}>
              {(proxyProvider) => {
                const sortedProxyNames = createMemo(() =>
                  sortProxiesByOrderingType(
                    proxyProvider.proxies.map((i) => i.name) ?? [],
                    latencyMap(),
                    proxiesOrderingType(),
                  ),
                )
                const title = (
                  <>
                    <div class="flex items-center justify-between pr-8">
                      <div class="flex items-center gap-2">
                        <span>{proxyProvider.name}</span>
                        <div class="badge badge-sm">
                          {proxyProvider.proxies.length}
                        </div>
                      </div>
                      <div class="flex items-center gap-2">
                        <Button
                          class="btn btn-circle btn-sm"
                          disabled={updatingMap()[proxyProvider.name]}
                          onClick={(e) =>
                            onUpdateProxyProviderClick(e, proxyProvider.name)
                          }
                          icon={
                            <IconReload
                              class={twMerge(
                                updatingMap()[proxyProvider.name] &&
                                  'animate-spin text-success',
                              )}
                            />
                          }
                        />
                        <Button
                          class="btn btn-circle btn-sm"
                          disabled={
                            proxyProviderLatencyTestingMap()[proxyProvider.name]
                          }
                          onClick={(e) =>
                            onProxyProviderLatencyTestClick(
                              e,
                              proxyProvider.name,
                            )
                          }
                          icon={
                            <IconBrandSpeedtest
                              class={twMerge(
                                proxyProviderLatencyTestingMap()[
                                  proxyProvider.name
                                ] && 'animate-pulse text-success',
                              )}
                            />
                          }
                        />
                      </div>
                    </div>
                    <SubscriptionInfo
                      subscriptionInfo={proxyProvider.subscriptionInfo}
                    />
                    <div class="text-sm text-slate-500">
                      {proxyProvider.vehicleType} :: {t('updated')}{' '}
                      {formatTimeFromNow(proxyProvider.updatedAt)}
                    </div>
                    <Show when={!collapsedMap()[proxyProvider.name]}>
                      <ProxyNodePreview proxyNameList={sortedProxyNames()} />
                    </Show>
                  </>
                )
                return (
                  <Collapse
                    isOpen={collapsedMap()[proxyProvider.name]}
                    title={title}
                    onCollapse={(val) =>
                      setCollapsedMap(proxyProvider.name, val)
                    }
                  >
                    <For each={sortedProxyNames()}>
                      {(proxyName) => <ProxyNodeCard proxyName={proxyName} />}
                    </For>
                  </Collapse>
                )
              }}
            </For>
          </RenderInTwoColumns>
        </Show>
      </div>
      <ProxiesSettingsModal ref={(el) => (proxiesSettingsModalRef = el)} />
    </div>
  )
}
src/pages/Rules.tsx
New file
@@ -0,0 +1,261 @@
import { IconReload } from '@tabler/icons-solidjs'
import { createVirtualizer } from '@tanstack/solid-virtual'
import { matchSorter } from 'match-sorter'
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
import { twMerge } from 'tailwind-merge'
import { Button } from '~/components'
import { useStringBooleanMap } from '~/helpers'
import { useI18n } from '~/i18n'
import { formatTimeFromNow, useRules } from '~/signals'
import { Rule, RuleProvider } from '~/types'
enum ActiveTab {
  ruleProviders = 'ruleProviders',
  rules = 'rules',
}
export default () => {
  const [t] = useI18n()
  const {
    rules,
    ruleProviders,
    updateRules,
    updateAllRuleProvider,
    updateRuleProviderByName,
  } = useRules()
  onMount(updateRules)
  const { map: updatingMap, setWithCallback: setUpdatingMap } =
    useStringBooleanMap()
  const [allProviderIsUpdating, setAllProviderIsUpdating] = createSignal(false)
  const onUpdateProviderClick = (e: MouseEvent, name: string) => {
    e.stopPropagation()
    void setUpdatingMap(name, () => updateRuleProviderByName(name))
  }
  const onUpdateAllProviderClick = async (e: MouseEvent) => {
    e.stopPropagation()
    setAllProviderIsUpdating(true)
    try {
      await updateAllRuleProvider()
    } catch {}
    setAllProviderIsUpdating(false)
  }
  const [activeTab, setActiveTab] = createSignal(ActiveTab.rules)
  const tabs = () => [
    {
      type: ActiveTab.rules,
      name: t('rules'),
      count: rules().length,
    },
    {
      type: ActiveTab.ruleProviders,
      name: t('ruleProviders'),
      count: ruleProviders().length,
    },
  ]
  const [globalFilter, setGlobalFilter] = createSignal('')
  const filteredRules = createMemo(() =>
    globalFilter()
      ? matchSorter(rules(), globalFilter(), {
          keys: ['type', 'payload', 'type'] as (keyof Rule)[],
        })
      : rules(),
  )
  const filteredRuleProviders = createMemo(() =>
    globalFilter()
      ? matchSorter(ruleProviders(), globalFilter(), {
          keys: ['name', 'vehicleType', 'behavior'] as (keyof RuleProvider)[],
        })
      : ruleProviders(),
  )
  let scrollElementRef: HTMLDivElement | undefined
  const getRuleItemKey = ({ type, payload, proxy }: Rule) =>
    `${type}-${payload}-${proxy}`
  const ruleVirtualizer = createVirtualizer({
    get count() {
      return filteredRules().length
    },
    getItemKey: (index) => getRuleItemKey(filteredRules()[index]),
    getScrollElement: () => scrollElementRef!,
    estimateSize: () => 82,
    overscan: 5,
  })
  const ruleVirtualizerItems = ruleVirtualizer.getVirtualItems()
  const getRuleProviderItemKey = ({
    type,
    name,
    vehicleType,
    behavior,
  }: RuleProvider) => `${type}-${name}-${vehicleType}-${behavior}`
  const ruleProviderVirtualizer = createVirtualizer({
    get count() {
      return filteredRuleProviders().length
    },
    getItemKey: (index) =>
      getRuleProviderItemKey(filteredRuleProviders()[index]),
    getScrollElement: () => scrollElementRef!,
    estimateSize: () => 82,
    overscan: 5,
  })
  const ruleProviderVirtualizerItems = ruleProviderVirtualizer.getVirtualItems()
  return (
    <div class="flex h-full flex-col gap-2">
      <div class="flex items-center gap-2">
        <div class="tabs-boxed tabs gap-2">
          <For each={tabs()}>
            {(tab) => (
              <button
                class={twMerge(
                  activeTab() === tab.type && 'tab-active',
                  'tab tab-sm gap-2 px-2 md:tab-md',
                )}
                onClick={() => setActiveTab(tab.type)}
              >
                <span>{tab.name}</span>
                <div class="badge badge-sm">{tab.count}</div>
              </button>
            )}
          </For>
        </div>
        <Show when={activeTab() === ActiveTab.ruleProviders}>
          <Button
            class="btn btn-circle btn-sm"
            disabled={allProviderIsUpdating()}
            onClick={(e) => onUpdateAllProviderClick(e)}
            icon={
              <IconReload
                class={twMerge(
                  allProviderIsUpdating() && 'animate-spin text-success',
                )}
              />
            }
          />
        </Show>
      </div>
      <input
        class="input input-bordered input-primary"
        placeholder={t('search')}
        value={globalFilter()}
        onInput={(e) => setGlobalFilter(e.currentTarget.value)}
      />
      <div
        ref={(ref) => (scrollElementRef = ref)}
        class="flex-1 overflow-y-auto"
      >
        <Show when={activeTab() === ActiveTab.rules}>
          <div
            class="relative"
            style={{ height: `${ruleVirtualizer.getTotalSize()}px` }}
          >
            {ruleVirtualizerItems.map((virtualizerItem) => {
              const rule = filteredRules().find(
                (rule) => getRuleItemKey(rule) === virtualizerItem.key,
              )!
              return (
                <div
                  ref={(el) =>
                    onMount(() => ruleVirtualizer.measureElement(el))
                  }
                  data-index={virtualizerItem.index}
                  class="absolute inset-x-0 top-0 pb-2 last:pb-0"
                  style={{
                    transform: `translateY(${virtualizerItem.start}px)`,
                  }}
                >
                  <div class="card card-bordered card-compact bg-base-200 p-4">
                    <div class="flex items-center gap-2">
                      <span class="break-all">{rule.payload}</span>
                      <Show when={rule.size !== -1}>
                        <div class="badge badge-sm">{rule.size}</div>
                      </Show>
                    </div>
                    <div class="text-xs text-slate-500">
                      {rule.type} :: {rule.proxy}
                    </div>
                  </div>
                </div>
              )
            })}
          </div>
        </Show>
        <Show when={activeTab() === ActiveTab.ruleProviders}>
          <div
            class="relative"
            style={{ height: `${ruleProviderVirtualizer.getTotalSize()}px` }}
          >
            {ruleProviderVirtualizerItems.map((virtualizerItem) => {
              const ruleProvider = ruleProviders().find(
                (ruleProvider) =>
                  getRuleProviderItemKey(ruleProvider) === virtualizerItem.key,
              )!
              return (
                <div
                  ref={(el) =>
                    onMount(() => ruleProviderVirtualizer.measureElement(el))
                  }
                  data-index={virtualizerItem.index}
                  class="absolute inset-x-0 top-0 pb-2 last:pb-0"
                  style={{
                    transform: `translateY(${virtualizerItem.start}px)`,
                  }}
                >
                  <div class="card card-bordered card-compact bg-base-200 p-4">
                    <div class="flex items-center gap-2 pr-8">
                      <span class="break-all">{ruleProvider.name}</span>
                      <div class="badge badge-sm">{ruleProvider.ruleCount}</div>
                    </div>
                    <div class="text-xs text-slate-500">
                      {ruleProvider.vehicleType} / {ruleProvider.behavior} /
                      {t('updated')} {formatTimeFromNow(ruleProvider.updatedAt)}
                    </div>
                    <Button
                      class="btn-circle btn-sm absolute right-2 top-2 mr-2 h-4"
                      disabled={updatingMap()[ruleProvider.name]}
                      onClick={(e) =>
                        onUpdateProviderClick(e, ruleProvider.name)
                      }
                      icon={
                        <IconReload
                          class={twMerge(
                            updatingMap()[ruleProvider.name] &&
                              'animate-spin text-success',
                          )}
                        />
                      }
                    />
                  </div>
                </div>
              )
            })}
          </div>
        </Show>
      </div>
    </div>
  )
}
src/pages/Setup.tsx
New file
@@ -0,0 +1,199 @@
import { createForm } from '@felte/solid'
import { validator } from '@felte/validator-zod'
import { useLocation, useNavigate } from '@solidjs/router'
import { IconX } from '@tabler/icons-solidjs'
import ky from 'ky'
import { For, onMount } from 'solid-js'
import { toast } from 'solid-toast'
import { v4 as uuid } from 'uuid'
import { z } from 'zod'
import { Button } from '~/components'
import { transformEndpointURL } from '~/helpers'
import { useI18n } from '~/i18n'
import {
  endpointList,
  selectedEndpoint,
  setEndpointList,
  setSelectedEndpoint,
} from '~/signals'
const schema = z.object({
  url: z.string().min(1),
  secret: z.string(),
})
export default () => {
  const [t] = useI18n()
  const location = useLocation()
  const navigate = useNavigate()
  const onSetupSuccess = (id: string) => {
    setSelectedEndpoint(id)
    navigate('/overview')
  }
  const checkEndpoint = (url: string, secret: string) =>
    ky
      .get(url, {
        headers: secret
          ? {
              Authorization: `Bearer ${secret}`,
            }
          : {},
      })
      .then(({ ok }) => ok)
      .catch((err) => {
        const { message } = err as Error
        toast.error(message)
      })
  const onEndpointSelect = async (id: string) => {
    const endpoint = endpointList().find((e) => e.id === id)
    if (!endpoint) {
      return
    }
    if (!(await checkEndpoint(endpoint.url, endpoint.secret))) {
      return
    }
    onSetupSuccess(id)
  }
  const onSubmit = async ({ url, secret }: { url: string; secret: string }) => {
    const transformedURL = transformEndpointURL(url)
    if (!(await checkEndpoint(transformedURL, secret))) {
      return
    }
    const id = uuid()
    const list = endpointList().slice()
    const point = list.find((history) => history.url === transformedURL)
    if (!point) {
      // new host and secret
      setEndpointList([{ id, url: transformedURL, secret }, ...list])
      onSetupSuccess(id)
      return
    }
    // exist host we update secret and id no matter if secret is equal or not
    point.secret = secret
    point.id = id
    setEndpointList(list)
    onSetupSuccess(id)
  }
  const onError = (err: unknown) => {
    const { message } = err as Error
    toast.error(message)
  }
  const { form } = createForm<z.infer<typeof schema>>({
    extend: validator({ schema }),
    onSubmit,
    onError,
  })
  const onRemove = (id: string) => {
    if (selectedEndpoint() === id) {
      setSelectedEndpoint('')
    }
    setEndpointList(endpointList().filter((e) => e.id !== id))
  }
  onMount(() => {
    const query = new URLSearchParams(location.search)
    if (query.has('hostname')) {
      void onSubmit({
        url: `${window.location.protocol}//${query.get('hostname')}${
          query.get('port') ? `:${query.get('port')}` : ''
        }`,
        secret: query.get('secret') ?? '',
      })
    } else if (endpointList().length === 0) {
      /**
        we only try auto login when there is nothing in endpoint list
        or user who is using default config won't be able to switch to another endpoint ever
      */
      void onSubmit({
        url: 'http://127.0.0.1:9090',
        secret: '',
      })
    }
  })
  return (
    <div class="mx-auto flex max-w-screen-sm flex-col items-center gap-4 py-10">
      <form class="contents" use:form={form}>
        <div class="flex w-full flex-col gap-4">
          <div class="flex-1">
            <label class="label">
              <span class="label-text">{t('endpointURL')}</span>
            </label>
            <input
              name="url"
              type="url"
              class="input input-bordered w-full"
              placeholder="http://127.0.0.1:9090"
              list="defaultEndpoints"
            />
            <datalist id="defaultEndpoints">
              <option value="http://127.0.0.1:9090" />
            </datalist>
          </div>
          <div class="flex-1">
            <label class="label">
              <span class="label-text">{t('secret')}</span>
            </label>
            <input
              name="secret"
              type="password"
              class="input input-bordered w-full"
              placeholder="secret"
            />
          </div>
          <Button type="submit" class="btn-primary uppercase">
            {t('add')}
          </Button>
        </div>
      </form>
      <div class="grid w-full grid-cols-2 gap-4">
        <For each={endpointList()}>
          {({ id, url }) => (
            <div
              class="badge badge-info flex w-full cursor-pointer items-center justify-between gap-4 py-4"
              onClick={() => onEndpointSelect(id)}
            >
              <span class="truncate">{url}</span>
              <Button
                class="btn-circle btn-ghost btn-xs text-white"
                onClick={(e) => {
                  e.stopPropagation()
                  onRemove(id)
                }}
              >
                <IconX />
              </Button>
            </div>
          )}
        </For>
      </div>
    </div>
  )
}
src/signals/config.ts
New file
@@ -0,0 +1,139 @@
import { makePersisted } from '@solid-primitives/storage'
import { createSignal } from 'solid-js'
import {
  CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER,
  CONNECTIONS_TABLE_INITIAL_COLUMN_VISIBILITY,
  DEFAULT_LOGS_TABLE_MAX_ROWS,
  LATENCY_QUALITY_MAP_HTTP,
  LATENCY_QUALITY_MAP_HTTPS,
  LOG_LEVEL,
  PROXIES_ORDERING_TYPE,
  PROXIES_PREVIEW_TYPE,
  TAILWINDCSS_SIZE,
  themes,
} from '~/constants'
import {
  ConnectionsTableColumnOrder,
  ConnectionsTableColumnVisibility,
} from '~/types'
export const [proxiesPreviewType, setProxiesPreviewType] = makePersisted(
  createSignal(PROXIES_PREVIEW_TYPE.Auto),
  { name: 'proxiesPreviewType', storage: localStorage },
)
export const [proxiesOrderingType, setProxiesOrderingType] = makePersisted(
  createSignal(PROXIES_ORDERING_TYPE.NATURAL),
  { name: 'proxiesOrderingType', storage: localStorage },
)
export const [hideUnAvailableProxies, setHideUnAvailableProxies] =
  makePersisted(createSignal(false), {
    name: 'hideUnAvailableProxies',
    storage: localStorage,
  })
export const [urlForLatencyTest, setUrlForLatencyTest] = makePersisted(
  createSignal('https://www.gstatic.com/generate_204'),
  { name: 'urlForLatencyTest', storage: localStorage },
)
export const [autoCloseConns, setAutoCloseConns] = makePersisted(
  createSignal(false),
  { name: 'autoCloseConns', storage: localStorage },
)
export const [useTwemoji, setUseTwemoji] = makePersisted(createSignal(true), {
  name: 'useTwemoji',
  storage: localStorage,
})
export const [autoSwitchTheme, setAutoSwitchTheme] = makePersisted(
  createSignal(false),
  { name: 'autoSwitchTheme', storage: localStorage },
)
export const [favDayTheme, setFavDayTheme] = makePersisted(
  createSignal<(typeof themes)[number]>('lofi'),
  { name: 'favDayTheme', storage: localStorage },
)
export const [favNightTheme, setFavNightTheme] = makePersisted(
  createSignal<(typeof themes)[number]>('business'),
  { name: 'favNightTheme', storage: localStorage },
)
export const [connectionsTableSize, setConnectionsTableSize] = makePersisted(
  createSignal<TAILWINDCSS_SIZE>(TAILWINDCSS_SIZE.XS),
  { name: 'connectionsTableSize', storage: localStorage },
)
export const [
  connectionsTableColumnVisibility,
  setConnectionsTableColumnVisibility,
] = makePersisted(
  createSignal<ConnectionsTableColumnVisibility>(
    CONNECTIONS_TABLE_INITIAL_COLUMN_VISIBILITY,
  ),
  {
    name: 'connectionsTableColumnVisibility',
    storage: localStorage,
  },
)
export const [connectionsTableColumnOrder, setConnectionsTableColumnOrder] =
  makePersisted(
    createSignal<ConnectionsTableColumnOrder>(
      CONNECTIONS_TABLE_INITIAL_COLUMN_ORDER,
    ),
    {
      name: 'connectionsTableColumnOrder',
      storage: localStorage,
    },
  )
export const [clientSourceIPTags, setClientSourceIPTags] = makePersisted(
  createSignal<{ tagName: string; sourceIP: string }[]>([]),
  {
    name: 'clientSourceIPTags',
    storage: localStorage,
  },
)
export const [logsTableSize, setLogsTableSize] = makePersisted(
  createSignal<TAILWINDCSS_SIZE>(TAILWINDCSS_SIZE.XS),
  { name: 'logsTableSize', storage: localStorage },
)
export const [logLevel, setLogLevel] = makePersisted(
  createSignal<LOG_LEVEL>(LOG_LEVEL.Info),
  { name: 'logLevel', storage: localStorage },
)
export const [logMaxRows, setLogMaxRows] = makePersisted(
  createSignal(DEFAULT_LOGS_TABLE_MAX_ROWS),
  {
    name: 'logMaxRows',
    storage: localStorage,
  },
)
export const tableSizeClassName = (size: TAILWINDCSS_SIZE) => {
  let className = 'table-xs'
  switch (size) {
    case TAILWINDCSS_SIZE.XS:
      className = 'table-xs'
      break
    case TAILWINDCSS_SIZE.SM:
      className = 'table-sm'
      break
    case TAILWINDCSS_SIZE.MD:
      className = 'table-md'
      break
    case TAILWINDCSS_SIZE.LG:
      className = 'table-lg'
      break
  }
  return className
}
export const [latencyTestTimeoutDuration, setLatencyTestTimeoutDuration] =
  makePersisted(createSignal(5000), {
    name: 'latencyTestTimeoutDuration',
    storage: localStorage,
  })
export const isLatencyTestByHttps = () =>
  urlForLatencyTest().startsWith('https')
export const latencyQualityMap = () =>
  isLatencyTestByHttps() ? LATENCY_QUALITY_MAP_HTTPS : LATENCY_QUALITY_MAP_HTTP
src/signals/connections.ts
New file
@@ -0,0 +1,105 @@
import { differenceWith, isNumber, unionWith } from 'lodash'
import { createEffect, createSignal, untrack } from 'solid-js'
import { CONNECTIONS_TABLE_MAX_CLOSED_ROWS } from '~/constants'
import { Connection, ConnectionRawMessage } from '~/types'
export type WsMsg = {
  connections?: ConnectionRawMessage[]
  uploadTotal: number
  downloadTotal: number
} | null
// we make connections global, so we can keep track of connections when user in proxy page
// when user selects proxy and close some connections they can back and check connections
// they closed
export const [allConnections, setAllConnections] = createSignal<Connection[]>(
  [],
)
export const [latestConnectionMsg, setLatestConnectionMsg] =
  createSignal<WsMsg>(null)
export const useConnections = () => {
  const [closedConnections, setClosedConnections] = createSignal<Connection[]>(
    [],
  )
  const [activeConnections, setActiveConnections] = createSignal<Connection[]>(
    [],
  )
  const [paused, setPaused] = createSignal(false)
  createEffect(() => {
    const rawConns = latestConnectionMsg()?.connections
    if (!rawConns) {
      return
    }
    untrack(() => {
      const activeConns = restructRawMsgToConnection(
        rawConns,
        activeConnections(),
      )
      mergeAllConnections(activeConnections())
      if (!paused()) {
        const closedConns = diffClosedConnections(activeConns, allConnections())
        setActiveConnections(activeConns)
        setClosedConnections(
          closedConns.slice(-CONNECTIONS_TABLE_MAX_CLOSED_ROWS),
        )
      }
      setAllConnections((allConnections) =>
        allConnections.slice(
          -(activeConns.length + CONNECTIONS_TABLE_MAX_CLOSED_ROWS),
        ),
      )
    })
  })
  return {
    closedConnections,
    activeConnections,
    paused,
    setPaused,
  }
}
export const restructRawMsgToConnection = (
  connections: ConnectionRawMessage[],
  prevActiveConnections: Connection[],
): Connection[] => {
  const prevMap = new Map<string, Connection>()
  prevActiveConnections.forEach((prev) => prevMap.set(prev.id, prev))
  return connections.map((connection) => {
    const prevConn = prevMap.get(connection.id)
    if (
      !prevConn ||
      !isNumber(prevConn.download) ||
      !isNumber(prevConn.upload)
    ) {
      return { ...connection, downloadSpeed: 0, uploadSpeed: 0 }
    }
    return {
      ...connection,
      downloadSpeed: connection.download - prevConn.download,
      uploadSpeed: connection.upload - prevConn.upload,
    }
  })
}
export const mergeAllConnections = (activeConns: Connection[]) => {
  setAllConnections((allConnections) =>
    unionWith(allConnections, activeConns, (a, b) => a.id === b.id),
  )
}
const diffClosedConnections = (
  activeConns: Connection[],
  allConns: Connection[],
) => differenceWith(allConns, activeConns, (a, b) => a.id === b.id)
src/signals/global.ts
New file
@@ -0,0 +1,5 @@
import dayjs from 'dayjs'
import { locale } from '~/i18n'
export const formatTimeFromNow = (time: number | string) =>
  dayjs(time).locale(locale()).fromNow()
src/signals/index.ts
New file
@@ -0,0 +1,7 @@
export * from './config'
export * from './connections'
export * from './global'
export * from './proxies'
export * from './request'
export * from './rules'
export * from './theme'
src/signals/proxies.ts
New file
@@ -0,0 +1,226 @@
import { batch, createSignal, untrack } from 'solid-js'
import {
  closeSingleConnectionAPI,
  fetchProxiesAPI,
  fetchProxyProvidersAPI,
  proxyGroupLatencyTestAPI,
  proxyLatencyTestAPI,
  proxyProviderHealthCheckAPI,
  selectProxyInGroupAPI,
  updateProxyProviderAPI,
} from '~/apis'
import { useStringBooleanMap } from '~/helpers'
import {
  autoCloseConns,
  latencyQualityMap,
  latencyTestTimeoutDuration,
  latestConnectionMsg,
  restructRawMsgToConnection,
  urlForLatencyTest,
} from '~/signals'
import type { Proxy, ProxyNode, ProxyProvider } from '~/types'
type ProxyInfo = {
  name: string
  udp: boolean
  now: string
  xudp: boolean
  type: string
  provider: string
}
export type ProxyWithProvider = Proxy & { provider?: string }
export type ProxyNodeWithProvider = ProxyNode & { provider?: string }
const { map: collapsedMap, set: setCollapsedMap } = useStringBooleanMap()
const {
  map: proxyLatencyTestingMap,
  setWithCallback: setProxyLatencyTestingMap,
} = useStringBooleanMap()
const {
  map: proxyGroupLatencyTestingMap,
  setWithCallback: setProxyGroupLatencyTestingMap,
} = useStringBooleanMap()
const {
  map: proxyProviderLatencyTestingMap,
  setWithCallback: setProxyProviderLatencyTestingMap,
} = useStringBooleanMap()
const { map: updatingMap, setWithCallback: setUpdatingMap } =
  useStringBooleanMap()
const [isAllProviderUpdating, setIsAllProviderUpdating] = createSignal(false)
// these signals should be global state
const [proxies, setProxies] = createSignal<ProxyWithProvider[]>([])
const [proxyProviders, setProxyProviders] = createSignal<
  (ProxyProvider & { proxies: ProxyNodeWithProvider[] })[]
>([])
const [latencyMap, setLatencyMap] = createSignal<Record<string, number>>({})
const [proxyNodeMap, setProxyNodeMap] = createSignal<Record<string, ProxyInfo>>(
  {},
)
const setProxiesInfo = (
  proxies: (ProxyWithProvider | ProxyNodeWithProvider)[],
) => {
  const newProxyNodeMap = { ...proxyNodeMap() }
  const newLatencyMap = { ...latencyMap() }
  proxies.forEach((proxy) => {
    const { udp, xudp, type, now, name, provider = '' } = proxy
    const latency =
      proxy.history.at(-1)?.delay || latencyQualityMap().NOT_CONNECTED
    newProxyNodeMap[proxy.name] = { udp, xudp, type, now, name, provider }
    newLatencyMap[proxy.name] = latency
  })
  batch(() => {
    setProxyNodeMap(newProxyNodeMap)
    setLatencyMap(newLatencyMap)
  })
}
export const useProxies = () => {
  const fetchProxies = async () => {
    const [{ providers }, { proxies }] = await Promise.all([
      fetchProxyProvidersAPI(),
      fetchProxiesAPI(),
    ])
    const sortIndex = [...(proxies['GLOBAL'].all ?? []), 'GLOBAL']
    const sortedProxies = Object.values(proxies)
      .filter((proxy) => proxy.all?.length)
      .sort(
        (prev, next) =>
          sortIndex.indexOf(prev.name) - sortIndex.indexOf(next.name),
      )
    const sortedProviders = Object.values(providers).filter(
      (provider) =>
        provider.name !== 'default' && provider.vehicleType !== 'Compatible',
    )
    const allProxies = [
      ...Object.values(proxies),
      ...sortedProviders.flatMap((provider) =>
        provider.proxies
          .filter((proxy) => !(proxy.name in proxies))
          .map((proxy) => ({
            ...proxy,
            provider: provider.name,
          })),
      ),
    ]
    batch(() => {
      setProxies(sortedProxies)
      setProxyProviders(sortedProviders)
      setProxiesInfo(allProxies)
    })
  }
  const selectProxyInGroup = async (proxy: Proxy, proxyName: string) => {
    await selectProxyInGroupAPI(proxy.name, proxyName)
    await fetchProxies()
    if (autoCloseConns()) {
      // we don't use activeConns from useConnection here for better performance,
      // and we use empty array to restruct msg because they are closed, they won't have speed anyway
      untrack(() => {
        const activeConns = restructRawMsgToConnection(
          latestConnectionMsg()?.connections ?? [],
          [],
        )
        if (activeConns.length > 0) {
          activeConns.forEach(({ id, chains }) => {
            if (chains.includes(proxy.name)) {
              closeSingleConnectionAPI(id)
            }
          })
        }
      })
    }
  }
  const proxyLatencyTest = (proxyName: string, provider: string) =>
    setProxyLatencyTestingMap(proxyName, async () => {
      const { delay } = await proxyLatencyTestAPI(
        proxyName,
        provider,
        urlForLatencyTest(),
        latencyTestTimeoutDuration(),
      )
      setLatencyMap((latencyMap) => ({
        ...latencyMap,
        [proxyName]: delay,
      }))
    })
  const proxyGroupLatencyTest = (proxyGroupName: string) =>
    setProxyGroupLatencyTestingMap(proxyGroupName, async () => {
      const newLatencyMap = await proxyGroupLatencyTestAPI(
        proxyGroupName,
        urlForLatencyTest(),
        latencyTestTimeoutDuration(),
      )
      setLatencyMap((latencyMap) => ({
        ...latencyMap,
        ...newLatencyMap,
      }))
      await fetchProxies()
    })
  const updateProviderByProviderName = (providerName: string) =>
    setUpdatingMap(providerName, async () => {
      try {
        await updateProxyProviderAPI(providerName)
      } catch {}
      await fetchProxies()
    })
  const updateAllProvider = async () => {
    setIsAllProviderUpdating(true)
    try {
      await Promise.allSettled(
        proxyProviders().map((provider) =>
          updateProxyProviderAPI(provider.name),
        ),
      )
      await fetchProxies()
    } finally {
      setIsAllProviderUpdating(false)
    }
  }
  const proxyProviderLatencyTest = (providerName: string) =>
    setProxyProviderLatencyTestingMap(providerName, async () => {
      await proxyProviderHealthCheckAPI(providerName)
      await fetchProxies()
    })
  return {
    collapsedMap,
    setCollapsedMap,
    proxyLatencyTestingMap,
    proxyGroupLatencyTestingMap,
    proxyProviderLatencyTestingMap,
    updatingMap,
    isAllProviderUpdating,
    proxies,
    proxyProviders,
    proxyLatencyTest,
    proxyGroupLatencyTest,
    latencyMap,
    proxyNodeMap,
    fetchProxies,
    selectProxyInGroup,
    updateProviderByProviderName,
    updateAllProvider,
    proxyProviderLatencyTest,
  }
}
src/signals/request.ts
New file
@@ -0,0 +1,77 @@
import { createEventSignal } from '@solid-primitives/event-listener'
import { makePersisted } from '@solid-primitives/storage'
import { createReconnectingWS } from '@solid-primitives/websocket'
import ky from 'ky'
import { createMemo, createSignal } from 'solid-js'
export const [selectedEndpoint, setSelectedEndpoint] = makePersisted(
  createSignal(''),
  {
    name: 'selectedEndpoint',
    storage: localStorage,
  },
)
export const [endpointList, setEndpointList] = makePersisted(
  createSignal<
    {
      id: string
      url: string
      secret: string
    }[]
  >([]),
  { name: 'endpointList', storage: localStorage },
)
export const useRequest = () => {
  const e = endpoint()
  if (!e) {
    return ky.create({})
  }
  const headers = new Headers()
  if (e.secret) {
    headers.set('Authorization', `Bearer ${e.secret}`)
  }
  return ky.create({
    prefixUrl: e.url,
    headers,
  })
}
export const endpoint = () =>
  endpointList().find(({ id }) => id === selectedEndpoint())
export const secret = () => endpoint()?.secret
export const wsEndpointURL = () =>
  new URL(endpoint()?.url ?? '').origin.replace('http', 'ws')
export const useWsRequest = <T>(
  path: string,
  queries: Record<string, string> = {},
) => {
  const queryParams = new URLSearchParams(queries)
  queryParams.set('token', secret() ?? '')
  const ws = createReconnectingWS(
    `${wsEndpointURL()}/${path}?${queryParams.toString()}`,
  )
  const event = createEventSignal<{
    message: MessageEvent
  }>(ws, 'message')
  return createMemo<T | null>(() => {
    const e = event()
    if (!e) {
      return null
    }
    return JSON.parse(event()?.data)
  })
}
src/signals/rules.ts
New file
@@ -0,0 +1,42 @@
import { createSignal } from 'solid-js'
import {
  fetchRuleProvidersAPI,
  fetchRulesAPI,
  updateRuleProviderAPI,
} from '~/apis'
import type { Rule, RuleProvider } from '~/types'
export const useRules = () => {
  const [rules, setRules] = createSignal<Rule[]>([])
  const [ruleProviders, setRuleProviders] = createSignal<RuleProvider[]>([])
  const updateRules = async () => {
    const [{ rules }, { providers }] = await Promise.all([
      fetchRulesAPI(),
      fetchRuleProvidersAPI(),
    ])
    setRules(Object.values(rules))
    setRuleProviders(Object.values(providers))
  }
  const updateAllRuleProvider = async () => {
    await Promise.all(
      ruleProviders().map((provider) => updateRuleProviderAPI(provider.name)),
    )
    await updateRules()
  }
  const updateRuleProviderByName = async (providerName: string) => {
    await updateRuleProviderAPI(providerName)
    await updateRules()
  }
  return {
    rules,
    ruleProviders,
    updateRules,
    updateAllRuleProvider,
    updateRuleProviderByName,
  }
}
src/signals/theme.ts
New file
@@ -0,0 +1,8 @@
import { makePersisted } from '@solid-primitives/storage'
import { createSignal } from 'solid-js'
import { themes } from '~/constants'
export const [curTheme, setCurTheme] = makePersisted(
  createSignal<(typeof themes)[number]>('halloween'),
  { name: 'theme', storage: localStorage },
)
src/types/index.d.ts
New file
@@ -0,0 +1,184 @@
import { CONNECTIONS_TABLE_ACCESSOR_KEY, LOG_LEVEL } from '~/constants'
declare module 'solid-js' {
  namespace JSX {
    interface Directives {
      [name: string]: unknown
    }
  }
}
export type Proxy = {
  name: string
  type: string
  all?: string[]
  extra: Record<string, unknown>
  history: {
    time: string
    delay: number
  }[]
  udp: boolean
  xudp: boolean
  tfo: boolean
  now: string
}
export type ProxyNode = {
  alive: boolean
  type: string
  name: string
  tfo: boolean
  udp: boolean
  xudp: boolean
  now: string
  id: string
  extra: Record<string, unknown>
  history: {
    time: string
    delay: number
  }[]
}
export type SubscriptionInfo = {
  Download?: number
  Upload?: number
  Total?: number
  Expire?: number
}
export type ProxyProvider = {
  subscriptionInfo?: SubscriptionInfo
  name: string
  proxies: ProxyNode[]
  testUrl: string
  updatedAt: string
  vehicleType: string
}
export type Rule = {
  type: string
  payload: string
  proxy: string
  size: number
}
export type RuleProvider = {
  behavior: string
  format: string
  name: string
  ruleCount: number
  type: string
  updatedAt: string
  vehicleType: string
}
export type ConnectionRawMessage = {
  id: string
  download: number
  upload: number
  chains: string[]
  rule: string
  rulePayload: string
  start: string
  metadata: {
    network: string
    type: string
    destinationIP: string
    destinationPort: string
    dnsMode: string
    host: string
    inboundIP: string
    inboundName: string
    inboundPort: string
    inboundUser: string
    process: string
    processPath: string
    remoteDestination: string
    sniffHost: string
    sourceIP: string
    sourcePort: string
    specialProxy: string
    specialRules: string
    uid: number
  }
}
export type Connection = ConnectionRawMessage & {
  downloadSpeed: number
  uploadSpeed: number
}
export type Log = {
  type: LOG_LEVEL
  payload: string
}
export type Config = {
  mode: 'global' | 'rule' | 'direct'
  port: number
  'socks-port': number
  'redir-port': number
  'tproxy-port': number
  'mixed-port': number
  tun: {
    enable: boolean
    device: string
    stack: string
    'dns-hijack': null
    'auto-route': boolean
    'auto-detect-interface': boolean
    'file-descriptor': number
  }
  'tuic-server': {
    enable: boolean
    listen: string
    certificate: string
    'private-key': string
  }
  'ss-config': string
  'vmess-config': string
  authentication: null
  'allow-lan': boolean
  'bind-address': string
  'inbound-tfo': boolean
  UnifiedDelay: boolean
  'log-level': string
  ipv6: boolean
  'interface-name': string
  'geodata-mode': boolean
  'geodata-loader': string
  'tcp-concurrent': boolean
  'find-process-mode': string
  sniffing: boolean
  'global-client-fingerprint': boolean
}
export type DNSQuery = {
  AD: boolean
  CD: boolean
  RA: boolean
  RD: boolean
  TC: boolean
  status: number
  Question: {
    Name: string
    Qtype: number
    Qclass: number
  }[]
  Answer?: {
    TTL: number
    data: string
    name: string
    type: number
  }[]
}
export type BackendVersion = {
  meta: boolean
  version: string
}
export type ConnectionsTableColumnVisibility = Partial<
  Record<CONNECTIONS_TABLE_ACCESSOR_KEY, boolean>
>
export type ConnectionsTableColumnOrder = CONNECTIONS_TABLE_ACCESSOR_KEY[]
src/vite-env.d.ts
New file
@@ -0,0 +1,7 @@
interface ImportMetaEnv {
  readonly version: string
}
interface ImportMeta {
  readonly env: ImportMetaEnv
}
tailwind.config.ts
New file
@@ -0,0 +1,15 @@
import daisyui from 'daisyui'
import { Config } from 'tailwindcss'
import safeArea from 'tailwindcss-safe-area'
export default {
  content: ['src/**/*.{css,ts,tsx}'],
  plugins: [daisyui, safeArea],
  daisyui: { themes: true },
  theme: {
    fontFamily: {
      twemoji: ['Fira Sans', 'Twemoji Mozilla', 'system-ui', 'monospace'],
      'no-twemoji': ['Fira Sans', 'system-ui', 'monospace'],
    },
  },
} as Config
tsconfig.json
New file
@@ -0,0 +1,20 @@
{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "types": ["vite/client"],
    "noEmit": true,
    "isolatedModules": true,
    "paths": {
      "~/*": ["./src/*"]
    }
  }
}
typings/index.d.ts
New file
@@ -0,0 +1 @@
declare module 'tailwindcss-safe-area'
vite.config.ts
New file
@@ -0,0 +1,43 @@
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import solidPlugin from 'vite-plugin-solid'
import pkgJSON from './package.json'
export default defineConfig({
  base: './',
  build: { chunkSizeWarningLimit: 1000 },
  resolve: { alias: { '~': '/src' } },
  define: {
    'import.meta.env.version': JSON.stringify(pkgJSON.version),
  },
  plugins: [
    solidPlugin(),
    splitVendorChunkPlugin(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.svg'],
      manifest: {
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: '/pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
            purpose: 'any maskable',
          },
        ],
      },
    }),
  ],
})