first commit

This commit is contained in:
liyp 2023-03-06 09:10:48 +08:00
commit 95e1515228
169 changed files with 17501 additions and 0 deletions

419
CHANGELOG.md Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
yacd.metacubex.one

12
assets/_headers Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
assets/yacd.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

BIN
assets/yacd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

3
docker-entrypoint.sh Executable file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

4
postcss.config.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;
}

View 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'];
}
}

View 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;
}

View 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);

View 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);
}

View 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>
);
}

View 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
View 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);

View 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;

View 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;
}

View 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>
);
}

View 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
View 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>
);
}

View 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);
}

View 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;

View 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;
}

View 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%;
}

View 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);

View 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;
}

View 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);

View 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;

View 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;
}
}

View 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;

View 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
View 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>
);
}

View 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
View 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
View 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);

View 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
View 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} />;
}

View 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);
}
}

View 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;

View file

@ -0,0 +1,8 @@
.lo {
opacity: 0.5;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

View 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;

View 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);

View 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
View 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);

View 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
View 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);

View 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;
}

View 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>
);
}

View 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
View 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;

View 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
View 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>
);
}

View 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
View 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;

View 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;
}

View 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>
);
}

File diff suppressed because one or more lines are too long

105
src/components/SideBar.tsx Normal file
View 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>
);
}

View 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;
}

View 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;

View 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>
);
}

View 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;
}
}

View 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;

View 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);

View 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);
}

View 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);

View 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>
);
}

View 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>
);
}

View 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%;
}
}
}

View 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;
}

View 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);
}

View 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);

View 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>
);
}

View 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;
}
}

View 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);

View 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);
}
}
}

View 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);

View file

@ -0,0 +1,11 @@
.header {
margin-bottom: 12px;
}
.zapWrapper {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}

View 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);

View 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;
}
}

View 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>
);
}

View 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