first commit
This commit is contained in:
commit
95e1515228
169 changed files with 17501 additions and 0 deletions
419
CHANGELOG.md
Normal file
419
CHANGELOG.md
Normal file
|
@ -0,0 +1,419 @@
|
|||
# Changelog
|
||||
|
||||
## [0.3.5](https://github.com/haishanh/yacd/compare/v0.3.4...v0.3.5) (2022-05-14)
|
||||
|
||||
Added:
|
||||
|
||||
- Added "Auto" theme option for theme to follow system theme preference
|
||||
- Display rule payload if possible in rule column of connections table
|
||||
- Allow override default backend url use environment variable with docker container
|
||||
- Gzip and cache static assets in docker container
|
||||
- Docker image is now published to ghcr too
|
||||
|
||||
Changed:
|
||||
|
||||
- Use Inter as app wide font
|
||||
|
||||
## [0.3.4](https://github.com/haishanh/yacd/compare/v0.3.3...v0.3.4) (2021-11-14)
|
||||
|
||||
Added:
|
||||
|
||||
- Add float action button to pause/start log streaming
|
||||
|
||||
## [0.3.3](https://github.com/haishanh/yacd/compare/v0.3.2...v0.3.3) (2021-07-19)
|
||||
|
||||
Added:
|
||||
|
||||
- Support switch theme on backend config page
|
||||
- If / is api server, use it as default
|
||||
|
||||
## [0.3.2](https://github.com/haishanh/yacd/compare/v0.3.1...v0.3.2) (2021-06-07)
|
||||
|
||||
Changed:
|
||||
|
||||
- Change web base to './'
|
||||
|
||||
## [0.3.1](https://github.com/haishanh/yacd/compare/v0.3.0...v0.3.1) (2021-06-06)
|
||||
|
||||
Fixed:
|
||||
|
||||
- Fixed floating action button style
|
||||
|
||||
## [0.3.0](https://github.com/haishanh/yacd/compare/v0.2.15...v0.3.0) (2021-06-05)
|
||||
|
||||
Changed:
|
||||
|
||||
- Switch the build system to use Vite. This should not change much about user experience.
|
||||
- Style tweaks:
|
||||
- The light theme now use a light gray background instead of a pure white
|
||||
- Statistic blocks on Overview are now styled more like a card
|
||||
- Log type badges are now ellipse shaped
|
||||
- Config fields are more compact now
|
||||
|
||||
Added:
|
||||
|
||||
- Request logs with configured log level
|
||||
- Reconnect logs web socket on log level config change
|
||||
|
||||
## [0.2.15](https://github.com/haishanh/yacd/compare/v0.2.14...v0.2.15) (2021-02-28)
|
||||
|
||||
Changed:
|
||||
|
||||
- Display API backend info in title only when there are multiple backends
|
||||
- Changed the function of floating action button from refresh to update all providers on rules page
|
||||
|
||||
Added:
|
||||
|
||||
- Action button to update all proxies providers on proxies page
|
||||
|
||||
## [0.2.14](https://github.com/haishanh/yacd/compare/v0.2.13...v0.2.14) (2021-01-04)
|
||||
|
||||
Added:
|
||||
|
||||
- support set default Clash API baseURL with data attribute in HTML template (see [details](https://github.com/haishanh/yacd/pull/550))
|
||||
- add apple-touch-icon\*.png
|
||||
|
||||
Fixed:
|
||||
|
||||
- encode URI for latency test url
|
||||
|
||||
## [0.2.13](https://github.com/haishanh/yacd/compare/v0.2.12...v0.2.13) (2020-12-06)
|
||||
|
||||
Added:
|
||||
|
||||
- Initial Chinese UI language support
|
||||
|
||||
Fixed:
|
||||
|
||||
- Fix weird scroll behavior on config page
|
||||
|
||||
## [0.2.12](https://github.com/haishanh/yacd/compare/v0.2.11...v0.2.12) (2020-11-24)
|
||||
|
||||
Changed:
|
||||
|
||||
- Some minor accessibility improvements
|
||||
- Changed log level display order to `debug warning info error silent`
|
||||
|
||||
## [0.2.11](https://github.com/haishanh/yacd/compare/v0.2.10...v0.2.11) (2020-11-09)
|
||||
|
||||
Changed:
|
||||
|
||||
- Display proxy type "Shadowsocks" as "SS" to make proxy item tile more compact
|
||||
|
||||
## [0.2.10](https://github.com/haishanh/yacd/compare/v0.2.9...v0.2.10) (2020-11-06)
|
||||
|
||||
Added:
|
||||
|
||||
- Precache assets with service worker.
|
||||
|
||||
## [0.2.9](https://github.com/haishanh/yacd/compare/v0.2.8...v0.2.9) (2020-11-01)
|
||||
|
||||
Added:
|
||||
|
||||
- Display current backend host in title.
|
||||
|
||||
Changed:
|
||||
|
||||
- Change backend baseURL default port to 9090.
|
||||
|
||||
## [0.2.8](https://github.com/haishanh/yacd/compare/v0.2.7...v0.2.8) (2020-10-12)
|
||||
|
||||
Added:
|
||||
|
||||
- Better error message for filling API base URL without providing a http protocol prefix.
|
||||
|
||||
## [0.2.7](https://github.com/haishanh/yacd/compare/v0.2.6...v0.2.7) (2020-09-13)
|
||||
|
||||
Added:
|
||||
|
||||
- multi backends management (see "Switch backend" action the the bottom of Config page)
|
||||
|
||||
## [0.2.6](https://github.com/haishanh/yacd/compare/v0.2.5...v0.2.6) (2020-09-08)
|
||||
|
||||
Changed:
|
||||
|
||||
- use API base URL instead of hostname and port for Clash backend config
|
||||
|
||||
## [0.2.5](https://github.com/haishanh/yacd/compare/v0.2.4...v0.2.5) (2020-08-30)
|
||||
|
||||
Added:
|
||||
|
||||
- docker image arm and arm64 support
|
||||
|
||||
## [0.2.4](https://github.com/haishanh/yacd/compare/v0.2.3...v0.2.4) (2020-08-11)
|
||||
|
||||
Fixed:
|
||||
|
||||
- fix cannot change mixed port
|
||||
|
||||
## [0.2.3](https://github.com/haishanh/yacd/compare/v0.2.2...v0.2.3) (2020-08-06)
|
||||
|
||||
Changed:
|
||||
|
||||
- use desc sort first for columns with numeric value in connections table
|
||||
|
||||
## [0.2.2](https://github.com/haishanh/yacd/compare/v0.2.1...v0.2.2) (2020-08-01)
|
||||
|
||||
Added:
|
||||
|
||||
- a simple about page
|
||||
|
||||
Removed:
|
||||
|
||||
- logo in sidebar
|
||||
|
||||
## [0.2.1](https://github.com/haishanh/yacd/compare/v0.2.0...v0.2.1) (2020-07-13)
|
||||
|
||||
Fixed:
|
||||
|
||||
- uri-encode API secret for it to be used in url safely
|
||||
|
||||
## [0.2.0](https://github.com/haishanh/yacd/compare/v0.1.25...v0.2.0) (2020-07-04)
|
||||
|
||||
Added:
|
||||
|
||||
- support rule provider
|
||||
|
||||
## [0.1.25](https://github.com/haishanh/yacd/compare/v0.1.24...v0.1.25) (2020-07-01)
|
||||
|
||||
Added:
|
||||
|
||||
- support mixed-port
|
||||
|
||||
## [0.1.24](https://github.com/haishanh/yacd/compare/v0.1.23...v0.1.24) (2020-06-22)
|
||||
|
||||
Fixed:
|
||||
|
||||
- fix can not type in Chinese in proxy text filter input
|
||||
|
||||
## [0.1.23](https://github.com/haishanh/yacd/compare/v0.1.22...v0.1.23) (2020-06-20)
|
||||
|
||||
Added:
|
||||
|
||||
- add a simple filter for proxy names
|
||||
|
||||
Fixed:
|
||||
|
||||
- fix color display for unavailable proxy item
|
||||
|
||||
## [0.1.22](https://github.com/haishanh/yacd/compare/v0.1.21...v0.1.22) (2020-06-18)
|
||||
|
||||
Fixed:
|
||||
|
||||
- fix mode switching
|
||||
- fix broken "Hide unavailable proxies" setting
|
||||
|
||||
Changed:
|
||||
|
||||
- make proxy group lowest latency item when sorting by latency
|
||||
|
||||
## [0.1.21](https://github.com/haishanh/yacd/compare/v0.1.20...v0.1.21) (2020-06-17)
|
||||
|
||||
Fixed:
|
||||
|
||||
- default to big latency for items with unavailable statistics when sorting
|
||||
|
||||
Added:
|
||||
|
||||
- a toggle to close old connections automatically when switching proxy
|
||||
- use special color for non-proxy summary view dot item
|
||||
|
||||
## [0.1.20](https://github.com/haishanh/yacd/compare/v0.1.19...v0.1.20) (2020-06-08)
|
||||
|
||||
Changed:
|
||||
|
||||
- switch to Open Sans and reduce emitted font files
|
||||
|
||||
## [0.1.19](https://github.com/haishanh/yacd/compare/v0.1.18...v0.1.19) (2020-06-07)
|
||||
|
||||
Added:
|
||||
|
||||
- modal prompt to close previous connections when switch proxy
|
||||
|
||||
Fixed:
|
||||
|
||||
- mode not display correctly due to clash API change
|
||||
|
||||
Changed:
|
||||
|
||||
- switch primary font family from "Merriweather Sans" to "Inter", also starting to self hosting font files
|
||||
|
||||
## [0.1.18](https://github.com/haishanh/yacd/compare/v0.1.17...v0.1.18) (2020-06-04)
|
||||
|
||||
Added:
|
||||
|
||||
- test latency button for each proxy group
|
||||
|
||||
## [0.1.17](https://github.com/haishanh/yacd/compare/v0.1.16...v0.1.17) (2020-06-03)
|
||||
|
||||
Changed:
|
||||
|
||||
- reduce connections table visual width
|
||||
|
||||
## [0.1.16](https://github.com/haishanh/yacd/compare/v0.1.15...v0.1.16) (2020-05-31)
|
||||
|
||||
Added:
|
||||
|
||||
- filtering connections
|
||||
|
||||
## [0.1.15](https://github.com/haishanh/yacd/compare/v0.1.14...v0.1.15) (2020-05-25)
|
||||
|
||||
Added:
|
||||
|
||||
- add loading status to test latency button
|
||||
|
||||
## [0.1.14](https://github.com/haishanh/yacd/compare/v0.1.13...v0.1.14) (2020-05-17)
|
||||
|
||||
Added:
|
||||
|
||||
- button to pause connection refresh
|
||||
|
||||
Fixed:
|
||||
|
||||
- sorting option accessibility issue due to incorrect background in dark mode
|
||||
|
||||
## [0.1.13](https://github.com/haishanh/yacd/compare/v0.1.12...v0.1.13) (2020-05-01)
|
||||
|
||||
Changed:
|
||||
|
||||
- use color icons in sidebar (experimental)
|
||||
|
||||
## [0.1.12](https://github.com/haishanh/yacd/compare/v0.1.11...v0.1.12) (2020-04-26)
|
||||
|
||||
Features:
|
||||
|
||||
- allow change proxies sorting in group
|
||||
|
||||
## [0.1.11](https://github.com/haishanh/yacd/compare/v0.1.10...v0.1.11) (2020-03-21)
|
||||
|
||||
Features:
|
||||
|
||||
- remembers group collapse state
|
||||
|
||||
## [0.1.10](https://github.com/haishanh/yacd/compare/v0.1.9...v0.1.10) (2020-03-14)
|
||||
|
||||
Fixes:
|
||||
|
||||
- fix broken allow-lan switch
|
||||
|
||||
Features:
|
||||
|
||||
- support set theme with querystring `?theme=dark` or `?theme=light`
|
||||
|
||||
## [0.1.9](https://github.com/haishanh/yacd/compare/v0.1.8...v0.1.9) (2020-03-01)
|
||||
|
||||
Fixes:
|
||||
|
||||
- allow request latency for non-original clash proxy types
|
||||
|
||||
## [0.1.8](https://github.com/haishanh/yacd/compare/v0.1.7...v0.1.8) (2020-03-01)
|
||||
|
||||
Features:
|
||||
|
||||
- support overwrite API hostname in querystring with `?hostname=`
|
||||
- show current download/upload speed of connections
|
||||
|
||||
## [0.1.7](https://github.com/haishanh/yacd/compare/v0.1.6...v0.1.7) (2020-02-11)
|
||||
|
||||
Refactor:
|
||||
|
||||
- proxies page UI improvement
|
||||
|
||||
Fixes:
|
||||
|
||||
- use destination ip as host if host is an empty string
|
||||
|
||||
## [0.1.6](https://github.com/haishanh/yacd/compare/v0.1.5...v0.1.6) (2020-01-07)
|
||||
|
||||
Features:
|
||||
|
||||
- keep up to 100 closed connections in another tab
|
||||
|
||||
## [0.1.5](https://github.com/haishanh/yacd/compare/v0.1.4...v0.1.5) (2020-01-04)
|
||||
|
||||
Features:
|
||||
|
||||
- support change latency test url #286
|
||||
|
||||
## [0.1.4](https://github.com/haishanh/yacd/compare/v0.1.3...v0.1.4) (2020-01-03)
|
||||
|
||||
Features:
|
||||
|
||||
- refresh providers and proxies on window regain focus
|
||||
|
||||
Fixes:
|
||||
|
||||
- optimize test latency action when there are providers
|
||||
- do not show provider section when is no provider
|
||||
|
||||
## [0.1.3](https://github.com/haishanh/yacd/compare/v0.1.2...v0.1.3) (2019-12-27)
|
||||
|
||||
Features:
|
||||
|
||||
- can healthcheck a provider
|
||||
|
||||
## [0.1.2](https://github.com/haishanh/yacd/compare/v0.1.1...v0.1.2) (2019-12-22)
|
||||
|
||||
Fixes:
|
||||
|
||||
- typo in connections table header
|
||||
|
||||
## [0.1.1](https://github.com/haishanh/yacd/compare/v0.1.0...v0.1.1) (2019-12-21)
|
||||
|
||||
Fixes:
|
||||
|
||||
- connections table header data miss alignment
|
||||
|
||||
## [0.1.0](https://github.com/haishanh/yacd/compare/v0.0.10...v0.1.0) (2019-12-20)
|
||||
|
||||
Features:
|
||||
|
||||
- support proxy provider
|
||||
|
||||
## [0.0.10](https://github.com/haishanh/yacd/compare/v0.0.9...v0.0.10) (2019-12-04)
|
||||
|
||||
Features:
|
||||
|
||||
- add upload/download total and connectors number on overview
|
||||
|
||||
## [0.0.9](https://github.com/haishanh/yacd/compare/v0.0.8...v0.0.9) (2019-12-02)
|
||||
|
||||
Fix:
|
||||
|
||||
- specify fab group z-index
|
||||
|
||||
## [0.0.8](https://github.com/haishanh/yacd/compare/v0.0.7...v0.0.8) (2019-12-01)
|
||||
|
||||
Features:
|
||||
|
||||
- support close all connections
|
||||
|
||||
## [0.0.7](https://github.com/haishanh/yacd/compare/v0.0.6...v0.0.7) (2019-11-20)
|
||||
|
||||
Features:
|
||||
|
||||
- use history latency data
|
||||
|
||||
## [0.0.6](https://github.com/haishanh/yacd/compare/v0.0.5...v0.0.6) (2019-11-17)
|
||||
|
||||
Improvements:
|
||||
|
||||
- improve UI for small screens
|
||||
- connections: update connections table sorting indicator icon
|
||||
- connections: add place holder when there is no connections data
|
||||
|
||||
## [0.0.5](https://github.com/haishanh/yacd/compare/v0.0.4...v0.0.5) (2019-11-09)
|
||||
|
||||
Features:
|
||||
|
||||
- connections inspection
|
||||
|
||||
## [0.0.4](https://github.com/haishanh/yacd/compare/v0.0.3...v0.0.4) (2019-10-14)
|
||||
|
||||
Features:
|
||||
|
||||
- probing the API server with the given url and auto fill hostname and port
|
||||
|
||||
Internal:
|
||||
|
||||
- upgrade dependencies
|
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
|||
FROM --platform=$BUILDPLATFORM node:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm i -g pnpm
|
||||
COPY pnpm-lock.yaml package.json .
|
||||
RUN pnpm i
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build \
|
||||
# remove source maps - people like small image
|
||||
&& rm public/*.map || true
|
||||
|
||||
FROM --platform=$TARGETPLATFORM nginx:alpine
|
||||
COPY docker/nginx-default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY --from=builder /app/public /usr/share/nginx/html
|
||||
ENV YACD_DEFAULT_BACKEND "http://127.0.0.1:9090"
|
||||
ADD docker-entrypoint.sh /
|
||||
CMD ["/docker-entrypoint.sh"]
|
38
README.md
Normal file
38
README.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
<h1 align="center">
|
||||
<img src="https://user-images.githubusercontent.com/1166872/47954055-97e6cb80-dfc0-11e8-991f-230fd40481e5.png" alt="yacd">
|
||||
</h1>
|
||||
|
||||
> Yet Another [Clash](https://github.com/yaling888/clash) [Dashboard](https://github.com/yaling888/clash-dashboard)
|
||||
|
||||
## Usage
|
||||
|
||||
Install [twemoji](https://github.com/mozilla/twemoji-colr/releases) to display emoji better on Windows system.
|
||||
|
||||
|
||||
The site http://yacd.metacubex.one is served with HTTP not HTTPS is because many browsers block requests to HTTP resources from a HTTPS website. If you think it's not safe, you could just download the [zip of the gh-pages](https://github.com/yaling888/yacd/archive/gh-pages.zip), unzip and serve those static files with a web server(like Nginx).
|
||||
|
||||
**Supported URL query params**
|
||||
|
||||
| Param | Description |
|
||||
| -------- | ---------------------------------------------------------------------------------- |
|
||||
| hostname | Hostname of the clash backend API (usually the host part of `external-controller`) |
|
||||
| port | Port of the clash backend API (usually the port part of `external-controller`) |
|
||||
| secret | Clash API secret (`secret` in your config.yaml) |
|
||||
| theme | UI color scheme (dark, light, auto) |
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
# install dependencies
|
||||
# you may install pnpm with `npm i -g pnpm`
|
||||
pnpm i
|
||||
|
||||
# start the dev server
|
||||
# then go to http://127.0.0.1:3000
|
||||
pnpm start
|
||||
|
||||
|
||||
# build optimized assets
|
||||
# ready to deploy assets will be in the directory `public`
|
||||
pnpm build
|
||||
```
|
1
assets/CNAME
Normal file
1
assets/CNAME
Normal file
|
@ -0,0 +1 @@
|
|||
yacd.metacubex.one
|
12
assets/_headers
Normal file
12
assets/_headers
Normal file
|
@ -0,0 +1,12 @@
|
|||
# for netlify hosting
|
||||
# https://docs.netlify.com/routing/headers/#syntax-for-the-headers-file
|
||||
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: same-origin
|
||||
/*.css
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
/*.js
|
||||
Cache-Control: public, max-age=31536000, immutable
|
BIN
assets/apple-touch-icon-precomposed.png
Normal file
BIN
assets/apple-touch-icon-precomposed.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 79 KiB |
BIN
assets/yacd.ico
Normal file
BIN
assets/yacd.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 177 KiB |
BIN
assets/yacd.png
Normal file
BIN
assets/yacd.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
3
docker-entrypoint.sh
Executable file
3
docker-entrypoint.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
sed -i "s|http://127.0.0.1:9090|$YACD_DEFAULT_BACKEND|" /usr/share/nginx/html/index.html
|
||||
nginx -g "daemon off;"
|
31
docker/nginx-default.conf
Normal file
31
docker/nginx-default.conf
Normal file
|
@ -0,0 +1,31 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
# access_log /var/log/nginx/host.access.log main;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 4;
|
||||
gzip_min_length 256;
|
||||
gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location ~ assets\/.*\.(?:css|js|woff2?|svg|gif|map)$ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "max-age=31536000";
|
||||
# access_log off;
|
||||
}
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
22
index.html
Normal file
22
index.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="yacd.ico" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="yacd.png" />
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="yacd.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="apple-touch-icon-precomposed.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="yacd" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="yacd" />
|
||||
<meta name="description" content="Yet Another Clash Dashboard" />
|
||||
<meta name="theme-color" content="#202020" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f7f7f7" media="(prefers-color-scheme: light)" />
|
||||
<title>yacd</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-base-url="http://127.0.0.1:9090"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
100
package.json
Normal file
100
package.json
Normal file
|
@ -0,0 +1,100 @@
|
|||
{
|
||||
"name": "yacd",
|
||||
"version": "0.3.5",
|
||||
"description": "Yet another Clash dashboard",
|
||||
"author": "Haishan <haishanhan@gmail.com> (https://haishan.me)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"keywords": [
|
||||
"react",
|
||||
"clash"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reach/tooltip": "0.18.0",
|
||||
"@reach/visually-hidden": "0.18.0",
|
||||
"clsx": "^1.2.1",
|
||||
"history": "5.3.0",
|
||||
"invariant": "^2.2.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"memoize-one": "6.0.0",
|
||||
"modern-normalize": "1.1.0",
|
||||
"prop-types": "15.8.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-modal": "3.16.1",
|
||||
"react-tabs": "6.0.0",
|
||||
"react-tiny-fab": "4.0.4",
|
||||
"react-window": "^1.8.8",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"tslib": "2.4.1",
|
||||
"use-asset": "1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "7.20.13",
|
||||
"@fontsource/inter": "4.5.15",
|
||||
"@fontsource/roboto-mono": "4.5.10",
|
||||
"@types/invariant": "2.2.35",
|
||||
"@types/jest": "29.4.0",
|
||||
"@types/lodash-es": "4.17.6",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react-modal": "3.13.1",
|
||||
"@types/react-window": "1.8.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
||||
"@typescript-eslint/parser": "5.49.0",
|
||||
"@vitejs/plugin-react": "3.0.1",
|
||||
"autoprefixer": "10.4.13",
|
||||
"chart.js": "4.2.0",
|
||||
"core-js": "3.27.2",
|
||||
"cssnano": "5.1.14",
|
||||
"date-fns": "2.29.3",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-flowtype": "8.0.3",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-jest": "27.2.1",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-react": "7.32.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "9.0.0",
|
||||
"framer-motion": "8.5.3",
|
||||
"husky": "^8.0.3",
|
||||
"i18next": "22.4.9",
|
||||
"i18next-browser-languagedetector": "7.0.1",
|
||||
"i18next-http-backend": "2.1.1",
|
||||
"immer": "9.0.18",
|
||||
"lint-staged": "^13.1.0",
|
||||
"postcss": "8.4.21",
|
||||
"postcss-preset-env": "^8.0.0",
|
||||
"prettier": "2.8.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "12.1.4",
|
||||
"react-icons": "4.7.1",
|
||||
"react-query": "3.39.3",
|
||||
"react-router": "6.8.0",
|
||||
"react-router-dom": "6.8.0",
|
||||
"react-switch": "7.0.0",
|
||||
"react-table": "7.8.0",
|
||||
"recoil": "0.7.6",
|
||||
"reselect": "4.1.7",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "1.57.1",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "4.0.4",
|
||||
"vite-plugin-pwa": "0.14.1",
|
||||
"workbox-core": "6.5.4",
|
||||
"workbox-expiration": "6.5.4",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"workbox-routing": "6.5.4",
|
||||
"workbox-strategies": "6.5.4"
|
||||
}
|
||||
}
|
7350
pnpm-lock.yaml
Normal file
7350
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
4
postcss.config.js
Normal file
4
postcss.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
module.exports = {
|
||||
plugins: [require('postcss-preset-env')()],
|
||||
};
|
20
src/App.module.scss
Normal file
20
src/App.module.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
.app {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
75
src/App.tsx
Normal file
75
src/App.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import * as React from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import { HashRouter as Router, Route, RouteObject, Routes, useRoutes } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import APIConfig from '~/components//APIConfig';
|
||||
import { About } from '~/components/about/About';
|
||||
import APIDiscovery from '~/components/APIDiscovery';
|
||||
import ErrorBoundary from '~/components/ErrorBoundary';
|
||||
import Home from '~/components/Home';
|
||||
import Loading from '~/components/Loading';
|
||||
import Loading2 from '~/components/Loading2';
|
||||
import { Head } from '~/components/shared/Head';
|
||||
import SideBar from '~/components/SideBar';
|
||||
import StateProvider from '~/components/StateProvider';
|
||||
import StyleGuide from '~/components/StyleGuide';
|
||||
import { queryClient } from '~/misc/query';
|
||||
import { actions, initialState } from '~/store';
|
||||
|
||||
import styles from './App.module.scss';
|
||||
|
||||
const { lazy, Suspense } = React;
|
||||
|
||||
const Connections = lazy(() => import('~/components/Connections'));
|
||||
const Config = lazy(() => import('~/components/Config'));
|
||||
const Logs = lazy(() => import('~/components/Logs'));
|
||||
const Proxies = lazy(() => import('~/components/proxies/Proxies'));
|
||||
const Rules = lazy(() => import('~/components/Rules'));
|
||||
|
||||
const routes = [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/connections', element: <Connections /> },
|
||||
{ path: '/configs', element: <Config /> },
|
||||
{ path: '/logs', element: <Logs /> },
|
||||
{ path: '/proxies', element: <Proxies /> },
|
||||
{ path: '/rules', element: <Rules /> },
|
||||
{ path: '/about', element: <About /> },
|
||||
process.env.NODE_ENV === 'development' ? { path: '/style', element: <StyleGuide /> } : false,
|
||||
].filter(Boolean) as RouteObject[];
|
||||
|
||||
function SideBarApp() {
|
||||
return (
|
||||
<>
|
||||
<APIDiscovery />
|
||||
<SideBar />
|
||||
<div className={styles.content}>
|
||||
<Suspense fallback={<Loading2 />}>{useRoutes(routes)}</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<ErrorBoundary>
|
||||
<RecoilRoot>
|
||||
<StateProvider initialState={initialState} actions={actions}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className={styles.app}>
|
||||
<Head />
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/backend" element={<APIConfig />} />
|
||||
<Route path="*" element={<SideBarApp />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Suspense>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</StateProvider>
|
||||
</RecoilRoot>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default App;
|
48
src/api/configs.ts
Normal file
48
src/api/configs.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { getURLAndInit } from '~/misc/request-helper';
|
||||
import { ClashGeneralConfig, TunPartial } from '~/store/types';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
const endpoint = '/configs';
|
||||
const updateGeoDatabasesFileEndpoint = '/configs/geo';
|
||||
const flushFakeIPPoolEndpoint = '/cache/fakeip/flush';
|
||||
|
||||
export async function fetchConfigs(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
return await fetch(url + endpoint, init);
|
||||
}
|
||||
|
||||
// TODO support PUT /configs
|
||||
// req body
|
||||
// { Path: string }
|
||||
|
||||
type ClashConfigPartial = TunPartial<ClashGeneralConfig>;
|
||||
function configsPatchWorkaround(o: ClashConfigPartial) {
|
||||
// backward compatibility for older clash using `socket-port`
|
||||
if ('socks-port' in o) {
|
||||
o['socket-port'] = o['socks-port'];
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
export async function updateConfigs(apiConfig: ClashAPIConfig, o: ClashConfigPartial) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const body = JSON.stringify(configsPatchWorkaround(o));
|
||||
return await fetch(url + endpoint, { ...init, body, method: 'PATCH' });
|
||||
}
|
||||
|
||||
export async function reloadConfigFile(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const body = '{"path": "", "payload": ""}';
|
||||
return await fetch(url + endpoint + '?force=true', { ...init, body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export async function updateGeoDatabasesFile(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const body = '{"path": "", "payload": ""}';
|
||||
return await fetch(url + updateGeoDatabasesFileEndpoint, { ...init, body, method: 'POST' });
|
||||
}
|
||||
|
||||
export async function flushFakeIPPool(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
return await fetch(url + flushFakeIPPoolEndpoint, { ...init, method: 'POST' });
|
||||
}
|
91
src/api/connections.ts
Normal file
91
src/api/connections.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import { buildWebSocketURL, getURLAndInit } from '../misc/request-helper';
|
||||
|
||||
const endpoint = '/connections';
|
||||
|
||||
const fetched = false;
|
||||
const subscribers = [];
|
||||
|
||||
// see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41
|
||||
type UUID = string;
|
||||
type ConnNetwork = 'tcp' | 'udp';
|
||||
type ConnType = 'HTTP' | 'HTTP Connect' | 'Socks5' | 'Redir' | 'Unknown';
|
||||
export type ConnectionItem = {
|
||||
id: UUID;
|
||||
metadata: {
|
||||
network: ConnNetwork;
|
||||
type: ConnType;
|
||||
sourceIP: string;
|
||||
destinationIP: string;
|
||||
remoteDestination: string;
|
||||
sourcePort: string;
|
||||
destinationPort: string;
|
||||
host: string;
|
||||
process?: string;
|
||||
sniffHost?: string;
|
||||
};
|
||||
upload: number;
|
||||
download: number;
|
||||
// e.g. "2019-11-30T22:48:13.416668+08:00",
|
||||
start: string;
|
||||
chains: string[];
|
||||
// e.g. 'Match', 'DomainKeyword'
|
||||
rule: string;
|
||||
rulePayload?: string;
|
||||
};
|
||||
type ConnectionsData = {
|
||||
downloadTotal: number;
|
||||
uploadTotal: number;
|
||||
connections: Array<ConnectionItem>;
|
||||
};
|
||||
|
||||
function appendData(s: string) {
|
||||
let o: ConnectionsData;
|
||||
try {
|
||||
o = JSON.parse(s);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('JSON.parse error', JSON.parse(s));
|
||||
}
|
||||
subscribers.forEach((f) => f(o));
|
||||
}
|
||||
|
||||
type UnsubscribeFn = () => void;
|
||||
|
||||
let wsState: number;
|
||||
export function fetchData(apiConfig: ClashAPIConfig, listener: unknown): UnsubscribeFn | void {
|
||||
if (fetched || wsState === 1) {
|
||||
if (listener) return subscribe(listener);
|
||||
}
|
||||
wsState = 1;
|
||||
const url = buildWebSocketURL(apiConfig, endpoint);
|
||||
const ws = new WebSocket(url);
|
||||
ws.addEventListener('error', () => (wsState = 3));
|
||||
ws.addEventListener('message', (event) => appendData(event.data));
|
||||
if (listener) return subscribe(listener);
|
||||
}
|
||||
|
||||
function subscribe(listener: unknown): UnsubscribeFn {
|
||||
subscribers.push(listener);
|
||||
return function unsubscribe() {
|
||||
const idx = subscribers.indexOf(listener);
|
||||
subscribers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeAllConnections(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
return await fetch(url + endpoint, { ...init, method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchConns(apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
return await fetch(url + endpoint, { ...init });
|
||||
}
|
||||
|
||||
export async function closeConnById(apiConfig: ClashAPIConfig, id: string) {
|
||||
const { url: baseURL, init } = getURLAndInit(apiConfig);
|
||||
const url = `${baseURL}${endpoint}/${id}`;
|
||||
return await fetch(url, { ...init, method: 'DELETE' });
|
||||
}
|
151
src/api/logs.ts
Normal file
151
src/api/logs.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { pad0 } from '~/misc/utils';
|
||||
import { Log } from '~/store/types';
|
||||
import { LogsAPIConfig } from '~/types';
|
||||
|
||||
import { buildLogsWebSocketURL, getURLAndInit } from '../misc/request-helper';
|
||||
|
||||
type AppendLogFn = (x: Log) => void;
|
||||
enum WebSocketReadyState {
|
||||
Connecting = 0,
|
||||
Open = 1,
|
||||
Closing = 2,
|
||||
Closed = 3,
|
||||
}
|
||||
|
||||
const endpoint = '/logs';
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
|
||||
const getRandomStr = () => {
|
||||
return Math.floor((1 + Math.random()) * 0x10000).toString(16);
|
||||
};
|
||||
|
||||
let even = false;
|
||||
let fetched = false;
|
||||
let decoded = '';
|
||||
let ws: WebSocket;
|
||||
let prevAppendLogFn: AppendLogFn;
|
||||
|
||||
function appendData(s: string, callback: AppendLogFn) {
|
||||
let o: Partial<Log>;
|
||||
try {
|
||||
o = JSON.parse(s);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('JSON.parse error', JSON.parse(s));
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const time = formatDate(now);
|
||||
// mutate input param in place intentionally
|
||||
o.time = time;
|
||||
o.id = +now - 0 + getRandomStr();
|
||||
o.even = even = !even;
|
||||
callback(o as Log);
|
||||
}
|
||||
|
||||
function formatDate(d: Date) {
|
||||
// 19-03-09 12:45
|
||||
const YY = d.getFullYear() % 100;
|
||||
const MM = pad0(d.getMonth() + 1, 2);
|
||||
const dd = pad0(d.getDate(), 2);
|
||||
const HH = pad0(d.getHours(), 2);
|
||||
const mm = pad0(d.getMinutes(), 2);
|
||||
const ss = pad0(d.getSeconds(), 2);
|
||||
return `${YY}-${MM}-${dd} ${HH}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function pump(reader: ReadableStreamDefaultReader, appendLog: AppendLogFn) {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
const str = textDecoder.decode(value, { stream: !done });
|
||||
decoded += str;
|
||||
|
||||
const splits = decoded.split('\n');
|
||||
|
||||
const lastSplit = splits[splits.length - 1];
|
||||
|
||||
for (let i = 0; i < splits.length - 1; i++) {
|
||||
appendData(splits[i], appendLog);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
appendData(lastSplit, appendLog);
|
||||
decoded = '';
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('GET /logs streaming done');
|
||||
fetched = false;
|
||||
return;
|
||||
} else {
|
||||
decoded = lastSplit;
|
||||
}
|
||||
return pump(reader, appendLog);
|
||||
});
|
||||
}
|
||||
|
||||
/** loose hashing of the connection configuration */
|
||||
function makeConnStr(c: LogsAPIConfig) {
|
||||
const keys = Object.keys(c);
|
||||
keys.sort();
|
||||
return keys.map((k) => c[k]).join('|');
|
||||
}
|
||||
|
||||
let prevConnStr: string;
|
||||
let controller: AbortController;
|
||||
|
||||
export function fetchLogs(apiConfig: LogsAPIConfig, appendLog: AppendLogFn) {
|
||||
if (apiConfig.logLevel === 'uninit') return;
|
||||
if (fetched || (ws && ws.readyState === WebSocketReadyState.Open)) return;
|
||||
prevAppendLogFn = appendLog;
|
||||
const url = buildLogsWebSocketURL(apiConfig, endpoint);
|
||||
ws = new WebSocket(url);
|
||||
ws.addEventListener('error', () => {
|
||||
fetchLogsWithFetch(apiConfig, appendLog);
|
||||
});
|
||||
ws.addEventListener('message', function (event) {
|
||||
appendData(event.data, appendLog);
|
||||
});
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
ws.close();
|
||||
if (controller) controller.abort();
|
||||
}
|
||||
|
||||
export function reconnect(apiConfig: LogsAPIConfig) {
|
||||
if (!prevAppendLogFn || !ws) return;
|
||||
ws.close();
|
||||
fetched = false;
|
||||
fetchLogs(apiConfig, prevAppendLogFn);
|
||||
}
|
||||
|
||||
function fetchLogsWithFetch(apiConfig: LogsAPIConfig, appendLog: AppendLogFn) {
|
||||
if (controller && makeConnStr(apiConfig) !== prevConnStr) {
|
||||
controller.abort();
|
||||
} else if (fetched) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetched = true;
|
||||
prevConnStr = makeConnStr(apiConfig);
|
||||
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
fetch(url + endpoint + '?level=' + apiConfig.logLevel, {
|
||||
...init,
|
||||
signal,
|
||||
}).then(
|
||||
(response) => {
|
||||
const reader = response.body.getReader();
|
||||
pump(reader, appendLog);
|
||||
},
|
||||
(err) => {
|
||||
fetched = false;
|
||||
if (signal.aborted) return;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('GET /logs error:', err.message);
|
||||
}
|
||||
);
|
||||
}
|
78
src/api/proxies.ts
Normal file
78
src/api/proxies.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { getURLAndInit } from '../misc/request-helper';
|
||||
|
||||
const endpoint = '/proxies';
|
||||
|
||||
/*
|
||||
$ curl "http://127.0.0.1:8080/proxies/Proxy" -XPUT -d '{ "name": "ss3" }' -i
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
{"error":"Selector update error: Proxy does not exist"}
|
||||
|
||||
~
|
||||
$ curl "http://127.0.0.1:8080/proxies/GLOBAL" -XPUT -d '{ "name": "Proxy" }' -i
|
||||
HTTP/1.1 204 No Content
|
||||
*/
|
||||
|
||||
export async function fetchProxies(config) {
|
||||
const { url, init } = getURLAndInit(config);
|
||||
const res = await fetch(url + endpoint, init);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function requestToSwitchProxy(apiConfig, name1, name2) {
|
||||
const body = { name: name2 };
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const fullURL = `${url}${endpoint}/${name1}`;
|
||||
return await fetch(fullURL, {
|
||||
...init,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestDelayForProxy(
|
||||
apiConfig,
|
||||
name,
|
||||
latencyTestUrl = 'http://www.gstatic.com/generate_204'
|
||||
) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const qs = `timeout=5000&url=${encodeURIComponent(latencyTestUrl)}`;
|
||||
const fullURL = `${url}${endpoint}/${encodeURIComponent(name)}/delay?${qs}`;
|
||||
return await fetch(fullURL, init);
|
||||
}
|
||||
|
||||
export async function requestDelayForProxyGroup(
|
||||
apiConfig,
|
||||
name,
|
||||
latencyTestUrl = 'http://www.gstatic.com/generate_202'
|
||||
) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const qs = `url=${encodeURIComponent(latencyTestUrl)}&timeout=2000`;
|
||||
const fullUrl = `${url}/group/${encodeURIComponent(name)}/delay?${qs}`;
|
||||
return await fetch(fullUrl, init);
|
||||
}
|
||||
|
||||
export async function fetchProviderProxies(config) {
|
||||
const { url, init } = getURLAndInit(config);
|
||||
const res = await fetch(url + '/providers/proxies', init);
|
||||
if (res.status === 404) {
|
||||
return { providers: {} };
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function updateProviderByName(config, name) {
|
||||
const { url, init } = getURLAndInit(config);
|
||||
const options = { ...init, method: 'PUT' };
|
||||
return await fetch(url + '/providers/proxies/' + encodeURIComponent(name), options);
|
||||
}
|
||||
|
||||
export async function healthcheckProviderByName(config, name) {
|
||||
const { url, init } = getURLAndInit(config);
|
||||
const options = { ...init, method: 'GET' };
|
||||
return await fetch(
|
||||
url + '/providers/proxies/' + encodeURIComponent(name) + '/healthcheck',
|
||||
options
|
||||
);
|
||||
}
|
84
src/api/rule-provider.ts
Normal file
84
src/api/rule-provider.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { getURLAndInit } from '~/misc/request-helper';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
export type RuleProvider = RuleProviderAPIItem & { idx: number };
|
||||
|
||||
export type RuleProviderAPIItem = {
|
||||
behavior: string;
|
||||
name: string;
|
||||
ruleCount: number;
|
||||
type: 'Rule';
|
||||
// example value "2020-06-30T16:23:01.44143802+08:00"
|
||||
updatedAt: string;
|
||||
vehicleType: 'HTTP' | 'File';
|
||||
};
|
||||
|
||||
type RuleProviderAPIData = {
|
||||
providers: Record<string, RuleProviderAPIItem>;
|
||||
};
|
||||
|
||||
function normalizeAPIResponse(data: RuleProviderAPIData) {
|
||||
const providers = data.providers;
|
||||
const names = Object.keys(providers);
|
||||
const byName: Record<string, RuleProvider> = {};
|
||||
|
||||
// attach an idx to each item
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i];
|
||||
byName[name] = { ...providers[name], idx: i };
|
||||
}
|
||||
|
||||
return { byName, names };
|
||||
}
|
||||
|
||||
export async function fetchRuleProviders(endpoint: string, apiConfig: ClashAPIConfig) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
|
||||
let data = { providers: {} };
|
||||
try {
|
||||
const res = await fetch(url + endpoint, init);
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
}
|
||||
} catch (err) {
|
||||
// log and ignore
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('failed to GET /providers/rules', err);
|
||||
}
|
||||
return normalizeAPIResponse(data);
|
||||
}
|
||||
|
||||
export async function refreshRuleProviderByName({
|
||||
name,
|
||||
apiConfig,
|
||||
}: {
|
||||
name: string;
|
||||
apiConfig: ClashAPIConfig;
|
||||
}) {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
try {
|
||||
const res = await fetch(url + `/providers/rules/${name}`, {
|
||||
method: 'PUT',
|
||||
...init,
|
||||
});
|
||||
return res.ok;
|
||||
} catch (err) {
|
||||
// log and ignore
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('failed to PUT /providers/rules/:name', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRuleProviders({
|
||||
names,
|
||||
apiConfig,
|
||||
}: {
|
||||
names: string[];
|
||||
apiConfig: ClashAPIConfig;
|
||||
}) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
// run in sequence
|
||||
await refreshRuleProviderByName({ name: names[i], apiConfig });
|
||||
}
|
||||
}
|
40
src/api/rules.ts
Normal file
40
src/api/rules.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import invariant from 'invariant';
|
||||
|
||||
import { getURLAndInit } from '~/misc/request-helper';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
// const endpoint = '/rules';
|
||||
|
||||
type RuleItem = RuleAPIItem & { id: number };
|
||||
|
||||
type RuleAPIItem = {
|
||||
type: string;
|
||||
payload: string;
|
||||
proxy: string;
|
||||
};
|
||||
|
||||
function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleItem> {
|
||||
invariant(
|
||||
json.rules && json.rules.length >= 0,
|
||||
'there is no valid rules list in the rules API response'
|
||||
);
|
||||
|
||||
// attach an id
|
||||
return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i }));
|
||||
}
|
||||
|
||||
export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) {
|
||||
let json = { rules: [] };
|
||||
try {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const res = await fetch(url + endpoint, init);
|
||||
if (res.ok) {
|
||||
json = await res.json();
|
||||
}
|
||||
} catch (err) {
|
||||
// log and ignore
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('failed to fetch rules', err);
|
||||
}
|
||||
return normalizeAPIResponse(json);
|
||||
}
|
119
src/api/traffic.ts
Normal file
119
src/api/traffic.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import { buildWebSocketURL, getURLAndInit } from '../misc/request-helper';
|
||||
|
||||
const endpoint = '/traffic';
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
|
||||
const Size = 150;
|
||||
|
||||
const traffic = {
|
||||
labels: Array(Size).fill(0),
|
||||
up: Array(Size),
|
||||
down: Array(Size),
|
||||
|
||||
size: Size,
|
||||
subscribers: [],
|
||||
appendData(o: { up: number; down: number }) {
|
||||
this.up.shift();
|
||||
this.down.shift();
|
||||
this.labels.shift();
|
||||
|
||||
const l = Date.now();
|
||||
this.up.push(o.up);
|
||||
this.down.push(o.down);
|
||||
this.labels.push(l);
|
||||
|
||||
this.subscribers.forEach((f) => f(o));
|
||||
},
|
||||
|
||||
subscribe(listener: (x: any) => void) {
|
||||
this.subscribers.push(listener);
|
||||
return () => {
|
||||
const idx = this.subscribers.indexOf(listener);
|
||||
this.subscribers.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let fetched = false;
|
||||
let decoded = '';
|
||||
|
||||
function parseAndAppend(x: string) {
|
||||
traffic.appendData(JSON.parse(x));
|
||||
}
|
||||
|
||||
function pump(reader: ReadableStreamDefaultReader) {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
const str = textDecoder.decode(value, { stream: !done });
|
||||
decoded += str;
|
||||
|
||||
const splits = decoded.split('\n');
|
||||
|
||||
const lastSplit = splits[splits.length - 1];
|
||||
|
||||
for (let i = 0; i < splits.length - 1; i++) {
|
||||
parseAndAppend(splits[i]);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
parseAndAppend(lastSplit);
|
||||
decoded = '';
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('GET /traffic streaming done');
|
||||
fetched = false;
|
||||
return;
|
||||
} else {
|
||||
decoded = lastSplit;
|
||||
}
|
||||
return pump(reader);
|
||||
});
|
||||
}
|
||||
|
||||
// 1 OPEN
|
||||
// other value CLOSED
|
||||
// similar to ws readyState but not the same
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
|
||||
let wsState: number;
|
||||
function fetchData(apiConfig: ClashAPIConfig) {
|
||||
if (fetched || wsState === 1) return traffic;
|
||||
wsState = 1;
|
||||
const url = buildWebSocketURL(apiConfig, endpoint);
|
||||
const ws = new WebSocket(url);
|
||||
ws.addEventListener('error', function (_ev) {
|
||||
wsState = 3;
|
||||
});
|
||||
ws.addEventListener('close', function (_ev) {
|
||||
wsState = 3;
|
||||
fetchDataWithFetch(apiConfig);
|
||||
});
|
||||
ws.addEventListener('message', function (event) {
|
||||
parseAndAppend(event.data);
|
||||
});
|
||||
return traffic;
|
||||
}
|
||||
|
||||
function fetchDataWithFetch(apiConfig: ClashAPIConfig) {
|
||||
if (fetched) return traffic;
|
||||
fetched = true;
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
fetch(url + endpoint, init).then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
const reader = response.body.getReader();
|
||||
pump(reader);
|
||||
} else {
|
||||
fetched = false;
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('fetch /traffic error', err);
|
||||
fetched = false;
|
||||
}
|
||||
);
|
||||
return traffic;
|
||||
}
|
||||
|
||||
export { fetchData };
|
27
src/api/version.ts
Normal file
27
src/api/version.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { getURLAndInit } from '~/misc/request-helper';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
type VersionData = {
|
||||
version?: string;
|
||||
premium?: boolean;
|
||||
meta?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchVersion(
|
||||
endpoint: string,
|
||||
apiConfig: ClashAPIConfig
|
||||
): Promise<VersionData> {
|
||||
let json = {};
|
||||
try {
|
||||
const { url, init } = getURLAndInit(apiConfig);
|
||||
const res = await fetch(url + endpoint, init);
|
||||
if (res.ok) {
|
||||
json = await res.json();
|
||||
}
|
||||
} catch (err) {
|
||||
// log and ignore
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`failed to fetch ${endpoint}`, err);
|
||||
}
|
||||
return json;
|
||||
}
|
52
src/components/APIConfig.module.scss
Normal file
52
src/components/APIConfig.module.scss
Normal file
|
@ -0,0 +1,52 @@
|
|||
.root {
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
--stroke: #f3f3f3;
|
||||
color: #20497e;
|
||||
opacity: 0.7;
|
||||
transition: opacity 400ms;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 15px 0 0;
|
||||
}
|
||||
|
||||
.hostnamePort {
|
||||
display: flex;
|
||||
|
||||
div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
div:nth-child(2) {
|
||||
flex-grow: 0;
|
||||
flex-basis: 120px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
height: 20px;
|
||||
font-size: 0.8em;
|
||||
color: #ff8b8b;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 5px 0 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
162
src/components/APIConfig.tsx
Normal file
162
src/components/APIConfig.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { fetchConfigs } from '~/api/configs';
|
||||
import { BackendList } from '~/components/BackendList';
|
||||
import { addClashAPIConfig, getClashAPIConfig } from '~/store/app';
|
||||
import { State } from '~/store/types';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import s0 from './APIConfig.module.scss';
|
||||
import Button from './Button';
|
||||
import Field from './Field';
|
||||
import { connect } from './StateProvider';
|
||||
import SvgYacd from './SvgYacd';
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = React;
|
||||
const Ok = 0;
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
function APIConfig({ dispatch }) {
|
||||
const [baseURL, setBaseURL] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
|
||||
const userTouchedFlagRef = useRef(false);
|
||||
const contentEl = useRef(null);
|
||||
|
||||
const handleInputOnChange = useCallback((e) => {
|
||||
userTouchedFlagRef.current = true;
|
||||
setErrMsg('');
|
||||
const target = e.target;
|
||||
const { name } = target;
|
||||
const value = target.value;
|
||||
switch (name) {
|
||||
case 'baseURL':
|
||||
setBaseURL(value);
|
||||
break;
|
||||
case 'secret':
|
||||
setSecret(value);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown input name ${name}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
let unconfirmedBaseURL = baseURL;
|
||||
if (unconfirmedBaseURL) {
|
||||
const prefix = baseURL.substring(0, 7);
|
||||
if (prefix.includes(':/')) {
|
||||
// same logic in verify function
|
||||
if (prefix !== 'http://' && prefix !== 'https:/') {
|
||||
return [1, 'Must starts with http:// or https://'];
|
||||
}
|
||||
} else if (window.location.protocol) {
|
||||
// only append scheme when prefix does not include scheme and current location includes scheme
|
||||
unconfirmedBaseURL = `${window.location.protocol}//${unconfirmedBaseURL}`;
|
||||
}
|
||||
}
|
||||
verify({ baseURL: unconfirmedBaseURL, secret }).then((ret) => {
|
||||
if (ret[0] !== Ok) {
|
||||
setErrMsg(ret[1]);
|
||||
} else {
|
||||
dispatch(addClashAPIConfig({ baseURL: unconfirmedBaseURL, secret }));
|
||||
}
|
||||
});
|
||||
}, [baseURL, secret, dispatch]);
|
||||
|
||||
const handleContentOnKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
(!e.target.tagName || e.target.tagName.toUpperCase() !== 'INPUT')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
|
||||
onConfirm();
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
const detectApiServer = async () => {
|
||||
// if there is already a clash API server at `/`, just use it as default value
|
||||
const res = await fetch('/');
|
||||
res.json().then((data) => {
|
||||
if (data['hello'] === 'clash') {
|
||||
setBaseURL(window.location.origin);
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
detectApiServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div className={s0.root} ref={contentEl} onKeyDown={handleContentOnKeyDown}>
|
||||
<div className={s0.header}>
|
||||
<div className={s0.icon}>
|
||||
<SvgYacd width={160} height={160} stroke="var(--stroke)" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s0.body}>
|
||||
<div className={s0.hostnamePort}>
|
||||
<Field
|
||||
id="baseURL"
|
||||
name="baseURL"
|
||||
label="API Base URL"
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:9090"
|
||||
value={baseURL}
|
||||
onChange={handleInputOnChange}
|
||||
/>
|
||||
<Field
|
||||
id="secret"
|
||||
name="secret"
|
||||
label="Secret(optional)"
|
||||
value={secret}
|
||||
type="text"
|
||||
onChange={handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s0.error}>{errMsg ? errMsg : null}</div>
|
||||
<div className={s0.footer}>
|
||||
<Button label="Add" onClick={onConfirm} />
|
||||
</div>
|
||||
<div style={{ height: 20 }} />
|
||||
<BackendList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapState)(APIConfig);
|
||||
|
||||
async function verify(apiConfig: ClashAPIConfig): Promise<[number, string?]> {
|
||||
try {
|
||||
new URL(apiConfig.baseURL);
|
||||
} catch (e) {
|
||||
if (apiConfig.baseURL) {
|
||||
const prefix = apiConfig.baseURL.substring(0, 7);
|
||||
if (prefix !== 'http://' && prefix !== 'https:/') {
|
||||
return [1, 'Must starts with http:// or https://'];
|
||||
}
|
||||
}
|
||||
|
||||
return [1, 'Invalid URL'];
|
||||
}
|
||||
try {
|
||||
const res = await fetchConfigs(apiConfig);
|
||||
if (res.status > 399) {
|
||||
return [1, res.statusText];
|
||||
}
|
||||
return [Ok];
|
||||
} catch (e) {
|
||||
return [1, 'Failed to connect'];
|
||||
}
|
||||
}
|
33
src/components/APIDiscovery.module.scss
Normal file
33
src/components/APIDiscovery.module.scss
Normal file
|
@ -0,0 +1,33 @@
|
|||
.content.content {
|
||||
background: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
transform: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.overlay.overlay {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
padding: 16px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
58
src/components/APIDiscovery.tsx
Normal file
58
src/components/APIDiscovery.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher';
|
||||
import { DOES_NOT_SUPPORT_FETCH, errors } from '~/misc/errors';
|
||||
import { getClashAPIConfig } from '~/store/app';
|
||||
import { fetchConfigs } from '~/store/configs';
|
||||
import { closeModal } from '~/store/modals';
|
||||
import { State } from '~/store/types';
|
||||
|
||||
import APIConfig from './APIConfig';
|
||||
import s0 from './APIDiscovery.module.scss';
|
||||
import Modal from './Modal';
|
||||
import { connect } from './StateProvider';
|
||||
|
||||
const { useCallback, useEffect } = React;
|
||||
|
||||
function APIDiscovery({ dispatch, apiConfig, modals }) {
|
||||
if (!window.fetch) {
|
||||
const { detail } = errors[DOES_NOT_SUPPORT_FETCH];
|
||||
const err = new Error(detail);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'code' does not exist on type 'Error'.
|
||||
err.code = DOES_NOT_SUPPORT_FETCH;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const closeApiConfigModal = useCallback(() => {
|
||||
dispatch(closeModal('apiConfig'));
|
||||
}, [dispatch]);
|
||||
useEffect(() => {
|
||||
dispatch(fetchConfigs(apiConfig));
|
||||
}, [dispatch, apiConfig]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={modals.apiConfig}
|
||||
className={s0.content}
|
||||
overlayClassName={s0.overlay}
|
||||
shouldCloseOnOverlayClick={false}
|
||||
shouldCloseOnEsc={false}
|
||||
onRequestClose={closeApiConfigModal}
|
||||
>
|
||||
<div className={s0.container}>
|
||||
<APIConfig />
|
||||
</div>
|
||||
|
||||
<div className={s0.fixed}>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
modals: s.modals,
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(APIDiscovery);
|
102
src/components/BackendList.module.scss
Normal file
102
src/components/BackendList.module.scss
Normal file
|
@ -0,0 +1,102 @@
|
|||
.ul {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
line-height: 1.8;
|
||||
|
||||
--width-max-content: 230px;
|
||||
}
|
||||
|
||||
.li {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
padding: 10px 0;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
grid-template-rows: 30px;
|
||||
grid-template-areas: 'close url .';
|
||||
column-gap: 10px;
|
||||
border: 1px solid var(--bg-near-transparent);
|
||||
}
|
||||
|
||||
.li:hover {
|
||||
background-color: var(--bg-near-transparent);
|
||||
}
|
||||
|
||||
.close {
|
||||
opacity: 0;
|
||||
grid-area: close;
|
||||
place-self: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.li:hover .close,
|
||||
.li:hover .eye {
|
||||
opacity: 1;
|
||||
}
|
||||
.close:focus,
|
||||
.eye:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hasSecret {
|
||||
grid-template-rows: repeat(2, 30px);
|
||||
grid-template-areas:
|
||||
'close url .'
|
||||
'close secret eye';
|
||||
}
|
||||
|
||||
.url {
|
||||
grid-area: url;
|
||||
}
|
||||
.secret {
|
||||
grid-area: secret;
|
||||
}
|
||||
.eye {
|
||||
grid-area: eye;
|
||||
opacity: 0;
|
||||
place-self: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.url,
|
||||
.secret {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.btn {
|
||||
outline: none;
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
.btn:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
.btn:hover:enabled {
|
||||
background-color: var(--color-focus-blue);
|
||||
color: white;
|
||||
}
|
||||
.btn:active:enabled {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.url {
|
||||
cursor: pointer;
|
||||
}
|
||||
.url:hover {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
142
src/components/BackendList.tsx
Normal file
142
src/components/BackendList.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { Eye, EyeOff, X as Close } from 'react-feather';
|
||||
|
||||
import { useToggle } from '~/hooks/basic';
|
||||
import { getClashAPIConfigs, getSelectedClashAPIConfigIndex } from '~/store/app';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import s from './BackendList.module.scss';
|
||||
import { connect, useStoreActions } from './StateProvider';
|
||||
|
||||
type Config = ClashAPIConfig & { addedAt: number };
|
||||
|
||||
const mapState = (s) => ({
|
||||
apiConfigs: getClashAPIConfigs(s),
|
||||
selectedClashAPIConfigIndex: getSelectedClashAPIConfigIndex(s),
|
||||
});
|
||||
|
||||
export const BackendList = connect(mapState)(BackendListImpl);
|
||||
|
||||
function BackendListImpl({
|
||||
apiConfigs,
|
||||
selectedClashAPIConfigIndex,
|
||||
}: {
|
||||
apiConfigs: Config[];
|
||||
selectedClashAPIConfigIndex: number;
|
||||
}) {
|
||||
const {
|
||||
app: { removeClashAPIConfig, selectClashAPIConfig },
|
||||
} = useStoreActions();
|
||||
|
||||
const onRemove = React.useCallback(
|
||||
(conf: ClashAPIConfig) => {
|
||||
removeClashAPIConfig(conf);
|
||||
},
|
||||
[removeClashAPIConfig]
|
||||
);
|
||||
const onSelect = React.useCallback(
|
||||
(conf: ClashAPIConfig) => {
|
||||
selectClashAPIConfig(conf);
|
||||
},
|
||||
[selectClashAPIConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={s.ul}>
|
||||
{apiConfigs.map((item, idx) => {
|
||||
return (
|
||||
<li
|
||||
className={cx(s.li, {
|
||||
[s.hasSecret]: item.secret,
|
||||
[s.isSelected]: idx === selectedClashAPIConfigIndex,
|
||||
})}
|
||||
key={item.baseURL + item.secret}
|
||||
>
|
||||
<Item
|
||||
disableRemove={idx === selectedClashAPIConfigIndex}
|
||||
baseURL={item.baseURL}
|
||||
secret={item.secret}
|
||||
onRemove={onRemove}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
baseURL,
|
||||
secret,
|
||||
disableRemove,
|
||||
onRemove,
|
||||
onSelect,
|
||||
}: {
|
||||
baseURL: string;
|
||||
secret: string;
|
||||
disableRemove: boolean;
|
||||
onRemove: (x: ClashAPIConfig) => void;
|
||||
onSelect: (x: ClashAPIConfig) => void;
|
||||
}) {
|
||||
const [show, toggle] = useToggle();
|
||||
const Icon = show ? EyeOff : Eye;
|
||||
|
||||
const handleTap = React.useCallback((e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableRemove}
|
||||
onClick={() => onRemove({ baseURL, secret })}
|
||||
className={s.close}
|
||||
>
|
||||
<Close size={20} />
|
||||
</Button>
|
||||
<span
|
||||
className={s.url}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => onSelect({ baseURL, secret })}
|
||||
onKeyUp={handleTap}
|
||||
>
|
||||
{baseURL}
|
||||
</span>
|
||||
<span />
|
||||
{secret ? (
|
||||
<>
|
||||
<span className={s.secret}>{show ? secret : '***'}</span>
|
||||
|
||||
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean | (() => void)' is not assignable to... Remove this comment to see the full error message */}
|
||||
<Button onClick={toggle} className={s.eye}>
|
||||
<Icon size={20} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Button({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
|
||||
className: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button disabled={disabled} className={cx(className, s.btn)} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
72
src/components/Button.module.scss
Normal file
72
src/components/Button.module.scss
Normal file
|
@ -0,0 +1,72 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.btn {
|
||||
-webkit-appearance: none;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-btn-fg);
|
||||
background: var(--color-btn-bg);
|
||||
border: 1px solid #555;
|
||||
border-radius: 100px;
|
||||
&:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
&:hover {
|
||||
background: #387cec;
|
||||
border: 1px solid #387cec;
|
||||
color: #fff;
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
font-size: 0.75em;
|
||||
padding: 4px 7px;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: small;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
&.minimal {
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
&:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #387cec;
|
||||
border: 1px solid #387cec;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.btnInternal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
.btnStart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
}
|
82
src/components/Button.tsx
Normal file
82
src/components/Button.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
|
||||
import s0 from './Button.module.scss';
|
||||
import { LoadingDot } from './shared/Basic';
|
||||
|
||||
const { forwardRef, useCallback } = React;
|
||||
|
||||
type ButtonInternalProps = {
|
||||
children?: React.ReactNode;
|
||||
label?: string;
|
||||
text?: string;
|
||||
start?: React.ReactNode | (() => React.ReactNode);
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
isLoading?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
|
||||
disabled?: boolean;
|
||||
kind?: 'primary' | 'minimal';
|
||||
className?: string;
|
||||
title?: string;
|
||||
} & ButtonInternalProps;
|
||||
|
||||
function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
|
||||
const {
|
||||
onClick,
|
||||
disabled = false,
|
||||
isLoading,
|
||||
kind = 'primary',
|
||||
className,
|
||||
children,
|
||||
label,
|
||||
text,
|
||||
start,
|
||||
...restProps
|
||||
} = props;
|
||||
const internalProps = { children, label, text, start };
|
||||
const internalOnClick = useCallback(
|
||||
(e) => {
|
||||
if (isLoading) return;
|
||||
onClick && onClick(e);
|
||||
},
|
||||
[isLoading, onClick]
|
||||
);
|
||||
const btnClassName = cx(s0.btn, { [s0.minimal]: kind === 'minimal' }, className);
|
||||
return (
|
||||
<button
|
||||
className={btnClassName}
|
||||
ref={ref}
|
||||
onClick={internalOnClick}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span style={{ display: 'inline-flex', opacity: 0 }}>
|
||||
<ButtonInternal {...internalProps} />
|
||||
</span>
|
||||
<span className={s0.loadingContainer}>
|
||||
<LoadingDot />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<ButtonInternal {...internalProps} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonInternal({ children, label, text, start }: ButtonInternalProps) {
|
||||
return (
|
||||
<div className={s0.btnInternal}>
|
||||
{start && (
|
||||
<span className={s0.btnStart}>{typeof start === 'function' ? start() : start}</span>
|
||||
)}
|
||||
{children || label || text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Button);
|
72
src/components/Collapsible.tsx
Normal file
72
src/components/Collapsible.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
|
||||
import { framerMotionResouce } from '../misc/motion';
|
||||
|
||||
const { memo, useState, useRef, useEffect } = React;
|
||||
|
||||
function usePrevious(value) {
|
||||
const ref = useRef();
|
||||
useEffect(() => void (ref.current = value), [value]);
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
function useMeasure() {
|
||||
const ref = useRef();
|
||||
const [bounds, set] = useState({ height: 0 });
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(([entry]) => set(entry.contentRect));
|
||||
if (ref.current) ro.observe(ref.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, bounds];
|
||||
}
|
||||
|
||||
const variantsCollpapsibleWrap = {
|
||||
initialOpen: {
|
||||
height: 'auto',
|
||||
transition: { duration: 0 },
|
||||
},
|
||||
open: (height) => ({
|
||||
height,
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
transition: { duration: 0.3 },
|
||||
}),
|
||||
closed: {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
const variantsCollpapsibleChildContainer = {
|
||||
open: {},
|
||||
closed: {},
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'isOpen' does not exist on type '{ childr... Remove this comment to see the full error message
|
||||
const Collapsible = memo(({ children, isOpen }) => {
|
||||
const module = framerMotionResouce.read();
|
||||
const motion = module.motion;
|
||||
const previous = usePrevious(isOpen);
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'height' does not exist on type 'MutableR... Remove this comment to see the full error message
|
||||
const [refToMeature, { height }] = useMeasure();
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
animate={isOpen && previous === isOpen ? 'initialOpen' : isOpen ? 'open' : 'closed'}
|
||||
custom={height}
|
||||
variants={variantsCollpapsibleWrap}
|
||||
>
|
||||
<motion.div variants={variantsCollpapsibleChildContainer} ref={refToMeature}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Collapsible;
|
39
src/components/CollapsibleSectionHeader.module.scss
Normal file
39
src/components/CollapsibleSectionHeader.module.scss
Normal file
|
@ -0,0 +1,39 @@
|
|||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-flex;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s;
|
||||
|
||||
&.isOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: var(--color-focus-blue) solid 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* TODO duplicate with connQty in Connections.module.css */
|
||||
.qty {
|
||||
font-family: var(--font-normal);
|
||||
font-size: 0.75em;
|
||||
margin-left: 3px;
|
||||
padding: 2px 7px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--bg-near-transparent);
|
||||
border-radius: 30px;
|
||||
}
|
49
src/components/CollapsibleSectionHeader.tsx
Normal file
49
src/components/CollapsibleSectionHeader.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'react-feather';
|
||||
|
||||
import Button from './Button';
|
||||
import s from './CollapsibleSectionHeader.module.scss';
|
||||
import { SectionNameType } from './shared/Basic';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
type: string;
|
||||
qty?: number;
|
||||
toggle?: () => void;
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
export default function Header({ name, type, toggle, isOpen, qty }: Props) {
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
[toggle]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={s.header}
|
||||
onClick={toggle}
|
||||
style={{ cursor: 'pointer' }}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
>
|
||||
<div>
|
||||
<SectionNameType name={name} type={type} />
|
||||
</div>
|
||||
|
||||
{typeof qty === 'number' ? <span className={s.qty}>{qty}</span> : null}
|
||||
|
||||
<Button kind="minimal" onClick={toggle} className={s.btn} title="Toggle collapsible section">
|
||||
<span className={cx(s.arrow, { [s.isOpen]: isOpen })}>
|
||||
<ChevronDown size={20} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
43
src/components/Config.module.scss
Normal file
43
src/components/Config.module.scss
Normal file
|
@ -0,0 +1,43 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.root,
|
||||
.section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(49%, 1fr));
|
||||
max-width: 900px;
|
||||
gap: 5px;
|
||||
@media (--breakpoint-not-small) {
|
||||
gap: 15px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.root,
|
||||
.section {
|
||||
padding: 6px 15px 10px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapSwitch {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sep {
|
||||
max-width: 900px;
|
||||
padding: 0 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 0 40px;
|
||||
}
|
||||
> div {
|
||||
border-top: 1px dashed #373737;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 15px 0;
|
||||
font-size: small;
|
||||
}
|
403
src/components/Config.tsx
Normal file
403
src/components/Config.tsx
Normal file
|
@ -0,0 +1,403 @@
|
|||
import * as React from 'react';
|
||||
import { DownloadCloud, LogOut, RotateCw, Trash2 } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import * as logsApi from '~/api/logs';
|
||||
import { fetchVersion } from '~/api/version';
|
||||
import Select from '~/components/shared/Select';
|
||||
import { ClashGeneralConfig, DispatchFn, State } from '~/store/types';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import { getClashAPIConfig, getLatencyTestUrl, getSelectedChartStyleIndex } from '../store/app';
|
||||
import {
|
||||
fetchConfigs,
|
||||
flushFakeIPPool,
|
||||
getConfigs,
|
||||
reloadConfigFile,
|
||||
updateConfigs,
|
||||
updateGeoDatabasesFile,
|
||||
} from '../store/configs';
|
||||
import { openModal } from '../store/modals';
|
||||
import Button from './Button';
|
||||
import s0 from './Config.module.scss';
|
||||
import ContentHeader from './ContentHeader';
|
||||
import Input, { SelfControlledInput } from './Input';
|
||||
import { Selection2 } from './Selection';
|
||||
import { connect, useStoreActions } from './StateProvider';
|
||||
import Switch from './SwitchThemed';
|
||||
import TrafficChartSample from './TrafficChartSample';
|
||||
|
||||
const { useEffect, useState, useCallback, useRef } = React;
|
||||
|
||||
const propsList = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
const logLeveOptions = [
|
||||
['debug', 'Debug'],
|
||||
['info', 'Info'],
|
||||
['warning', 'Warning'],
|
||||
['error', 'Error'],
|
||||
['silent', 'Silent'],
|
||||
];
|
||||
|
||||
const portFields = [
|
||||
{ key: 'port', label: 'Http Port' },
|
||||
{ key: 'socks-port', label: 'Socks5 Port' },
|
||||
{ key: 'mixed-port', label: 'Mixed Port' },
|
||||
{ key: 'redir-port', label: 'Redir Port' },
|
||||
{ key: 'mitm-port', label: 'MITM Port' },
|
||||
];
|
||||
|
||||
const langOptions = [
|
||||
['zh', '中文'],
|
||||
['en', 'English'],
|
||||
];
|
||||
|
||||
const modeOptions = [
|
||||
['direct', 'Direct'],
|
||||
['rule', 'Rule'],
|
||||
['script', 'Script'],
|
||||
['global', 'Global'],
|
||||
];
|
||||
|
||||
const tunStackOptions = [
|
||||
['gvisor', 'gVisor'],
|
||||
['system', 'System'],
|
||||
['lwip', 'LWIP'],
|
||||
];
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
configs: getConfigs(s),
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
const mapState2 = (s: State) => ({
|
||||
selectedChartStyleIndex: getSelectedChartStyleIndex(s),
|
||||
latencyTestUrl: getLatencyTestUrl(s),
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
const Config = connect(mapState2)(ConfigImpl);
|
||||
export default connect(mapState)(ConfigContainer);
|
||||
|
||||
function ConfigContainer({ dispatch, configs, apiConfig }) {
|
||||
useEffect(() => {
|
||||
dispatch(fetchConfigs(apiConfig));
|
||||
}, [dispatch, apiConfig]);
|
||||
return <Config configs={configs} />;
|
||||
}
|
||||
|
||||
type ConfigImplProps = {
|
||||
dispatch: DispatchFn;
|
||||
configs: ClashGeneralConfig;
|
||||
selectedChartStyleIndex: number;
|
||||
latencyTestUrl: string;
|
||||
apiConfig: ClashAPIConfig;
|
||||
};
|
||||
|
||||
function ConfigImpl({
|
||||
dispatch,
|
||||
configs,
|
||||
selectedChartStyleIndex,
|
||||
latencyTestUrl,
|
||||
apiConfig,
|
||||
}: ConfigImplProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [configState, setConfigStateInternal] = useState(configs);
|
||||
const refConfigs = useRef(configs);
|
||||
useEffect(() => {
|
||||
if (refConfigs.current !== configs) {
|
||||
setConfigStateInternal(configs);
|
||||
}
|
||||
refConfigs.current = configs;
|
||||
}, [configs]);
|
||||
|
||||
const openAPIConfigModal = useCallback(() => {
|
||||
dispatch(openModal('apiConfig'));
|
||||
}, [dispatch]);
|
||||
|
||||
const setConfigState = useCallback(
|
||||
(name: string, val: any) => {
|
||||
setConfigStateInternal({ ...configState, [name]: val });
|
||||
},
|
||||
[configState]
|
||||
);
|
||||
|
||||
const setTunConfigState = useCallback(
|
||||
(name: any, val: any) => {
|
||||
const tun = { ...configState.tun, [name]: val };
|
||||
setConfigStateInternal({ ...configState, tun: { ...tun } });
|
||||
},
|
||||
[configState]
|
||||
);
|
||||
|
||||
const handleInputOnChange = useCallback(
|
||||
({ name, value }) => {
|
||||
switch (name) {
|
||||
case 'mode':
|
||||
case 'log-level':
|
||||
case 'allow-lan':
|
||||
case 'sniffing':
|
||||
setConfigState(name, value);
|
||||
dispatch(updateConfigs(apiConfig, { [name]: value }));
|
||||
if (name === 'log-level') {
|
||||
logsApi.reconnect({ ...apiConfig, logLevel: value });
|
||||
}
|
||||
break;
|
||||
case 'mitm-port':
|
||||
case 'redir-port':
|
||||
case 'socks-port':
|
||||
case 'mixed-port':
|
||||
case 'port':
|
||||
if (value !== '') {
|
||||
const num = parseInt(value, 10);
|
||||
if (num < 0 || num > 65535) return;
|
||||
}
|
||||
setConfigState(name, value);
|
||||
break;
|
||||
case 'enable':
|
||||
case 'stack':
|
||||
setTunConfigState(name, value);
|
||||
dispatch(updateConfigs(apiConfig, { tun: { [name]: value } }));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[apiConfig, dispatch, setConfigState, setTunConfigState]
|
||||
);
|
||||
|
||||
const { selectChartStyleIndex, updateAppConfig } = useStoreActions();
|
||||
|
||||
const handleInputOnBlur = useCallback(
|
||||
(
|
||||
e:
|
||||
| React.FocusEvent<HTMLSelectElement | HTMLInputElement>
|
||||
| React.ChangeEvent<HTMLSelectElement | HTMLInputElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
switch (name) {
|
||||
case 'port':
|
||||
case 'socks-port':
|
||||
case 'mixed-port':
|
||||
case 'redir-port':
|
||||
case 'mitm-port': {
|
||||
const num = parseInt(value, 10);
|
||||
if (num < 0 || num > 65535) return;
|
||||
dispatch(updateConfigs(apiConfig, { [name]: num }));
|
||||
break;
|
||||
}
|
||||
case 'latencyTestUrl': {
|
||||
updateAppConfig(name, value);
|
||||
break;
|
||||
}
|
||||
case 'device name':
|
||||
case 'interface name':
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown input name ${name}`);
|
||||
}
|
||||
},
|
||||
[apiConfig, dispatch, updateAppConfig]
|
||||
);
|
||||
const handleReloadConfigFile = useCallback(() => {
|
||||
dispatch(reloadConfigFile(apiConfig));
|
||||
}, [apiConfig, dispatch]);
|
||||
|
||||
const handleUpdateGeoDatabasesFile = useCallback(() => {
|
||||
dispatch(updateGeoDatabasesFile(apiConfig));
|
||||
}, [apiConfig, dispatch]);
|
||||
|
||||
const handleFlushFakeIPPool = useCallback(() => {
|
||||
dispatch(flushFakeIPPool(apiConfig));
|
||||
}, [apiConfig, dispatch]);
|
||||
|
||||
const { data: version } = useQuery(['/version', apiConfig], () =>
|
||||
fetchVersion('/version', apiConfig)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title={t('Config')} />
|
||||
<div className={s0.root}>
|
||||
{portFields.map((f) =>
|
||||
configState[f.key] !== undefined ? (
|
||||
<div key={f.key}>
|
||||
<div className={s0.label}>{f.label}</div>
|
||||
<Input
|
||||
name={f.key}
|
||||
value={configState[f.key]}
|
||||
onChange={({ target: { name, value } }) => handleInputOnChange({ name, value })}
|
||||
onBlur={handleInputOnBlur}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Mode</div>
|
||||
<Select
|
||||
options={modeOptions}
|
||||
selected={configState.mode.toLowerCase()}
|
||||
onChange={(e) => handleInputOnChange({ name: 'mode', value: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Log Level</div>
|
||||
<Select
|
||||
options={logLeveOptions}
|
||||
selected={configState['log-level'].toLowerCase()}
|
||||
onChange={(e) => handleInputOnChange({ name: 'log-level', value: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>{t('allow_lan')}</div>
|
||||
<div className={s0.wrapSwitch}>
|
||||
<Switch
|
||||
name="allow-lan"
|
||||
checked={configState['allow-lan']}
|
||||
onChange={(value: boolean) =>
|
||||
handleInputOnChange({ name: 'allow-lan', value: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{version.meta && (
|
||||
<div>
|
||||
<div className={s0.label}>{t('tls_sniffing')}</div>
|
||||
<div className={s0.wrapSwitch}>
|
||||
<Switch
|
||||
name="sniffing"
|
||||
checked={configState['sniffing']}
|
||||
onChange={(value: boolean) =>
|
||||
handleInputOnChange({ name: 'sniffing', value: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={s0.sep}>
|
||||
<div />
|
||||
</div>
|
||||
{version.meta && (
|
||||
<>
|
||||
<div className={s0.section}>
|
||||
<div>
|
||||
<div className={s0.label}>{t('enable_tun_device')}</div>
|
||||
<div className={s0.wrapSwitch}>
|
||||
<Switch
|
||||
checked={configState['tun']?.enable}
|
||||
onChange={(value: boolean) =>
|
||||
handleInputOnChange({ name: 'enable', value: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>TUN IP Stack</div>
|
||||
<Select
|
||||
options={tunStackOptions}
|
||||
selected={configState.tun?.stack?.toLowerCase()}
|
||||
onChange={(e) => handleInputOnChange({ name: 'stack', value: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>Device Name</div>
|
||||
<Input
|
||||
name="device name"
|
||||
value={configState.tun?.device}
|
||||
onChange={handleInputOnBlur}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>Interface Name</div>
|
||||
<Input
|
||||
name="interface name"
|
||||
value={configState['interface-name'] || ''}
|
||||
onChange={handleInputOnBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s0.sep}>
|
||||
<div />
|
||||
</div>
|
||||
<div className={s0.section}>
|
||||
<div>
|
||||
<div className={s0.label}>Reload</div>
|
||||
<Button
|
||||
start={<RotateCw size={16} />}
|
||||
label={t('reload_config_file')}
|
||||
onClick={handleReloadConfigFile}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>GEO Databases</div>
|
||||
<Button
|
||||
start={<DownloadCloud size={16} />}
|
||||
label={t('update_geo_databases_file')}
|
||||
onClick={handleUpdateGeoDatabasesFile}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>FakeIP</div>
|
||||
<Button
|
||||
start={<Trash2 size={16} />}
|
||||
label={t('flush_fake_ip_pool')}
|
||||
onClick={handleFlushFakeIPPool}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s0.sep}>
|
||||
<div />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={s0.section}>
|
||||
<div>
|
||||
<div className={s0.label}>{t('latency_test_url')}</div>
|
||||
<SelfControlledInput
|
||||
name="latencyTestUrl"
|
||||
type="text"
|
||||
value={latencyTestUrl}
|
||||
onBlur={handleInputOnBlur}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={s0.label}>{t('lang')}</div>
|
||||
<div>
|
||||
<Select
|
||||
options={langOptions}
|
||||
selected={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>{t('chart_style')}</div>
|
||||
<Selection2
|
||||
OptionComponent={TrafficChartSample}
|
||||
optionPropsList={propsList}
|
||||
selectedIndex={selectedChartStyleIndex}
|
||||
onChange={selectChartStyleIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Action</div>
|
||||
<Button
|
||||
start={<LogOut size={16} />}
|
||||
label="Switch backend"
|
||||
onClick={openAPIConfigModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
54
src/components/ConnectionTable.module.scss
Normal file
54
src/components/ConnectionTable.module.scss
Normal file
|
@ -0,0 +1,54 @@
|
|||
.tr {
|
||||
display: grid;
|
||||
/* grid-template-columns: repeat(11, minmax(max-content, 1fr)); */
|
||||
grid-template-columns: repeat(13, minmax(max-content, auto));
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 8px 10px;
|
||||
height: 50px;
|
||||
background: var(--color-background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 8px 13px;
|
||||
font-size: 0.9em;
|
||||
cursor: default;
|
||||
&:hover {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
font-family: var(--font-normal);
|
||||
}
|
||||
|
||||
.td.odd {
|
||||
background: var(--color-row-odd);
|
||||
}
|
||||
|
||||
/* download upload td cells */
|
||||
.du {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sortIconContainer {
|
||||
display: inline-flex;
|
||||
margin-left: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.rotate180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
111
src/components/ConnectionTable.tsx
Normal file
111
src/components/ConnectionTable.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import cx from 'clsx';
|
||||
import { formatDistance, Locale } from 'date-fns';
|
||||
import { enUS, zhCN } from 'date-fns/locale';
|
||||
import React from 'react';
|
||||
import { ChevronDown } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSortBy, useTable } from 'react-table';
|
||||
|
||||
import prettyBytes from '../misc/pretty-bytes';
|
||||
import s from './ConnectionTable.module.scss';
|
||||
|
||||
const sortDescFirst = true;
|
||||
|
||||
const columns = [
|
||||
{ accessor: 'id', show: false },
|
||||
{ Header: 'c_host', accessor: 'host' },
|
||||
{ Header: 'c_sni', accessor: 'sniffHost' },
|
||||
{ Header: 'c_process', accessor: 'process' },
|
||||
{ Header: 'c_dl', accessor: 'download', sortDescFirst },
|
||||
{ Header: 'c_ul', accessor: 'upload', sortDescFirst },
|
||||
{ Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst },
|
||||
{ Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst },
|
||||
{ Header: 'c_chains', accessor: 'chains' },
|
||||
{ Header: 'c_rule', accessor: 'rule' },
|
||||
{ Header: 'c_time', accessor: 'start', sortDescFirst },
|
||||
{ Header: 'c_source', accessor: 'source' },
|
||||
{ Header: 'c_destination_ip', accessor: 'destinationIP' },
|
||||
{ Header: 'c_type', accessor: 'type' },
|
||||
];
|
||||
|
||||
function renderCell(cell: { column: { id: string }; value: number }, locale: Locale) {
|
||||
switch (cell.column.id) {
|
||||
case 'start':
|
||||
return formatDistance(cell.value, 0, { locale: locale });
|
||||
case 'download':
|
||||
case 'upload':
|
||||
return prettyBytes(cell.value);
|
||||
case 'downloadSpeedCurr':
|
||||
case 'uploadSpeedCurr':
|
||||
return prettyBytes(cell.value) + '/s';
|
||||
default:
|
||||
return cell.value;
|
||||
}
|
||||
}
|
||||
|
||||
const sortById = { id: 'id', desc: true };
|
||||
const tableState = {
|
||||
sortBy: [
|
||||
// maintain a more stable order
|
||||
sortById,
|
||||
],
|
||||
hiddenColumns: ['id'],
|
||||
};
|
||||
|
||||
function Table({ data }) {
|
||||
const { getTableProps, headerGroups, rows, prepareRow } = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: tableState,
|
||||
autoResetSortBy: false,
|
||||
},
|
||||
useSortBy
|
||||
);
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language === 'zh' ? zhCN : enUS;
|
||||
|
||||
return (
|
||||
<div {...getTableProps()}>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
return (
|
||||
<div {...headerGroup.getHeaderGroupProps()} className={s.tr}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<div {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}>
|
||||
<span>{t(column.render('Header'))}</span>
|
||||
<span className={s.sortIconContainer}>
|
||||
{column.isSorted ? (
|
||||
<span className={column.isSortedDesc ? '' : s.rotate180}>
|
||||
<ChevronDown size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{rows.map((row, i) => {
|
||||
prepareRow(row);
|
||||
return row.cells.map((cell, j) => {
|
||||
return (
|
||||
<div
|
||||
{...cell.getCellProps()}
|
||||
className={cx(
|
||||
s.td,
|
||||
i % 2 === 0 ? s.odd : false,
|
||||
j >= 2 && j <= 5 ? s.du : false
|
||||
)}
|
||||
>
|
||||
{renderCell(cell, locale)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
49
src/components/Connections.css
Normal file
49
src/components/Connections.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.react-tabs {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.react-tabs__tab-list {
|
||||
margin: 0 0 10px;
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.react-tabs__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
bottom: -1px;
|
||||
position: relative;
|
||||
list-style: none;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.react-tabs__tab--selected {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.react-tabs__tab--disabled {
|
||||
color: GrayText;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.react-tabs__tab:focus {
|
||||
border-color: hsl(208, 99%, 50%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.react-tabs__tab:focus:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.react-tabs__tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-tabs__tab-panel--selected {
|
||||
display: block;
|
||||
}
|
48
src/components/Connections.module.scss
Normal file
48
src/components/Connections.module.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
.placeHolder {
|
||||
margin-top: 20%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-background);
|
||||
opacity: 0.1;
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
.connQty {
|
||||
font-family: var(--font-normal);
|
||||
font-size: 0.75em;
|
||||
margin-left: 3px;
|
||||
padding: 2px 7px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--bg-near-transparent);
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
margin: 0 30px;
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.input {
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--color-input-bg);
|
||||
background-image: none;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
box-sizing: border-box;
|
||||
color: #c1c1c1;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 36px;
|
||||
outline: none;
|
||||
padding: 0 15px;
|
||||
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
width: 100%;
|
||||
}
|
263
src/components/Connections.tsx
Normal file
263
src/components/Connections.tsx
Normal file
|
@ -0,0 +1,263 @@
|
|||
import './Connections.css';
|
||||
|
||||
import React from 'react';
|
||||
import { Pause, Play, X as IconClose } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
|
||||
import { ConnectionItem } from '~/api/connections';
|
||||
import { State } from '~/store/types';
|
||||
|
||||
import * as connAPI from '../api/connections';
|
||||
import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
|
||||
import { getClashAPIConfig } from '../store/app';
|
||||
import s from './Connections.module.scss';
|
||||
import ConnectionTable from './ConnectionTable';
|
||||
import ContentHeader from './ContentHeader';
|
||||
import ModalCloseAllConnections from './ModalCloseAllConnections';
|
||||
import { Action, Fab, position as fabPosition } from './shared/Fab';
|
||||
import { connect } from './StateProvider';
|
||||
import SvgYacd from './SvgYacd';
|
||||
|
||||
const { useEffect, useState, useRef, useCallback } = React;
|
||||
|
||||
const paddingBottom = 30;
|
||||
|
||||
function arrayToIdKv<T extends { id: string }>(items: T[]) {
|
||||
const o = {};
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
o[item.id] = item;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
type FormattedConn = {
|
||||
id: string;
|
||||
upload: number;
|
||||
download: number;
|
||||
start: number;
|
||||
chains: string;
|
||||
rule: string;
|
||||
destinationPort: string;
|
||||
destinationIP: string;
|
||||
remoteDestination: string;
|
||||
sourceIP: string;
|
||||
sourcePort: string;
|
||||
source: string;
|
||||
host: string;
|
||||
sniffHost: string;
|
||||
type: string;
|
||||
network: string;
|
||||
process?: string;
|
||||
downloadSpeedCurr?: number;
|
||||
uploadSpeedCurr?: number;
|
||||
};
|
||||
|
||||
function hasSubstring(s: string, pat: string) {
|
||||
return s.toLowerCase().includes(pat.toLowerCase());
|
||||
}
|
||||
|
||||
function filterConns(conns: FormattedConn[], keyword: string) {
|
||||
return !keyword
|
||||
? conns
|
||||
: conns.filter((conn) =>
|
||||
[
|
||||
conn.host,
|
||||
conn.sourceIP,
|
||||
conn.sourcePort,
|
||||
conn.destinationIP,
|
||||
conn.chains,
|
||||
conn.rule,
|
||||
conn.type,
|
||||
conn.network,
|
||||
conn.process,
|
||||
].some((field) => hasSubstring(field, keyword))
|
||||
);
|
||||
}
|
||||
|
||||
function formatConnectionDataItem(
|
||||
i: ConnectionItem,
|
||||
prevKv: Record<string, { upload: number; download: number }>,
|
||||
now: number
|
||||
): FormattedConn {
|
||||
const { id, metadata, upload, download, start, chains, rule, rulePayload } = i;
|
||||
const {
|
||||
host,
|
||||
destinationPort,
|
||||
destinationIP,
|
||||
remoteDestination,
|
||||
network,
|
||||
type,
|
||||
sourceIP,
|
||||
sourcePort,
|
||||
process,
|
||||
sniffHost,
|
||||
} = metadata;
|
||||
// host could be an empty string if it's direct IP connection
|
||||
let host2 = host;
|
||||
if (host2 === '') host2 = destinationIP;
|
||||
const prev = prevKv[id];
|
||||
const ret = {
|
||||
id,
|
||||
upload,
|
||||
download,
|
||||
start: now - new Date(start).valueOf(),
|
||||
chains: chains.reverse().join(' / '),
|
||||
rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
|
||||
...metadata,
|
||||
host: `${host2}:${destinationPort}`,
|
||||
sniffHost: sniffHost ? sniffHost : '-',
|
||||
type: `${type}(${network})`,
|
||||
source: `${sourceIP}:${sourcePort}`,
|
||||
downloadSpeedCurr: download - (prev ? prev.download : 0),
|
||||
uploadSpeedCurr: upload - (prev ? prev.upload : 0),
|
||||
process: process ? process : '-',
|
||||
destinationIP: remoteDestination || destinationIP || host,
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
|
||||
function renderTableOrPlaceholder(conns: FormattedConn[]) {
|
||||
return conns.length > 0 ? (
|
||||
<ConnectionTable data={conns} />
|
||||
) : (
|
||||
<div className={s.placeHolder}>
|
||||
<SvgYacd width={200} height={200} c1="var(--color-text)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnQty({ qty }) {
|
||||
return qty < 100 ? '' + qty : '99+';
|
||||
}
|
||||
|
||||
function Conn({ apiConfig }) {
|
||||
const [refContainer, containerHeight] = useRemainingViewPortHeight();
|
||||
const [conns, setConns] = useState([]);
|
||||
const [closedConns, setClosedConns] = useState([]);
|
||||
const [filterKeyword, setFilterKeyword] = useState('');
|
||||
const filteredConns = filterConns(conns, filterKeyword);
|
||||
const filteredClosedConns = filterConns(closedConns, filterKeyword);
|
||||
const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false);
|
||||
const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []);
|
||||
const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []);
|
||||
const [isRefreshPaused, setIsRefreshPaused] = useState(false);
|
||||
const toggleIsRefreshPaused = useCallback(() => {
|
||||
setIsRefreshPaused((x) => !x);
|
||||
}, []);
|
||||
const closeAllConnections = useCallback(() => {
|
||||
connAPI.closeAllConnections(apiConfig);
|
||||
closeCloseAllModal();
|
||||
}, [apiConfig, closeCloseAllModal]);
|
||||
const prevConnsRef = useRef(conns);
|
||||
const read = useCallback(
|
||||
({ connections }) => {
|
||||
const prevConnsKv = arrayToIdKv(prevConnsRef.current);
|
||||
const now = Date.now();
|
||||
const x = connections.map((c: ConnectionItem) =>
|
||||
formatConnectionDataItem(c, prevConnsKv, now)
|
||||
);
|
||||
const closed = [];
|
||||
for (const c of prevConnsRef.current) {
|
||||
const idx = x.findIndex((conn: ConnectionItem) => conn.id === c.id);
|
||||
if (idx < 0) closed.push(c);
|
||||
}
|
||||
setClosedConns((prev) => {
|
||||
// keep max 100 entries
|
||||
return [...closed, ...prev].slice(0, 101);
|
||||
});
|
||||
// if previous connections and current connections are both empty
|
||||
// arrays, we wont update state to avaoid rerender
|
||||
if (x && (x.length !== 0 || prevConnsRef.current.length !== 0) && !isRefreshPaused) {
|
||||
prevConnsRef.current = x;
|
||||
setConns(x);
|
||||
} else {
|
||||
prevConnsRef.current = x;
|
||||
}
|
||||
},
|
||||
[setConns, isRefreshPaused]
|
||||
);
|
||||
useEffect(() => {
|
||||
return connAPI.fetchData(apiConfig, read);
|
||||
}, [apiConfig, read]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title={t('Connections')} />
|
||||
<Tabs>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<span>{t('Active')}</span>
|
||||
<span className={s.connQty}>
|
||||
{/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
|
||||
<ConnQty qty={filteredConns.length} />
|
||||
</span>
|
||||
</Tab>
|
||||
<Tab>
|
||||
<span>{t('Closed')}</span>
|
||||
<span className={s.connQty}>
|
||||
{/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */}
|
||||
<ConnQty qty={filteredClosedConns.length} />
|
||||
</span>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={s.inputWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
name="filter"
|
||||
autoComplete="off"
|
||||
className={s.input}
|
||||
placeholder={t('Search')}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={refContainer} style={{ padding: 30, paddingBottom, paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
height: containerHeight - paddingBottom,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<TabPanel>
|
||||
<>{renderTableOrPlaceholder(filteredConns)}</>
|
||||
<Fab
|
||||
icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />}
|
||||
mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}}
|
||||
style={fabPosition}
|
||||
text={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')}
|
||||
onClick={toggleIsRefreshPaused}
|
||||
>
|
||||
<Action text={t('close_all_connections')} onClick={openCloseAllModal}>
|
||||
<IconClose size={10} />
|
||||
</Action>
|
||||
</Fab>
|
||||
</TabPanel>
|
||||
<TabPanel>{renderTableOrPlaceholder(filteredClosedConns)}</TabPanel>
|
||||
</div>
|
||||
</div>
|
||||
<ModalCloseAllConnections
|
||||
isOpen={isCloseAllModalOpen}
|
||||
primaryButtonOnTap={closeAllConnections}
|
||||
onRequestClose={closeCloseAllModal}
|
||||
/>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(Conn);
|
18
src/components/ContentHeader.module.scss
Normal file
18
src/components/ContentHeader.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.root {
|
||||
height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.h1 {
|
||||
padding: 0 15px;
|
||||
font-size: 1.7em;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 0 40px;
|
||||
font-size: 2em;
|
||||
}
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
17
src/components/ContentHeader.tsx
Normal file
17
src/components/ContentHeader.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
import s0 from './ContentHeader.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
function ContentHeader({ title }: Props) {
|
||||
return (
|
||||
<div className={s0.root}>
|
||||
<h1 className={s0.h1}>{title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ContentHeader);
|
32
src/components/ErrorBoundary.tsx
Normal file
32
src/components/ErrorBoundary.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import * as React from 'react';
|
||||
|
||||
// import { getSentry } from '../misc/sentry';
|
||||
import { deriveMessageFromError, Err } from '../misc/errors';
|
||||
import ErrorBoundaryFallback from './ErrorBoundaryFallback';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type State = {
|
||||
error?: Err;
|
||||
};
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
state = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Err) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
const { message, detail } = deriveMessageFromError(this.state.error);
|
||||
return <ErrorBoundaryFallback message={message} detail={detail} />;
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
37
src/components/ErrorBoundaryFallback.module.scss
Normal file
37
src/components/ErrorBoundaryFallback.module.scss
Normal file
|
@ -0,0 +1,37 @@
|
|||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
|
||||
padding: 20px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.yacd {
|
||||
color: #2a477a;
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #387cec;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
31
src/components/ErrorBoundaryFallback.tsx
Normal file
31
src/components/ErrorBoundaryFallback.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
import s0 from './ErrorBoundaryFallback.module.scss';
|
||||
import SvgGithub from './SvgGithub';
|
||||
import SvgYacd from './SvgYacd';
|
||||
const yacdRepoIssueUrl = 'https://github.com/metacubex/yacd';
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
function ErrorBoundaryFallback({ message, detail }: Props) {
|
||||
return (
|
||||
<div className={s0.root}>
|
||||
<div className={s0.yacd}>
|
||||
<SvgYacd width={150} height={150} />
|
||||
</div>
|
||||
{message ? <h1>{message}</h1> : null}
|
||||
{detail ? <p>{detail}</p> : null}
|
||||
<p>
|
||||
<a className={s0.link} href={yacdRepoIssueUrl}>
|
||||
<SvgGithub width={16} height={16} />
|
||||
metacubex/yacd
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundaryFallback;
|
42
src/components/Field.module.scss
Normal file
42
src/components/Field.module.scss
Normal file
|
@ -0,0 +1,42 @@
|
|||
.root {
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
input {
|
||||
-webkit-appearance: none;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--color-input-border);
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 40px;
|
||||
outline: none;
|
||||
padding: 0 4px;
|
||||
width: 100%;
|
||||
&:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
bottom: 22px;
|
||||
transition: transform 150ms ease-in-out;
|
||||
transform-origin: 0 0;
|
||||
font-size: 0.9em;
|
||||
&.floatAbove {
|
||||
transform: scale(0.75) translateY(-25px);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
&:focus + label {
|
||||
color: var(--color-focus-blue);
|
||||
transform: scale(0.75) translateY(-25px);
|
||||
}
|
||||
}
|
||||
}
|
27
src/components/Field.tsx
Normal file
27
src/components/Field.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import s from './Field.module.scss';
|
||||
|
||||
const { useCallback } = React;
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
value?: string | number;
|
||||
type?: 'text' | 'number';
|
||||
onChange?: (...args: any[]) => any;
|
||||
id?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function Field({ id, label, value, onChange, ...props }: Props) {
|
||||
const valueOnChange = useCallback((e) => onChange(e), [onChange]);
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<input id={id} value={value} onChange={valueOnChange} {...props} />
|
||||
<label htmlFor={id} className={s.floatAbove}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
8
src/components/Home.module.scss
Normal file
8
src/components/Home.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.root {
|
||||
padding: 6px 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
}
|
27
src/components/Home.tsx
Normal file
27
src/components/Home.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import React, { Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ContentHeader from './ContentHeader';
|
||||
import s0 from './Home.module.scss';
|
||||
import Loading from './Loading';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import TrafficNow from './TrafficNow';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title={t('Overview')} />
|
||||
<div className={s0.root}>
|
||||
<div>
|
||||
<TrafficNow />
|
||||
</div>
|
||||
<div className={s0.chart}>
|
||||
<Suspense fallback={<Loading height="200px" />}>
|
||||
<TrafficChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
20
src/components/Icon.tsx
Normal file
20
src/components/Icon.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import cx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Icon = ({ id, width = 20, height = 20, className, ...props }: Props) => {
|
||||
const c = cx('icon', id, className);
|
||||
const href = '#' + id;
|
||||
return (
|
||||
<svg className={c} width={width} height={height} {...props}>
|
||||
<use xlinkHref={href} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export default React.memo(Icon);
|
26
src/components/Input.module.scss
Normal file
26
src/components/Input.module.scss
Normal file
|
@ -0,0 +1,26 @@
|
|||
.input {
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--color-input-bg);
|
||||
background-image: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
padding: 0 15px;
|
||||
width: 100%;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
box-shadow: rgba(66, 153, 225, 0.6) 0px 0px 0px 3px;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
24
src/components/Input.tsx
Normal file
24
src/components/Input.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
|
||||
import s0 from './Input.module.scss';
|
||||
|
||||
const { useState, useRef, useEffect, useCallback } = React;
|
||||
|
||||
export default function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={s0.input} {...props} />;
|
||||
}
|
||||
|
||||
export function SelfControlledInput({ value, ...restProps }) {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const refValue = useRef(value);
|
||||
useEffect(() => {
|
||||
if (refValue.current !== value) {
|
||||
// ideally we should only do this when this input is not focused
|
||||
setInternalValue(value);
|
||||
}
|
||||
refValue.current = value;
|
||||
}, [value]);
|
||||
const onChange = useCallback((e) => setInternalValue(e.target.value), [setInternalValue]);
|
||||
|
||||
return <input className={s0.input} value={internalValue} onChange={onChange} {...restProps} />;
|
||||
}
|
28
src/components/Loading.module.scss
Normal file
28
src/components/Loading.module.scss
Normal file
|
@ -0,0 +1,28 @@
|
|||
.loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
animation: rotate 1s steps(12, end) infinite;
|
||||
background: transparent
|
||||
url('data:image/svg+xml;charset=utf8, %3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 100 100"%3E%3Cpath fill="none" d="M0 0h100v100H0z"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E9E9E9" rx="5" ry="5" transform="translate(0 -30)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23989697" rx="5" ry="5" transform="rotate(30 105.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%239B999A" rx="5" ry="5" transform="rotate(60 75.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23A3A1A2" rx="5" ry="5" transform="rotate(90 65 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23ABA9AA" rx="5" ry="5" transform="rotate(120 58.66 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23B2B2B2" rx="5" ry="5" transform="rotate(150 54.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23BAB8B9" rx="5" ry="5" transform="rotate(180 50 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23C2C0C1" rx="5" ry="5" transform="rotate(-150 45.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23CBCBCB" rx="5" ry="5" transform="rotate(-120 41.34 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23D2D2D2" rx="5" ry="5" transform="rotate(-90 35 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23DADADA" rx="5" ry="5" transform="rotate(-60 24.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E2E2E2" rx="5" ry="5" transform="rotate(-30 -5.98 65)"/%3E%3C/svg%3E')
|
||||
no-repeat;
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate3d(0, 0, 1, 360deg);
|
||||
}
|
||||
}
|
18
src/components/Loading.tsx
Normal file
18
src/components/Loading.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
import s from './Loading.module.scss';
|
||||
|
||||
type Props = {
|
||||
height?: string;
|
||||
};
|
||||
|
||||
const Loading = ({ height }: Props) => {
|
||||
const style = height ? { height } : {};
|
||||
return (
|
||||
<div className={s.loading} style={style}>
|
||||
<div className={s.spinner} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
8
src/components/Loading2.module.scss
Normal file
8
src/components/Loading2.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
.lo {
|
||||
opacity: 0.5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
14
src/components/Loading2.tsx
Normal file
14
src/components/Loading2.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import s0 from './Loading2.module.scss';
|
||||
import SvgYacd from './SvgYacd';
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className={s0.lo}>
|
||||
<SvgYacd width={280} height={280} animate c0="transparent" c1="#646464" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
6
src/components/LogSearch.ts
Normal file
6
src/components/LogSearch.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { getSearchText, updateSearchText } from '../store/logs';
|
||||
import Search from './Search';
|
||||
import { connect } from './StateProvider';
|
||||
|
||||
const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText });
|
||||
export default connect(mapState)(Search);
|
75
src/components/Logs.module.scss
Normal file
75
src/components/Logs.module.scss
Normal file
|
@ -0,0 +1,75 @@
|
|||
.logMeta {
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 5px;
|
||||
display: block;
|
||||
line-height: 1.55em;
|
||||
}
|
||||
|
||||
.logType {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
width: 66px;
|
||||
border-radius: 100px;
|
||||
padding: 3px 5px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.logTime {
|
||||
flex-shrink: 0;
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.logText {
|
||||
flex-shrink: 0;
|
||||
color: #888;
|
||||
align-items: center;
|
||||
line-height: 1.35em;
|
||||
/* force wrap */
|
||||
width: 100%;
|
||||
@media (max-width: 768px) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/*******************/
|
||||
|
||||
.logsWrapper {
|
||||
margin: 45px;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-log-info-card);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
overflow-y: auto;
|
||||
@media (max-width: 768px) {
|
||||
margin: 25px;
|
||||
}
|
||||
:global {
|
||||
.log {
|
||||
margin-bottom: 10px;
|
||||
//background: var(--color-background);
|
||||
}
|
||||
.log.even {
|
||||
//background: var(--color-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************/
|
||||
|
||||
.logPlaceholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2d2d30;
|
||||
|
||||
div:nth-child(2) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.4em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.logPlaceholderIcon {
|
||||
opacity: 0.3;
|
||||
}
|
106
src/components/Logs.tsx
Normal file
106
src/components/Logs.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import * as React from 'react';
|
||||
import { Pause, Play } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs';
|
||||
import ContentHeader from '~/components/ContentHeader';
|
||||
import LogSearch from '~/components/LogSearch';
|
||||
import { connect, useStoreActions } from '~/components/StateProvider';
|
||||
import SvgYacd from '~/components/SvgYacd';
|
||||
import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight';
|
||||
import { getClashAPIConfig, getLogStreamingPaused } from '~/store/app';
|
||||
import { getLogLevel } from '~/store/configs';
|
||||
import { appendLog, getLogsForDisplay } from '~/store/logs';
|
||||
import { Log, State } from '~/store/types';
|
||||
|
||||
import s from './Logs.module.scss';
|
||||
import { Fab, position as fabPosition } from './shared/Fab';
|
||||
|
||||
const { useCallback, useEffect } = React;
|
||||
|
||||
const paddingBottom = 30;
|
||||
const colors = {
|
||||
debug: '#389d3d',
|
||||
info: '#58c3f2',
|
||||
warning: '#cc5abb',
|
||||
error: '#c11c1c',
|
||||
};
|
||||
|
||||
const logTypes = {
|
||||
debug: 'debug',
|
||||
info: 'info',
|
||||
warning: 'warn',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
type LogLineProps = Partial<Log>;
|
||||
|
||||
function LogLine({ time, payload, type }: LogLineProps) {
|
||||
return (
|
||||
<div className={s.logMeta}>
|
||||
<span className={s.logTime}>{time}</span>
|
||||
<span className={s.logType} style={{ color: colors[type] }}>
|
||||
[ {logTypes[type]} ]
|
||||
</span>
|
||||
<span className={s.logText}>{payload}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) {
|
||||
const actions = useStoreActions();
|
||||
const toggleIsRefreshPaused = useCallback(() => {
|
||||
logStreamingPaused ? reconnectLogs({ ...apiConfig, logLevel }) : stopLogs();
|
||||
// being lazy here
|
||||
// ideally we should check the result of previous operation before updating this
|
||||
actions.app.updateAppConfig('logStreamingPaused', !logStreamingPaused);
|
||||
}, [apiConfig, logLevel, logStreamingPaused, actions.app]);
|
||||
const appendLogInternal = useCallback((log) => dispatch(appendLog(log)), [dispatch]);
|
||||
useEffect(() => {
|
||||
fetchLogs({ ...apiConfig, logLevel }, appendLogInternal);
|
||||
}, [apiConfig, logLevel, appendLogInternal]);
|
||||
const [refLogsContainer, containerHeight] = useRemainingViewPortHeight();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title={t('Logs')} />
|
||||
<LogSearch />
|
||||
<div ref={refLogsContainer}>
|
||||
{logs.length === 0 ? (
|
||||
<div className={s.logPlaceholder} style={{ height: containerHeight - paddingBottom * 2 }}>
|
||||
<div className={s.logPlaceholderIcon}>
|
||||
<SvgYacd width={200} height={200} />
|
||||
</div>
|
||||
<div>{t('no_logs')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.logsWrapper} style={{ height: containerHeight - paddingBottom * 2 }}>
|
||||
{logs.map((log, index) => (
|
||||
<div className="" key={index}>
|
||||
<LogLine {...log} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Fab
|
||||
icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />}
|
||||
mainButtonStyles={logStreamingPaused ? { background: '#e74c3c' } : {}}
|
||||
style={fabPosition}
|
||||
text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')}
|
||||
onClick={toggleIsRefreshPaused}
|
||||
></Fab>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
logs: getLogsForDisplay(s),
|
||||
logLevel: getLogLevel(s),
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
logStreamingPaused: getLogStreamingPaused(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(Logs);
|
21
src/components/Modal.module.scss
Normal file
21
src/components/Modal.module.scss
Normal file
|
@ -0,0 +1,21 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: #444;
|
||||
z-index: 1024;
|
||||
}
|
||||
|
||||
.content {
|
||||
outline: none;
|
||||
position: relative;
|
||||
color: var(--color-text);
|
||||
background: #444;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
38
src/components/Modal.tsx
Normal file
38
src/components/Modal.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import Modal, { Props as ReactModalProps } from 'react-modal';
|
||||
|
||||
import s0 from './Modal.module.scss';
|
||||
|
||||
type Props = ReactModalProps & {
|
||||
isOpen: boolean;
|
||||
onRequestClose: (...args: any[]) => any;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
};
|
||||
|
||||
function ModalAPIConfig({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
className,
|
||||
overlayClassName,
|
||||
children,
|
||||
...otherProps
|
||||
}: Props) {
|
||||
const contentCls = cx(className, s0.content);
|
||||
const overlayCls = cx(overlayClassName, s0.overlay);
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
className={contentCls}
|
||||
overlayClassName={overlayCls}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ModalAPIConfig);
|
23
src/components/ModalCloseAllConnections.module.scss
Normal file
23
src/components/ModalCloseAllConnections.module.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
.overlay {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.cnt {
|
||||
background-color: var(--bg-modal);
|
||||
color: var(--color-text);
|
||||
max-width: 300px;
|
||||
line-height: 1.4;
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.afterOpen {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.btngrp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
43
src/components/ModalCloseAllConnections.tsx
Normal file
43
src/components/ModalCloseAllConnections.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import cx from 'clsx';
|
||||
import React from 'react';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import Button from './Button';
|
||||
import modalStyle from './Modal.module.scss';
|
||||
import s from './ModalCloseAllConnections.module.scss';
|
||||
|
||||
const { useRef, useCallback, useMemo } = React;
|
||||
|
||||
export default function Comp({ isOpen, onRequestClose, primaryButtonOnTap }) {
|
||||
const primaryButtonRef = useRef(null);
|
||||
const onAfterOpen = useCallback(() => {
|
||||
primaryButtonRef.current.focus();
|
||||
}, []);
|
||||
const className = useMemo(
|
||||
() => ({
|
||||
base: cx(modalStyle.content, s.cnt),
|
||||
afterOpen: s.afterOpen,
|
||||
beforeClose: '',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
onAfterOpen={onAfterOpen}
|
||||
className={className}
|
||||
overlayClassName={cx(modalStyle.overlay, s.overlay)}
|
||||
>
|
||||
<p>Are you sure you want to close all connections?</p>
|
||||
<div className={s.btngrp}>
|
||||
<Button onClick={primaryButtonOnTap} ref={primaryButtonRef}>
|
||||
I'm sure
|
||||
</Button>
|
||||
{/* im lazy :) */}
|
||||
<div style={{ width: 20 }} />
|
||||
<Button onClick={onRequestClose}>No</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
38
src/components/Rule.module.scss
Normal file
38
src/components/Rule.module.scss
Normal file
|
@ -0,0 +1,38 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 40px;
|
||||
padding-right: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.b {
|
||||
padding: 10px 0;
|
||||
font-family: 'Roboto Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.type {
|
||||
width: 110px;
|
||||
color: #3b5f76;
|
||||
}
|
42
src/components/Rule.tsx
Normal file
42
src/components/Rule.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
|
||||
import s0 from './Rule.module.scss';
|
||||
|
||||
const colorMap = {
|
||||
_default: '#59caf9',
|
||||
DIRECT: '#f5bc41',
|
||||
REJECT: '#cb3166',
|
||||
};
|
||||
|
||||
function getStyleFor({ proxy }) {
|
||||
let color = colorMap._default;
|
||||
if (colorMap[proxy]) {
|
||||
color = colorMap[proxy];
|
||||
}
|
||||
return { color };
|
||||
}
|
||||
|
||||
type Props = {
|
||||
id?: number;
|
||||
type?: string;
|
||||
payload?: string;
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
function Rule({ type, payload, proxy, id }: Props) {
|
||||
const styleProxy = getStyleFor({ proxy });
|
||||
return (
|
||||
<div className={s0.rule}>
|
||||
<div className={s0.left}>{id}</div>
|
||||
<div>
|
||||
<div className={s0.b}>{payload}</div>
|
||||
<div className={s0.a}>
|
||||
<div className={s0.type}>{type}</div>
|
||||
<div style={styleProxy}>{proxy}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Rule;
|
23
src/components/Rules.module.scss
Normal file
23
src/components/Rules.module.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(auto, 330px);
|
||||
align-items: center;
|
||||
|
||||
/*
|
||||
* the content header has some padding
|
||||
* we need to apply some right padding to this container then
|
||||
*/
|
||||
padding-right: 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding-right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.RuleProviderItemWrapper {
|
||||
padding: 6px 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
}
|
115
src/components/Rules.tsx
Normal file
115
src/components/Rules.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { areEqual, VariableSizeList } from 'react-window';
|
||||
|
||||
import { RuleProviderItem } from '~/components/rules/RuleProviderItem';
|
||||
import { useRuleAndProvider } from '~/components/rules/rules.hooks';
|
||||
import { RulesPageFab } from '~/components/rules/RulesPageFab';
|
||||
import { TextFilter } from '~/components/shared/TextFitler';
|
||||
import { ruleFilterText } from '~/store/rules';
|
||||
import { State } from '~/store/types';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
|
||||
import { getClashAPIConfig } from '../store/app';
|
||||
import ContentHeader from './ContentHeader';
|
||||
import Rule from './Rule';
|
||||
import s from './Rules.module.scss';
|
||||
import { connect } from './StateProvider';
|
||||
|
||||
const { memo } = React;
|
||||
|
||||
const paddingBottom = 30;
|
||||
|
||||
type ItemData = {
|
||||
rules: any[];
|
||||
provider: any;
|
||||
apiConfig: ClashAPIConfig;
|
||||
};
|
||||
|
||||
function itemKey(index: number, { rules, provider }: ItemData) {
|
||||
const providerQty = provider.names.length;
|
||||
|
||||
if (index < providerQty) {
|
||||
return provider.names[index];
|
||||
}
|
||||
const item = rules[index - providerQty];
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getItemSizeFactory({ provider }) {
|
||||
return function getItemSize(idx: number) {
|
||||
const providerQty = provider.names.length;
|
||||
if (idx < providerQty) {
|
||||
// provider
|
||||
return 90;
|
||||
}
|
||||
// rule
|
||||
return 60;
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'index' does not exist on type '{ childre... Remove this comment to see the full error message
|
||||
const Row = memo(({ index, style, data }) => {
|
||||
const { rules, provider, apiConfig } = data;
|
||||
const providerQty = provider.names.length;
|
||||
|
||||
if (index < providerQty) {
|
||||
const name = provider.names[index];
|
||||
const item = provider.byName[name];
|
||||
return (
|
||||
<div style={style} className={s.RuleProviderItemWrapper}>
|
||||
<RuleProviderItem apiConfig={apiConfig} {...item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const r = rules[index - providerQty];
|
||||
return (
|
||||
<div style={style}>
|
||||
<Rule {...r} />
|
||||
</div>
|
||||
);
|
||||
}, areEqual);
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(Rules);
|
||||
|
||||
type RulesProps = {
|
||||
apiConfig: ClashAPIConfig;
|
||||
};
|
||||
|
||||
function Rules({ apiConfig }: RulesProps) {
|
||||
const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
|
||||
const { rules, provider } = useRuleAndProvider(apiConfig);
|
||||
const getItemSize = getItemSizeFactory({ provider });
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={s.header}>
|
||||
<ContentHeader title={t('Rules')} />
|
||||
<TextFilter textAtom={ruleFilterText} placeholder={t('Search')} />
|
||||
</div>
|
||||
<div ref={refRulesContainer} style={{ paddingBottom }}>
|
||||
<VariableSizeList
|
||||
height={containerHeight - paddingBottom}
|
||||
width="100%"
|
||||
itemCount={rules.length + provider.names.length}
|
||||
itemSize={getItemSize}
|
||||
itemData={{ rules, provider, apiConfig }}
|
||||
itemKey={itemKey}
|
||||
>
|
||||
{Row}
|
||||
</VariableSizeList>
|
||||
</div>
|
||||
{provider && provider.names && provider.names.length > 0 ? (
|
||||
<RulesPageFab apiConfig={apiConfig} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
47
src/components/Search.module.scss
Normal file
47
src/components/Search.module.scss
Normal file
|
@ -0,0 +1,47 @@
|
|||
.RuleSearch {
|
||||
padding: 0 40px 5px;
|
||||
@media (max-width: 768px) {
|
||||
padding: 0 25px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.RuleSearchContainer {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
@media (max-width: 768px) {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--color-input-bg);
|
||||
background-image: none;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
box-sizing: border-box;
|
||||
color: #c1c1c1;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 40px;
|
||||
outline: none;
|
||||
padding: 0 15px 0 35px;
|
||||
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 10px;
|
||||
line-height: 0;
|
||||
}
|
46
src/components/Search.tsx
Normal file
46
src/components/Search.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import debounce from 'lodash-es/debounce';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Search as SearchIcon } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import s0 from './Search.module.scss';
|
||||
|
||||
function RuleSearch({ dispatch, searchText, updateSearchText }) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState(searchText);
|
||||
const updateSearchTextInternal = useCallback(
|
||||
(v) => {
|
||||
dispatch(updateSearchText(v));
|
||||
},
|
||||
[dispatch, updateSearchText]
|
||||
);
|
||||
const updateSearchTextDebounced = useMemo(
|
||||
() => debounce(updateSearchTextInternal, 300),
|
||||
[updateSearchTextInternal]
|
||||
);
|
||||
const onChange = (e) => {
|
||||
setText(e.target.value);
|
||||
updateSearchTextDebounced(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={s0.RuleSearch}>
|
||||
<div className={s0.RuleSearchContainer}>
|
||||
<div className={s0.inputWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
className={s0.input}
|
||||
placeholder={t('Search')}
|
||||
/>
|
||||
</div>
|
||||
<div className={s0.iconWrapper}>
|
||||
<SearchIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RuleSearch;
|
23
src/components/Selection.module.scss
Normal file
23
src/components/Selection.module.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
.fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.input + .cnt {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.input:focus + .cnt {
|
||||
border-color: #387cec;
|
||||
}
|
||||
|
||||
.input:checked + .cnt {
|
||||
border-color: #387cec;
|
||||
}
|
45
src/components/Selection.tsx
Normal file
45
src/components/Selection.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import cx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import s from './Selection.module.scss';
|
||||
|
||||
type SelectionProps = {
|
||||
OptionComponent?: (...args: any[]) => any;
|
||||
optionPropsList?: any[];
|
||||
selectedIndex?: number;
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
export function Selection2({
|
||||
OptionComponent,
|
||||
optionPropsList,
|
||||
selectedIndex,
|
||||
onChange,
|
||||
}: SelectionProps) {
|
||||
const inputCx = cx('visually-hidden', s.input);
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
return (
|
||||
<fieldset className={s.fieldset}>
|
||||
{optionPropsList.map((props, idx) => {
|
||||
return (
|
||||
<label key={idx}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedIndex === idx}
|
||||
name="selection"
|
||||
value={idx}
|
||||
aria-labelledby={'traffic chart type ' + idx}
|
||||
onChange={onInputChange}
|
||||
className={inputCx}
|
||||
/>
|
||||
<div className={s.cnt}>
|
||||
<OptionComponent {...props} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
112
src/components/SideBar.module.scss
Normal file
112
src/components/SideBar.module.scss
Normal file
File diff suppressed because one or more lines are too long
105
src/components/SideBar.tsx
Normal file
105
src/components/SideBar.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { Tooltip } from '@reach/tooltip';
|
||||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { Info } from 'react-feather';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FcAreaChart, FcDocument, FcGlobe, FcLink, FcRuler, FcSettings } from 'react-icons/fc';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher';
|
||||
|
||||
import s from './SideBar.module.scss';
|
||||
|
||||
const icons = {
|
||||
activity: FcAreaChart,
|
||||
globe: FcGlobe,
|
||||
command: FcRuler,
|
||||
file: FcDocument,
|
||||
settings: FcSettings,
|
||||
link: FcLink,
|
||||
};
|
||||
|
||||
const SideBarRow = React.memo(function SideBarRow({
|
||||
isActive,
|
||||
to,
|
||||
iconId,
|
||||
labelText,
|
||||
}: SideBarRowProps) {
|
||||
const Comp = icons[iconId];
|
||||
const className = cx(s.row, isActive ? s.rowActive : null);
|
||||
return (
|
||||
<Link to={to} className={className}>
|
||||
<Comp />
|
||||
<div className={s.label}>{labelText}</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
interface SideBarRowProps {
|
||||
isActive: boolean;
|
||||
to: string;
|
||||
iconId?: string;
|
||||
labelText?: string;
|
||||
}
|
||||
|
||||
const pages = [
|
||||
{
|
||||
to: '/',
|
||||
iconId: 'activity',
|
||||
labelText: 'Overview',
|
||||
},
|
||||
{
|
||||
to: '/proxies',
|
||||
iconId: 'globe',
|
||||
labelText: 'Proxies',
|
||||
},
|
||||
{
|
||||
to: '/rules',
|
||||
iconId: 'command',
|
||||
labelText: 'Rules',
|
||||
},
|
||||
{
|
||||
to: '/connections',
|
||||
iconId: 'link',
|
||||
labelText: 'Conns',
|
||||
},
|
||||
{
|
||||
to: '/configs',
|
||||
iconId: 'settings',
|
||||
labelText: 'Config',
|
||||
},
|
||||
{
|
||||
to: '/logs',
|
||||
iconId: 'file',
|
||||
labelText: 'Logs',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SideBar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.logoPlaceholder} />
|
||||
<div className={s.rows}>
|
||||
{pages.map(({ to, iconId, labelText }) => (
|
||||
<SideBarRow
|
||||
key={to}
|
||||
to={to}
|
||||
isActive={location.pathname === to}
|
||||
iconId={iconId}
|
||||
labelText={t(labelText)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.footer}>
|
||||
<ThemeSwitcher />
|
||||
<Tooltip label={t('about')}>
|
||||
<Link to="/about" className={s.iconWrapper}>
|
||||
<Info size={20} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
99
src/components/StateProvider.tsx
Normal file
99
src/components/StateProvider.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
import produce, * as immer from 'immer';
|
||||
import React from 'react';
|
||||
|
||||
// in logs store we update logs in place
|
||||
// outside of immer produce
|
||||
// this is just workaround
|
||||
immer.setAutoFreeze(false);
|
||||
|
||||
const { createContext, memo, useMemo, useRef, useEffect, useCallback, useContext, useState } =
|
||||
React;
|
||||
|
||||
export { immer };
|
||||
|
||||
const StateContext = createContext(null);
|
||||
const DispatchContext = createContext(null);
|
||||
const ActionsContext = createContext(null);
|
||||
|
||||
export function useStoreState() {
|
||||
return useContext(StateContext);
|
||||
}
|
||||
|
||||
export function useStoreDispatch() {
|
||||
return useContext(DispatchContext);
|
||||
}
|
||||
|
||||
export function useStoreActions() {
|
||||
return useContext(ActionsContext);
|
||||
}
|
||||
|
||||
// boundActionCreators
|
||||
export default function Provider({ initialState, actions = {}, children }) {
|
||||
const stateRef = useRef(initialState);
|
||||
const [state, setState] = useState(initialState);
|
||||
const getState = useCallback(() => stateRef.current, []);
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(window as any).getState2 = getState;
|
||||
}
|
||||
}, [getState]);
|
||||
const dispatch = useCallback(
|
||||
(actionId: string | ((a: any, b: any) => any), fn: (s: any) => void) => {
|
||||
if (typeof actionId === 'function') return actionId(dispatch, getState);
|
||||
|
||||
const stateNext = produce(getState(), fn);
|
||||
if (stateNext !== stateRef.current) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(actionId, stateNext);
|
||||
}
|
||||
stateRef.current = stateNext;
|
||||
setState(stateNext);
|
||||
}
|
||||
},
|
||||
[getState]
|
||||
);
|
||||
const boundActions = useMemo(() => bindActions(actions, dispatch), [actions, dispatch]);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
<ActionsContext.Provider value={boundActions}>{children}</ActionsContext.Provider>
|
||||
</DispatchContext.Provider>
|
||||
</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function connect(mapStateToProps: any) {
|
||||
return (Component: any) => {
|
||||
const MemoComponent = memo(Component);
|
||||
function Connected(props: any) {
|
||||
const state = useContext(StateContext);
|
||||
const dispatch = useContext(DispatchContext);
|
||||
const mapped = mapStateToProps(state, props);
|
||||
const nextProps = { dispatch, ...props, ...mapped };
|
||||
return <MemoComponent {...nextProps} />;
|
||||
}
|
||||
return Connected;
|
||||
};
|
||||
}
|
||||
|
||||
// steal from https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts
|
||||
function bindAction(action: any, dispatch: any) {
|
||||
return function (...args: any[]) {
|
||||
return dispatch(action.apply(this, args));
|
||||
};
|
||||
}
|
||||
|
||||
function bindActions(actions: any, dispatch: any) {
|
||||
const boundActions = {};
|
||||
for (const key in actions) {
|
||||
const action = actions[key];
|
||||
if (typeof action === 'function') {
|
||||
boundActions[key] = bindAction(action, dispatch);
|
||||
} else if (typeof action === 'object') {
|
||||
boundActions[key] = bindActions(action, dispatch);
|
||||
}
|
||||
}
|
||||
return boundActions;
|
||||
}
|
71
src/components/StyleGuide.tsx
Normal file
71
src/components/StyleGuide.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import { Zap } from 'react-feather';
|
||||
|
||||
import Loading from '~/components/Loading';
|
||||
|
||||
import Button from './Button';
|
||||
import Input from './Input';
|
||||
import SwitchThemed from './SwitchThemed';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
|
||||
const noop = () => {
|
||||
/* empty */
|
||||
};
|
||||
|
||||
const paneStyle = {
|
||||
padding: '20px 0',
|
||||
};
|
||||
|
||||
const optionsRule = [
|
||||
{ label: 'Global', value: 'Global' },
|
||||
{ label: 'Rule', value: 'Rule' },
|
||||
{ label: 'Direct', value: 'Direct' },
|
||||
];
|
||||
|
||||
const Pane = ({ children, style }) => <div style={{ ...paneStyle, ...style }}>{children}</div>;
|
||||
|
||||
function useToggle(initialState = false) {
|
||||
const [onoff, setonoff] = React.useState(initialState);
|
||||
const handleChange = React.useCallback(() => {
|
||||
setonoff((x) => !x);
|
||||
}, []);
|
||||
return [onoff, handleChange];
|
||||
}
|
||||
|
||||
function SwitchExample() {
|
||||
const [checked, handleChange] = useToggle(false);
|
||||
return <SwitchThemed checked={checked} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
class StyleGuide extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'style' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Pane>
|
||||
<SwitchExample />
|
||||
</Pane>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'style' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Pane>
|
||||
<Input />
|
||||
</Pane>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'style' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Pane>
|
||||
<ToggleSwitch name="test" options={optionsRule} value="Rule" onChange={noop} />
|
||||
</Pane>
|
||||
{/* @ts-expect-error ts-migrate(2741) FIXME: Property 'style' is missing in type '{ children: E... Remove this comment to see the full error message */}
|
||||
<Pane>
|
||||
<Button text="Test Latency" start={<Zap size={16} />} />
|
||||
<Button text="Test Latency" start={<Zap size={16} />} isLoading />
|
||||
<Button label="Test Latency" />
|
||||
<Button label="Button Plain" kind="minimal" />
|
||||
</Pane>
|
||||
<Pane style={{ paddingLeft: 20 }}>
|
||||
<Loading />
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StyleGuide;
|
24
src/components/SvgGithub.tsx
Normal file
24
src/components/SvgGithub.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function SvgGithub({ width = 24, height = 24 }: Props = {}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
14
src/components/SvgYacd.module.scss
Normal file
14
src/components/SvgYacd.module.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
.path {
|
||||
stroke-dasharray: 890;
|
||||
stroke-dashoffset: 890;
|
||||
animation: dash 3s ease-in-out forwards normal infinite;
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
from {
|
||||
stroke-dashoffset: 890;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
92
src/components/SvgYacd.tsx
Normal file
92
src/components/SvgYacd.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
|
||||
import s from './SvgYacd.module.scss';
|
||||
|
||||
type Props = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
animate?: boolean;
|
||||
c0?: string;
|
||||
c1?: string;
|
||||
stroke?: string;
|
||||
eye?: string;
|
||||
line?: string;
|
||||
};
|
||||
|
||||
function SvgYacd({
|
||||
width = 320,
|
||||
height = 320,
|
||||
animate = false,
|
||||
c0 = '#316eb5',
|
||||
c1 = '#f19500',
|
||||
line = '#cccccc',
|
||||
}: Props) {
|
||||
const faceClasName = cx({ [s.path]: animate });
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.2"
|
||||
viewBox="0 0 512 512"
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c0}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m280.8 182.4l119-108.3c1.9-1.7 4.3-2.7 6.8-2.4l39.5 4.1c2.1 0.3 3.9 2.2 3.9 4.4v251.1c0 2-1.5 3.9-3.5 4.4l-41.9 9c-0.5 0.3-1.2 0.3-1.9 0.3h-18.8c-2.4 0-4.4-2-4.4-4.4v-132.9c0-7.5-9-11.7-14.8-6.3l-59 53.4c-2.2 2.2-5.4 2.9-8.5 1.9-27.1-8-56.3-8-83.4 0-2.9 1-6.1 0.3-8.5-1.9l-59-53.4c-5.6-5.4-14.6-1.2-14.6 6.3v132.9c0 2.4-2.2 4.4-4.7 4.4h-18.7c-0.7 0-1.2 0-2-0.3l-41.6-9c-2-0.5-3.5-2.4-3.5-4.4v-251.1c0-2.2 1.8-4.1 3.9-4.4l39.5-4.1c2.5-0.3 4.9 0.7 6.9 2.4l115.7 105.3c2 1.7 4.6 2.5 7.1 2.2 15.3-2.2 31.4-1.9 46.5 0.8z"
|
||||
/>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c0}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m269.4 361.8l-7.1 13.4c-2.4 4.2-8.5 4.2-11 0l-7-13.4c-2.5-4.1 0.7-9.3 5.3-9h14.4c4.9 0 7.8 4.9 5.4 9z"
|
||||
/>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c1}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m160.7 362.5c3.6 0 6.8 3.2 6.8 6.9 0 3.6-3.2 6.5-6.8 6.5h-94.6c-3.6 0-6.8-2.9-6.8-6.5 0-3.7 3.2-6.9 6.8-6.9z"
|
||||
/>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c1}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m158.7 394.7c3.4-1 7.1 1 8.3 4.4 1 3.4-1 7.3-4.4 8.3l-92.8 31.7c-3.4 1.2-7.3-0.7-8.3-4.2-1.2-3.6 0.7-7.3 4.4-8.5z"
|
||||
/>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c1}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m446.1 426.4c3.4 1.2 5.3 4.9 4.3 8.5-1.2 3.5-4.8 5.4-8.2 4.2l-93.1-31.7c-3.5-1-5.4-4.9-4.2-8.3 1-3.4 4.9-5.4 8.3-4.4z"
|
||||
/>
|
||||
<path
|
||||
id="Layer"
|
||||
className={faceClasName}
|
||||
fill={c1}
|
||||
stroke={line}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="4"
|
||||
d="m445.8 362.5c3.7 0 6.6 3.2 6.6 6.9 0 3.6-2.9 6.5-6.6 6.5h-94.8c-3.6 0-6.6-2.9-6.6-6.5 0-3.7 3-6.9 6.6-6.9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SvgYacd;
|
35
src/components/SwitchThemed.tsx
Normal file
35
src/components/SwitchThemed.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import ReactSwitch from 'react-switch';
|
||||
|
||||
import { State } from '~/store/types';
|
||||
|
||||
import { getTheme } from '../store/app';
|
||||
import { connect } from './StateProvider';
|
||||
|
||||
// workaround https://github.com/vitejs/vite/issues/2139#issuecomment-802981228
|
||||
// @ts-ignore
|
||||
const Switch = ReactSwitch.default ? ReactSwitch.default : ReactSwitch;
|
||||
|
||||
function SwitchThemed({ checked = false, onChange, theme, name }) {
|
||||
const offColor = theme === 'dark' ? '#393939' : '#e9e9e9';
|
||||
|
||||
return (
|
||||
<Switch
|
||||
onChange={onChange}
|
||||
checked={checked}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
offColor={offColor}
|
||||
onColor="#047aff"
|
||||
offHandleColor="#fff"
|
||||
onHandleColor="#fff"
|
||||
handleDiameter={24}
|
||||
height={28}
|
||||
width={44}
|
||||
className="rs"
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((s: State) => ({ theme: getTheme(s) }))(SwitchThemed);
|
39
src/components/ToggleSwitch.module.scss
Normal file
39
src/components/ToggleSwitch.module.scss
Normal file
|
@ -0,0 +1,39 @@
|
|||
.ToggleSwitch {
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #525252;
|
||||
color: var(--color-text);
|
||||
background: var(--color-toggle-bg);
|
||||
display: flex;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: left 0.2s ease-out;
|
||||
background: var(--color-toggle-selected);
|
||||
}
|
65
src/components/ToggleSwitch.tsx
Normal file
65
src/components/ToggleSwitch.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import s0 from './ToggleSwitch.module.scss';
|
||||
|
||||
type Props = {
|
||||
options?: any[];
|
||||
value?: string;
|
||||
name?: string;
|
||||
onChange?: (...args: any[]) => any;
|
||||
};
|
||||
|
||||
function ToggleSwitch({ options, value, name, onChange }: Props) {
|
||||
const idxSelected = useMemo(() => options.map((o) => o.value).indexOf(value), [options, value]);
|
||||
|
||||
const getPortionPercentage = useCallback(
|
||||
(idx: number) => {
|
||||
const w = Math.floor(100 / options.length);
|
||||
if (idx === options.length - 1) {
|
||||
return 100 - options.length * w + w;
|
||||
} else if (idx > -1) {
|
||||
return w;
|
||||
}
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const sliderStyle = useMemo(() => {
|
||||
return {
|
||||
width: getPortionPercentage(idxSelected) + '%',
|
||||
left: idxSelected * getPortionPercentage(0) + '%',
|
||||
};
|
||||
}, [idxSelected, getPortionPercentage]);
|
||||
|
||||
return (
|
||||
<div className={s0.ToggleSwitch}>
|
||||
<div className={s0.slider} style={sliderStyle} />
|
||||
{options.map((o, idx) => {
|
||||
const id = `${name}-${o.label}`;
|
||||
const className = idx === 0 ? '' : 'border-left';
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
key={id}
|
||||
className={className}
|
||||
style={{
|
||||
width: getPortionPercentage(idx) + '%',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="radio"
|
||||
value={o.value}
|
||||
checked={value === o.value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div>{o.label}</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ToggleSwitch);
|
61
src/components/TrafficChart.tsx
Normal file
61
src/components/TrafficChart.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { State } from '~/store/types';
|
||||
|
||||
import { fetchData } from '../api/traffic';
|
||||
import useLineChart from '../hooks/useLineChart';
|
||||
import { chartJSResource, chartStyles, commonDataSetProps } from '../misc/chart';
|
||||
import { getClashAPIConfig, getSelectedChartStyleIndex } from '../store/app';
|
||||
import { connect } from './StateProvider';
|
||||
|
||||
const { useMemo } = React;
|
||||
|
||||
const chartWrapperStyle = {
|
||||
// make chartjs chart responsive
|
||||
position: 'relative',
|
||||
maxWidth: 1000,
|
||||
marginTop: '1em',
|
||||
};
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
selectedChartStyleIndex: getSelectedChartStyleIndex(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(TrafficChart);
|
||||
|
||||
function TrafficChart({ apiConfig, selectedChartStyleIndex }) {
|
||||
const ChartMod = chartJSResource.read();
|
||||
const traffic = fetchData(apiConfig);
|
||||
const { t } = useTranslation();
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
labels: traffic.labels,
|
||||
datasets: [
|
||||
{
|
||||
...commonDataSetProps,
|
||||
...chartStyles[selectedChartStyleIndex].up,
|
||||
label: t('Up'),
|
||||
data: traffic.up,
|
||||
},
|
||||
{
|
||||
...commonDataSetProps,
|
||||
...chartStyles[selectedChartStyleIndex].down,
|
||||
label: t('Down'),
|
||||
data: traffic.down,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[traffic, selectedChartStyleIndex, t]
|
||||
);
|
||||
|
||||
useLineChart(ChartMod.Chart, 'trafficChart', data, traffic);
|
||||
|
||||
return (
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ position: string; maxWidth: number; }' is ... Remove this comment to see the full error message
|
||||
<div style={chartWrapperStyle}>
|
||||
<canvas id="trafficChart" />
|
||||
</div>
|
||||
);
|
||||
}
|
52
src/components/TrafficChartSample.tsx
Normal file
52
src/components/TrafficChartSample.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import useLineChart from '../hooks/useLineChart';
|
||||
import { chartJSResource, chartStyles, commonDataSetProps } from '../misc/chart';
|
||||
|
||||
const { useMemo } = React;
|
||||
|
||||
const extraChartOptions: import('chart.js').ChartOptions<'line'> = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
x: { display: false, type: 'category' },
|
||||
y: { display: false, type: 'linear' },
|
||||
},
|
||||
};
|
||||
|
||||
const data1 = [23e3, 35e3, 46e3, 33e3, 90e3, 68e3, 23e3, 45e3];
|
||||
const data2 = [184e3, 183e3, 196e3, 182e3, 190e3, 186e3, 182e3, 189e3];
|
||||
const labels = data1;
|
||||
|
||||
export default function TrafficChart({ id }) {
|
||||
const ChartMod = chartJSResource.read();
|
||||
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
...commonDataSetProps,
|
||||
...chartStyles[id].up,
|
||||
data: data1,
|
||||
},
|
||||
{
|
||||
...commonDataSetProps,
|
||||
...chartStyles[id].down,
|
||||
data: data2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[id]
|
||||
);
|
||||
|
||||
const eleId = 'chart-' + id;
|
||||
useLineChart(ChartMod.Chart, eleId, data, null, extraChartOptions);
|
||||
|
||||
return (
|
||||
<div style={{ width: 80, padding: 5 }}>
|
||||
<canvas id={eleId} />
|
||||
</div>
|
||||
);
|
||||
}
|
28
src/components/TrafficNow.module.scss
Normal file
28
src/components/TrafficNow.module.scss
Normal file
|
@ -0,0 +1,28 @@
|
|||
.TrafficNow {
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
max-width: 1000px;
|
||||
|
||||
.sec {
|
||||
padding: 10px;
|
||||
width: 19%;
|
||||
margin: 3px;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
div:nth-child(1) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.65em;
|
||||
}
|
||||
div:nth-child(2) {
|
||||
padding: 10px 0 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
width: 48%;
|
||||
}
|
||||
}
|
||||
}
|
81
src/components/TrafficNow.tsx
Normal file
81
src/components/TrafficNow.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as connAPI from '../api/connections';
|
||||
import { fetchData } from '../api/traffic';
|
||||
import prettyBytes from '../misc/pretty-bytes';
|
||||
import { getClashAPIConfig } from '../store/app';
|
||||
import { connect } from './StateProvider';
|
||||
import s0 from './TrafficNow.module.scss';
|
||||
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
const mapState = (s) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
export default connect(mapState)(TrafficNow);
|
||||
|
||||
function TrafficNow({ apiConfig }) {
|
||||
const { t } = useTranslation();
|
||||
const { upStr, downStr } = useSpeed(apiConfig);
|
||||
const { upTotal, dlTotal, connNumber } = useConnection(apiConfig);
|
||||
return (
|
||||
<div className={s0.TrafficNow}>
|
||||
<div className={s0.sec}>
|
||||
<div>{t('Upload')}</div>
|
||||
<div>{upStr}</div>
|
||||
</div>
|
||||
<div className={s0.sec}>
|
||||
<div>{t('Download')}</div>
|
||||
<div>{downStr}</div>
|
||||
</div>
|
||||
<div className={s0.sec}>
|
||||
<div>{t('Upload Total')}</div>
|
||||
<div>{upTotal}</div>
|
||||
</div>
|
||||
<div className={s0.sec}>
|
||||
<div>{t('Download Total')}</div>
|
||||
<div>{dlTotal}</div>
|
||||
</div>
|
||||
<div className={s0.sec}>
|
||||
<div>{t('Active Connections')}</div>
|
||||
<div>{connNumber}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSpeed(apiConfig) {
|
||||
const [speed, setSpeed] = useState({ upStr: '0 B/s', downStr: '0 B/s' });
|
||||
useEffect(() => {
|
||||
return fetchData(apiConfig).subscribe((o) =>
|
||||
setSpeed({
|
||||
upStr: prettyBytes(o.up) + '/s',
|
||||
downStr: prettyBytes(o.down) + '/s',
|
||||
})
|
||||
);
|
||||
}, [apiConfig]);
|
||||
return speed;
|
||||
}
|
||||
|
||||
function useConnection(apiConfig) {
|
||||
const [state, setState] = useState({
|
||||
upTotal: '0 B',
|
||||
dlTotal: '0 B',
|
||||
connNumber: 0,
|
||||
});
|
||||
const read = useCallback(
|
||||
({ downloadTotal, uploadTotal, connections }) => {
|
||||
setState({
|
||||
upTotal: prettyBytes(uploadTotal),
|
||||
dlTotal: prettyBytes(downloadTotal),
|
||||
connNumber: connections.length,
|
||||
});
|
||||
},
|
||||
[setState]
|
||||
);
|
||||
useEffect(() => {
|
||||
return connAPI.fetchData(apiConfig, read);
|
||||
}, [apiConfig, read]);
|
||||
return state;
|
||||
}
|
20
src/components/about/About.module.scss
Normal file
20
src/components/about/About.module.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.root {
|
||||
padding: 6px 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-text-secondary);
|
||||
display: inline-flex;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
56
src/components/about/About.tsx
Normal file
56
src/components/about/About.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import { GitHub } from 'react-feather';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { fetchVersion } from '~/api/version';
|
||||
import ContentHeader from '~/components/ContentHeader';
|
||||
import { connect } from '~/components/StateProvider';
|
||||
import { getClashAPIConfig } from '~/store/app';
|
||||
import { ClashAPIConfig } from '~/types';
|
||||
|
||||
import s from './About.module.scss';
|
||||
|
||||
type Props = { apiConfig: ClashAPIConfig };
|
||||
|
||||
function Version({ name, link, version }: { name: string; link: string; version: string }) {
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<h2>{name}</h2>
|
||||
<p>
|
||||
<span>Version </span>
|
||||
<span className={s.mono}>{version}</span>
|
||||
</p>
|
||||
<p>
|
||||
<a className={s.link} href={link} target="_blank" rel="noopener noreferrer">
|
||||
<GitHub size={20} />
|
||||
<span>Source</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutImpl(props: Props) {
|
||||
const { data: version } = useQuery(['/version', props.apiConfig], () =>
|
||||
fetchVersion('/version', props.apiConfig)
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ContentHeader title="About" />
|
||||
{version && version.version ? (
|
||||
<Version
|
||||
name={version.meta ? 'Clash.Meta' : 'Clash'}
|
||||
version={version.version}
|
||||
link="https://github.com/metacubex/clash.meta"
|
||||
/>
|
||||
) : null}
|
||||
<Version name="Yacd" version={__VERSION__} link="https://github.com/metacubex/yacd" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
});
|
||||
|
||||
export const About = connect(mapState)(AboutImpl);
|
48
src/components/proxies/ClosePrevConns.tsx
Normal file
48
src/components/proxies/ClosePrevConns.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import Button from '../Button';
|
||||
import { FlexCenter } from '../shared/Styled';
|
||||
|
||||
const { useRef, useEffect } = React;
|
||||
|
||||
type Props = {
|
||||
onClickPrimaryButton?: () => void;
|
||||
onClickSecondaryButton?: () => void;
|
||||
};
|
||||
|
||||
export function ClosePrevConns({ onClickPrimaryButton, onClickSecondaryButton }: Props) {
|
||||
const primaryButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const secondaryButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
primaryButtonRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === 39) {
|
||||
secondaryButtonRef.current.focus();
|
||||
} else if (e.keyCode === 37) {
|
||||
primaryButtonRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
<h2>Close Connections?</h2>
|
||||
<p>
|
||||
Click 'Yes' to close those connections that are still using the old selected proxy in this
|
||||
group
|
||||
</p>
|
||||
<div style={{ height: 30 }} />
|
||||
<FlexCenter>
|
||||
<Button onClick={onClickPrimaryButton} ref={primaryButtonRef}>
|
||||
Yes
|
||||
</Button>
|
||||
<div style={{ width: 20 }} />
|
||||
<Button onClick={onClickSecondaryButton} ref={secondaryButtonRef}>
|
||||
No
|
||||
</Button>
|
||||
</FlexCenter>
|
||||
</div>
|
||||
);
|
||||
}
|
38
src/components/proxies/Proxies.module.scss
Normal file
38
src/components/proxies/Proxies.module.scss
Normal file
|
@ -0,0 +1,38 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.topBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
z-index: 1;
|
||||
background-color: var(--color-background2);
|
||||
backdrop-filter: blur(36px);
|
||||
}
|
||||
|
||||
.topBarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.textFilterContainer {
|
||||
max-width: 350px;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 10px 15px;
|
||||
@media (--breakpoint-not-small) {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
}
|
128
src/components/proxies/Proxies.tsx
Normal file
128
src/components/proxies/Proxies.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { Tooltip } from '@reach/tooltip';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '~/components/Button';
|
||||
import ContentHeader from '~/components/ContentHeader';
|
||||
import { ClosePrevConns } from '~/components/proxies/ClosePrevConns';
|
||||
import { ProxyGroup } from '~/components/proxies/ProxyGroup';
|
||||
import { ProxyPageFab } from '~/components/proxies/ProxyPageFab';
|
||||
import { ProxyProviderList } from '~/components/proxies/ProxyProviderList';
|
||||
import Settings from '~/components/proxies/Settings';
|
||||
import BaseModal from '~/components/shared/BaseModal';
|
||||
import { TextFilter } from '~/components/shared/TextFitler';
|
||||
import { connect, useStoreActions } from '~/components/StateProvider';
|
||||
import Equalizer from '~/components/svg/Equalizer';
|
||||
import { getClashAPIConfig } from '~/store/app';
|
||||
import {
|
||||
fetchProxies,
|
||||
getDelay,
|
||||
getProxyGroupNames,
|
||||
getProxyProviders,
|
||||
getShowModalClosePrevConns,
|
||||
proxyFilterText,
|
||||
} from '~/store/proxies';
|
||||
import type { State } from '~/store/types';
|
||||
|
||||
import s0 from './Proxies.module.scss';
|
||||
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
function Proxies({
|
||||
dispatch,
|
||||
groupNames,
|
||||
delay,
|
||||
proxyProviders,
|
||||
apiConfig,
|
||||
showModalClosePrevConns,
|
||||
}) {
|
||||
const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({});
|
||||
|
||||
const fetchProxiesHooked = useCallback(() => {
|
||||
refFetchedTimestamp.current.startAt = Date.now();
|
||||
dispatch(fetchProxies(apiConfig)).then(() => {
|
||||
refFetchedTimestamp.current.completeAt = Date.now();
|
||||
});
|
||||
}, [apiConfig, dispatch]);
|
||||
useEffect(() => {
|
||||
// fetch it now
|
||||
fetchProxiesHooked();
|
||||
|
||||
// arm a window on focus listener to refresh it
|
||||
const fn = () => {
|
||||
if (
|
||||
refFetchedTimestamp.current.startAt &&
|
||||
Date.now() - refFetchedTimestamp.current.startAt > 3e4 // 30s
|
||||
) {
|
||||
fetchProxiesHooked();
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', fn, false);
|
||||
return () => window.removeEventListener('focus', fn, false);
|
||||
}, [fetchProxiesHooked]);
|
||||
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const closeSettingsModal = useCallback(() => {
|
||||
setIsSettingsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
proxies: { closeModalClosePrevConns, closePrevConnsAndTheModal },
|
||||
} = useStoreActions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseModal isOpen={isSettingsModalOpen} onRequestClose={closeSettingsModal}>
|
||||
<Settings />
|
||||
</BaseModal>
|
||||
<div className={s0.topBar}>
|
||||
<ContentHeader title={t('Proxies')} />
|
||||
<div className={s0.topBarRight}>
|
||||
<div className={s0.textFilterContainer}>
|
||||
<TextFilter textAtom={proxyFilterText} placeholder={t('Search')} />
|
||||
</div>
|
||||
<Tooltip label={t('settings')}>
|
||||
<Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}>
|
||||
<Equalizer size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{groupNames.map((groupName: string) => {
|
||||
return (
|
||||
<div className={s0.group} key={groupName}>
|
||||
<ProxyGroup
|
||||
name={groupName}
|
||||
delay={delay}
|
||||
apiConfig={apiConfig}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProxyProviderList items={proxyProviders} />
|
||||
<div style={{ height: 60 }} />
|
||||
<ProxyPageFab dispatch={dispatch} apiConfig={apiConfig} proxyProviders={proxyProviders} />
|
||||
<BaseModal isOpen={showModalClosePrevConns} onRequestClose={closeModalClosePrevConns}>
|
||||
<ClosePrevConns
|
||||
onClickPrimaryButton={() => closePrevConnsAndTheModal(apiConfig)}
|
||||
onClickSecondaryButton={closeModalClosePrevConns}
|
||||
/>
|
||||
</BaseModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s: State) => ({
|
||||
apiConfig: getClashAPIConfig(s),
|
||||
groupNames: getProxyGroupNames(s),
|
||||
proxyProviders: getProxyProviders(s),
|
||||
delay: getDelay(s),
|
||||
showModalClosePrevConns: getShowModalClosePrevConns(s),
|
||||
});
|
||||
|
||||
export default connect(mapState)(Proxies);
|
103
src/components/proxies/Proxy.module.scss
Normal file
103
src/components/proxies/Proxy.module.scss
Normal file
|
@ -0,0 +1,103 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.proxy {
|
||||
padding: 5px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
outline: none;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-focus-blue);
|
||||
}
|
||||
|
||||
@media (--breakpoint-not-small) {
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
background-color: var(--color-bg-proxy);
|
||||
|
||||
&.now {
|
||||
background-color: var(--color-focus-blue);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
&.error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&.selectable {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: hsl(0deg, 0%, var(--card-hover-border-lightness));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.proxyType {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6em;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
.udpType {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6em;
|
||||
margin-right: 3px;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
.tfoType {
|
||||
padding: 2px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proxyName {
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.85em;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
.proxySmall {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
|
||||
.now {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&.selectable {
|
||||
transition: transform 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
231
src/components/proxies/Proxy.tsx
Normal file
231
src/components/proxies/Proxy.tsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
import { TooltipPopup, useTooltip } from '@reach/tooltip';
|
||||
import cx from 'clsx';
|
||||
import * as React from 'react';
|
||||
|
||||
import { keyCodes } from '~/misc/keycode';
|
||||
import {
|
||||
getLatencyTestUrl,
|
||||
} from '~/store/app';
|
||||
import { ProxyItem } from '~/store/types';
|
||||
|
||||
import { getDelay, getProxies, NonProxyTypes } from '../../store/proxies';
|
||||
import { connect } from '../StateProvider';
|
||||
import s0 from './Proxy.module.scss';
|
||||
import { ProxyLatency } from './ProxyLatency';
|
||||
|
||||
const { useMemo } = React;
|
||||
|
||||
const colorMap = {
|
||||
// green
|
||||
good: '#67c23a',
|
||||
// yellow
|
||||
normal: '#d4b75c',
|
||||
// orange
|
||||
bad: '#e67f3c',
|
||||
// bad: '#F56C6C',
|
||||
na: '#909399',
|
||||
};
|
||||
|
||||
function getLabelColor({
|
||||
number,
|
||||
}: {
|
||||
number?: number;
|
||||
} = {},
|
||||
httpsTest: boolean) {
|
||||
const delayMap = {
|
||||
good: httpsTest ? 800 : 200,
|
||||
normal: httpsTest ? 1500 : 500,
|
||||
};
|
||||
if (number === 0) {
|
||||
return colorMap.na;
|
||||
} else if (number < delayMap.good) {
|
||||
return colorMap.good;
|
||||
} else if (number < delayMap.normal) {
|
||||
return colorMap.normal;
|
||||
} else if (typeof number === 'number') {
|
||||
return colorMap.bad;
|
||||
}
|
||||
return colorMap.na;
|
||||
}
|
||||
|
||||
function getProxyDotBackgroundColor(
|
||||
latency: {
|
||||
number?: number;
|
||||
},
|
||||
proxyType: string,
|
||||
httpsTest: boolean,
|
||||
) {
|
||||
if (NonProxyTypes.indexOf(proxyType) > -1) {
|
||||
return 'linear-gradient(135deg, white 15%, #999 15% 30%, white 30% 45%, #999 45% 60%, white 60% 75%, #999 75% 90%, white 90% 100%)';
|
||||
}
|
||||
return getLabelColor(latency, httpsTest);
|
||||
}
|
||||
|
||||
type ProxyProps = {
|
||||
name: string;
|
||||
now?: boolean;
|
||||
proxy: ProxyItem;
|
||||
latency: any;
|
||||
httpsLatencyTest: boolean,
|
||||
isSelectable?: boolean;
|
||||
udp: boolean;
|
||||
tfo: boolean;
|
||||
onClick?: (proxyName: string) => unknown;
|
||||
};
|
||||
|
||||
function ProxySmallImpl({ now, name, proxy, latency, httpsLatencyTest, isSelectable, onClick }: ProxyProps) {
|
||||
const color = useMemo(() => getProxyDotBackgroundColor(latency, proxy.type, httpsLatencyTest), [latency, proxy]);
|
||||
const title = useMemo(() => {
|
||||
let ret = name;
|
||||
if (latency && typeof latency.number === 'number') {
|
||||
ret += ' ' + latency.number + ' ms';
|
||||
}
|
||||
return ret;
|
||||
}, [name, latency]);
|
||||
|
||||
const doSelect = React.useCallback(() => {
|
||||
isSelectable && onClick && onClick(name);
|
||||
}, [name, onClick, isSelectable]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === keyCodes.Enter) {
|
||||
doSelect();
|
||||
}
|
||||
},
|
||||
[doSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
className={cx(s0.proxySmall, {
|
||||
[s0.selectable]: isSelectable,
|
||||
})}
|
||||
style={{ background: color, scale: now ? '1.6' : '1' }}
|
||||
onClick={doSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
role={isSelectable ? 'menuitem' : ''}
|
||||
>
|
||||
{now && <div className={s0.now} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatProxyType(t: string) {
|
||||
if (t === 'Shadowsocks') return 'SS';
|
||||
return t;
|
||||
}
|
||||
|
||||
const positionProxyNameTooltip = (triggerRect: { left: number; top: number }) => {
|
||||
return {
|
||||
left: triggerRect.left + window.scrollX - 5,
|
||||
top: triggerRect.top + window.scrollY - 38,
|
||||
};
|
||||
};
|
||||
|
||||
function ProxyNameTooltip({ children, label, 'aria-label': ariaLabel }) {
|
||||
const [trigger, tooltip] = useTooltip();
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, trigger)}
|
||||
<TooltipPopup
|
||||
{...tooltip}
|
||||
label={label}
|
||||
aria-label={ariaLabel}
|
||||
position={positionProxyNameTooltip}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyImpl({ now, name, proxy, latency, httpsLatencyTest, isSelectable, onClick }: ProxyProps) {
|
||||
const color = useMemo(() => getLabelColor(latency, httpsLatencyTest), [latency]);
|
||||
const doSelect = React.useCallback(() => {
|
||||
isSelectable && onClick && onClick(name);
|
||||
}, [name, onClick, isSelectable]);
|
||||
function formatUdpType(udp: boolean, xudp?: boolean) {
|
||||
if (!udp) return '';
|
||||
return xudp ? 'XUDP' : 'UDP';
|
||||
}
|
||||
function formatTfo(t: boolean) {
|
||||
if (!t) return '';
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2962"
|
||||
width="10"
|
||||
height="10"
|
||||
>
|
||||
<path
|
||||
d="M648.093513 719.209284l-1.492609-40.940127 31.046263-26.739021c202.73892-174.805813 284.022131-385.860697 255.70521-561.306199-176.938111-28.786027-389.698834 51.857494-563.907604 254.511123l-26.31256 30.619803-40.38573-0.938211c-60.557271-1.407317-111.903014 12.79379-162.822297 47.0385l189.561318 127.084977-37.95491 68.489421c-9.126237 16.461343-0.554398 53.307457 29.084549 82.818465 29.5963 29.511008 67.380626 38.381369 83.287571 29.852176l68.318836-36.760822 127.639376 191.267156c36.163779-52.11337 50.450177-103.629696 48.189941-165.039887zM994.336107 16.105249l10.490908 2.686696 2.64405 10.405615c47.46496 178.089552-1.023503 451.492838-274.170913 686.898568 4.051367 111.263324-35.396151 200.222809-127.255561 291.741051l-15.779008 15.693715-145.934494-218.731157c-51.217805 27.59194-128.790816 10.405616-183.93205-44.522388-55.226525-55.013296-72.41285-132.287785-43.498885-184.529093L0.002773 430.325513l15.736362-15.65107c89.300652-88.959484 178.64395-128.108481 289.011709-125.549722C539.730114 15.806727 815.56422-31.061189 994.336107 16.105249zM214.93844 805.098259c28.572797 28.572797 22.346486 79.49208-12.537914 114.376479C156.428175 965.489735 34.034254 986.002445 34.034254 986.002445s25.331704-127.084978 66.612998-168.323627c34.8844-34.8844 85.633099-41.281295 114.291188-12.580559zM661.01524 298.549479a63.968948 63.968948 0 1 0 0 127.937897 63.968948 63.968948 0 0 0 0-127.937897z"
|
||||
p-id="2963"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === keyCodes.Enter) {
|
||||
doSelect();
|
||||
}
|
||||
},
|
||||
[doSelect]
|
||||
);
|
||||
const className = useMemo(() => {
|
||||
return cx(s0.proxy, {
|
||||
[s0.now]: now,
|
||||
[s0.error]: latency && latency.error,
|
||||
[s0.selectable]: isSelectable,
|
||||
});
|
||||
}, [isSelectable, now, latency]);
|
||||
|
||||
const latencyNumber = latency?.number ?? proxy.history[proxy.history.length - 1]?.delay;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={className}
|
||||
onClick={doSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
role={isSelectable ? 'menuitem' : ''}
|
||||
>
|
||||
<div className={cx(s0.proxyName, s0.row)}>
|
||||
<ProxyNameTooltip label={name} aria-label={`proxy name: ${name}`}>
|
||||
<span>{name}</span>
|
||||
</ProxyNameTooltip>
|
||||
<span className={s0.proxyType} style={{ paddingLeft: 4, opacity: now ? 0.6 : 0.2 }}>
|
||||
{formatUdpType(proxy.udp, proxy.xudp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={s0.row}>
|
||||
<div className={s0.row}>
|
||||
<span className={s0.proxyType} style={{ paddingRight: 4, opacity: now ? 0.6 : 0.2 }}>
|
||||
{formatProxyType(proxy.type)}
|
||||
</span>
|
||||
|
||||
{formatTfo(proxy.tfo)}
|
||||
</div>
|
||||
|
||||
{latencyNumber ? <ProxyLatency number={latencyNumber} color={color} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapState = (s: any, { name }) => {
|
||||
const proxies = getProxies(s);
|
||||
const delay = getDelay(s);
|
||||
const latencyTestUrl = getLatencyTestUrl(s);
|
||||
return {
|
||||
proxy: proxies[name],
|
||||
latency: delay[name],
|
||||
httpsLatencyTest: latencyTestUrl.startsWith('https://'),
|
||||
};
|
||||
};
|
||||
|
||||
export const Proxy = connect(mapState)(ProxyImpl);
|
||||
export const ProxySmall = connect(mapState)(ProxySmallImpl);
|
11
src/components/proxies/ProxyGroup.module.scss
Normal file
11
src/components/proxies/ProxyGroup.module.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
.header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.zapWrapper {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
137
src/components/proxies/ProxyGroup.tsx
Normal file
137
src/components/proxies/ProxyGroup.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import * as React from 'react';
|
||||
import { Zap } from 'react-feather';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import * as proxiesAPI from '~/api/proxies';
|
||||
import { fetchVersion } from '~/api/version';
|
||||
import {
|
||||
getCollapsibleIsOpen,
|
||||
getHideUnavailableProxies,
|
||||
getLatencyTestUrl,
|
||||
getProxySortBy,
|
||||
} from '~/store/app';
|
||||
import { fetchProxies, getProxies, switchProxy } from '~/store/proxies';
|
||||
|
||||
import Button from '../Button';
|
||||
import CollapsibleSectionHeader from '../CollapsibleSectionHeader';
|
||||
import { connect, useStoreActions } from '../StateProvider';
|
||||
import { useFilteredAndSorted } from './hooks';
|
||||
import s0 from './ProxyGroup.module.scss';
|
||||
import { ProxyList, ProxyListSummaryView } from './ProxyList';
|
||||
|
||||
const { createElement, useCallback, useMemo, useState } = React;
|
||||
|
||||
function ZapWrapper() {
|
||||
return (
|
||||
<div className={s0.zapWrapper}>
|
||||
<Zap size={16} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyGroupImpl({
|
||||
name,
|
||||
all: allItems,
|
||||
delay,
|
||||
hideUnavailableProxies,
|
||||
proxySortBy,
|
||||
proxies,
|
||||
type,
|
||||
now,
|
||||
isOpen,
|
||||
latencyTestUrl,
|
||||
apiConfig,
|
||||
dispatch,
|
||||
}) {
|
||||
const all = useFilteredAndSorted(allItems, delay, hideUnavailableProxies, proxySortBy, proxies);
|
||||
|
||||
const { data: version } = useQuery(['/version', apiConfig], () =>
|
||||
fetchVersion('/version', apiConfig)
|
||||
);
|
||||
|
||||
const isSelectable = useMemo(
|
||||
() => ['Selector', version.meta && 'Fallback'].includes(type),
|
||||
[type, version.meta]
|
||||
);
|
||||
|
||||
const {
|
||||
app: { updateCollapsibleIsOpen },
|
||||
proxies: { requestDelayForProxies },
|
||||
} = useStoreActions();
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
updateCollapsibleIsOpen('proxyGroup', name, !isOpen);
|
||||
}, [isOpen, updateCollapsibleIsOpen, name]);
|
||||
|
||||
const itemOnTapCallback = useCallback(
|
||||
(proxyName) => {
|
||||
if (!isSelectable) return;
|
||||
dispatch(switchProxy(apiConfig, name, proxyName));
|
||||
},
|
||||
[apiConfig, dispatch, name, isSelectable]
|
||||
);
|
||||
const [isTestingLatency, setIsTestingLatency] = useState(false);
|
||||
const testLatency = useCallback(async () => {
|
||||
setIsTestingLatency(true);
|
||||
try {
|
||||
if (version.meta === true) {
|
||||
await proxiesAPI.requestDelayForProxyGroup(apiConfig, name, latencyTestUrl);
|
||||
await dispatch(fetchProxies(apiConfig));
|
||||
} else {
|
||||
await requestDelayForProxies(apiConfig, all);
|
||||
await dispatch(fetchProxies(apiConfig));
|
||||
}
|
||||
} catch (err) {}
|
||||
setIsTestingLatency(false);
|
||||
}, [all, apiConfig, dispatch, name, version.meta]);
|
||||
|
||||
return (
|
||||
<div className={s0.group}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CollapsibleSectionHeader
|
||||
name={name}
|
||||
type={type}
|
||||
toggle={toggle}
|
||||
qty={all.length}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
<Button
|
||||
title="Test latency"
|
||||
kind="minimal"
|
||||
onClick={testLatency}
|
||||
isLoading={isTestingLatency}
|
||||
>
|
||||
<ZapWrapper />
|
||||
</Button>
|
||||
</div>
|
||||
{createElement(isOpen ? ProxyList : ProxyListSummaryView, {
|
||||
all,
|
||||
now,
|
||||
isSelectable,
|
||||
itemOnTapCallback,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProxyGroup = connect((s, { name, delay }) => {
|
||||
const proxies = getProxies(s);
|
||||
const collapsibleIsOpen = getCollapsibleIsOpen(s);
|
||||
const proxySortBy = getProxySortBy(s);
|
||||
const hideUnavailableProxies = getHideUnavailableProxies(s);
|
||||
const latencyTestUrl = getLatencyTestUrl(s);
|
||||
|
||||
const group = proxies[name];
|
||||
const { all, type, now } = group;
|
||||
return {
|
||||
all,
|
||||
delay,
|
||||
hideUnavailableProxies,
|
||||
proxySortBy,
|
||||
proxies,
|
||||
type,
|
||||
now,
|
||||
isOpen: collapsibleIsOpen[`proxyGroup:${name}`],
|
||||
latencyTestUrl,
|
||||
};
|
||||
})(ProxyGroupImpl);
|
10
src/components/proxies/ProxyLatency.module.scss
Normal file
10
src/components/proxies/ProxyLatency.module.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.proxyLatency {
|
||||
border-radius: 20px;
|
||||
color: #eee;
|
||||
font-size: 0.6em;
|
||||
@media (--breakpoint-not-small) {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
16
src/components/proxies/ProxyLatency.tsx
Normal file
16
src/components/proxies/ProxyLatency.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import s0 from './ProxyLatency.module.scss';
|
||||
|
||||
type ProxyLatencyProps = {
|
||||
number: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function ProxyLatency({ number, color }: ProxyLatencyProps) {
|
||||
return (
|
||||
<span className={s0.proxyLatency} style={{ color }}>
|
||||
<span>{number} ms</span>
|
||||
</span>
|
||||
);
|
||||
}
|
19
src/components/proxies/ProxyList.module.scss
Normal file
19
src/components/proxies/ProxyList.module.scss
Normal file
|
@ -0,0 +1,19 @@
|
|||
@import '~/styles/utils/custom-media';
|
||||
|
||||
.list {
|
||||
margin: 8px 0;
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-template-columns: auto auto;
|
||||
|
||||
@media (--breakpoint-not-small) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
grid-template-columns: repeat(auto-fill, 12px);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue