update to 0.3.6
Some checks failed
continuous-integration/drone/push Build is passing
Deploy / build (push) Failing after 3s

This commit is contained in:
liyp 2023-04-27 21:28:17 +08:00
parent 5d67349046
commit e33fb875dd
38 changed files with 6010 additions and 2804 deletions

7
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'daily'

30
.github/workflows/push.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Deploy
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Dashboard code
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: latest
- name: Setup Nodejs
uses: actions/setup-node@v3
with:
node-version: '18.x'
cache: pnpm
- name: Install package and build
run: |
pnpm install --no-frozen-lockfile
pnpm build
- name: Deploy
uses: crazy-max/ghaction-github-pages@v2
with:
target_branch: gh-pages
build_dir: public
fqdn: yacd.metacubex.one
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
public

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View file

@ -1,5 +1,7 @@
[中文](./README_CN.md)
<h1 align="center"> <h1 align="center">
<img src="https://user-images.githubusercontent.com/1166872/47954055-97e6cb80-dfc0-11e8-991f-230fd40481e5.png" alt="yacd"> <img src="https://user-images.githubusercontent.com/78135608/232244383-5e1389db-ce56-4c83-9627-4f3d1a489c6e.png" alt="yacd">
</h1> </h1>
> Yet Another [Clash](https://github.com/yaling888/clash) [Dashboard](https://github.com/yaling888/clash-dashboard) > Yet Another [Clash](https://github.com/yaling888/clash) [Dashboard](https://github.com/yaling888/clash-dashboard)
@ -8,8 +10,7 @@
Install [twemoji](https://github.com/mozilla/twemoji-colr/releases) to display emoji better on Windows system. 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/MetaCubeX/yacd/archive/gh-pages.zip), unzip and serve those static files with a web server(like Nginx).
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** **Supported URL query params**

36
README_CN.md Normal file
View file

@ -0,0 +1,36 @@
<h1 align="center">
<img src="https://user-images.githubusercontent.com/78135608/232244383-5e1389db-ce56-4c83-9627-4f3d1a489c6e.png" alt="yacd">
</h1>
> Yet Another [Clash](https://github.com/yaling888/clash) [Dashboard](https://github.com/yaling888/clash-dashboard)
## 用法
安装[twemoji](https://github.com/mozilla/twemoji-colr/releases)以在 Windows 系统上更好地显示表情符号。
网站 http://yacd.metacubex.one 是通过 HTTP 提供服务的,而不是 HTTPS因为许多浏览器会阻止从 HTTPS 网站请求 HTTP 资源。如果认为这不安全,可以下载[gh-pages 的 zip 文件](https://github.com/MetaCubeX/yacd/archive/gh-pages.zip),解压缩并使用 Web 服务器(如 Nginx提供这些静态文件。
**支持的 URL 查询参数**
| 参数 | 描述 |
| -------- | ------------------------------------------------------------------ |
| hostname | Clash 后端 API 的主机名(通常是`external-controller`的 host 部分) |
| port | Clash 后端 API 的端口号(通常是`external-controller`的 port 部分) |
| secret | Clash API 密钥(`config.yaml`中的"secret" |
| theme | UI 颜色方案dark、light、auto |
## 开发部署
```sh
# 安装依赖库
# 你可以使用 `npm i -g pnpm` 安装 pnpm
pnpm i
# 启动开发服务器
# 然后访问 http://127.0.0.1:3000
pnpm start
# 构建优化资源
# 准备好部署的资源将在目录 `public`
pnpm build
```

BIN
assets/Twemoji_Mozilla.ttf Normal file

Binary file not shown.

View file

@ -1,8 +1,8 @@
{ {
"name": "yacd", "name": "yacd",
"version": "0.3.5", "version": "0.3.6",
"description": "Yet another Clash dashboard", "description": "Yet another Clash dashboard",
"author": "Haishan <haishanhan@gmail.com> (https://haishan.me)", "author": "MetaCubeX (https://github.com/MetaCubeX)",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"keywords": [ "keywords": [

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ import { ClashAPIConfig } from '~/types';
const endpoint = '/configs'; const endpoint = '/configs';
const updateGeoDatabasesFileEndpoint = '/configs/geo'; const updateGeoDatabasesFileEndpoint = '/configs/geo';
const flushFakeIPPoolEndpoint = '/cache/fakeip/flush'; const flushFakeIPPoolEndpoint = '/cache/fakeip/flush';
const restartCoreEndpoint = '/restart';
const upgradeCoreEndpoint = '/upgrade';
export async function fetchConfigs(apiConfig: ClashAPIConfig) { export async function fetchConfigs(apiConfig: ClashAPIConfig) {
const { url, init } = getURLAndInit(apiConfig); const { url, init } = getURLAndInit(apiConfig);
@ -42,6 +44,18 @@ export async function updateGeoDatabasesFile(apiConfig: ClashAPIConfig) {
return await fetch(url + updateGeoDatabasesFileEndpoint, { ...init, body, method: 'POST' }); return await fetch(url + updateGeoDatabasesFileEndpoint, { ...init, body, method: 'POST' });
} }
export async function restartCore(apiConfig: ClashAPIConfig) {
const { url, init } = getURLAndInit(apiConfig);
const body = '{"path": "", "payload": ""}';
return await fetch(url + restartCoreEndpoint, { ...init, body, method: 'POST' });
}
export async function upgradeCore(apiConfig: ClashAPIConfig) {
const { url, init } = getURLAndInit(apiConfig);
const body = '{"path": "", "payload": ""}';
return await fetch(url + upgradeCoreEndpoint, { ...init, body, method: 'POST' });
}
export async function flushFakeIPPool(apiConfig: ClashAPIConfig) { export async function flushFakeIPPool(apiConfig: ClashAPIConfig) {
const { url, init } = getURLAndInit(apiConfig); const { url, init } = getURLAndInit(apiConfig);
return await fetch(url + flushFakeIPPoolEndpoint, { ...init, method: 'POST' }); return await fetch(url + flushFakeIPPoolEndpoint, { ...init, method: 'POST' });

View file

@ -5,6 +5,12 @@ import { buildWebSocketURL, getURLAndInit } from '../misc/request-helper';
const endpoint = '/connections'; const endpoint = '/connections';
const fetched = false; const fetched = false;
interface Subscriber {
listner: unknown; // on data received, listener will be called with data
onClose: () => void; // on stream closed, onClose will be called
}
const subscribers = []; const subscribers = [];
// see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41 // see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41
@ -48,28 +54,49 @@ function appendData(s: string) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('JSON.parse error', JSON.parse(s)); console.log('JSON.parse error', JSON.parse(s));
} }
subscribers.forEach((f) => f(o)); subscribers.forEach((s) => s.listner(o));
} }
type UnsubscribeFn = () => void; type UnsubscribeFn = () => void;
let wsState: number; let wsState: number;
export function fetchData(apiConfig: ClashAPIConfig, listener: unknown): UnsubscribeFn | void { export function fetchData(
apiConfig: ClashAPIConfig,
listener: unknown,
onClose: () => void
): UnsubscribeFn | void {
if (fetched || wsState === 1) { if (fetched || wsState === 1) {
if (listener) return subscribe(listener); if (listener)
return subscribe({
listner: listener,
onClose,
});
} }
wsState = 1; wsState = 1;
const url = buildWebSocketURL(apiConfig, endpoint); const url = buildWebSocketURL(apiConfig, endpoint);
const ws = new WebSocket(url); const ws = new WebSocket(url);
ws.addEventListener('error', () => (wsState = 3)); ws.addEventListener('error', () => {
wsState = 3;
subscribers.forEach((s) => s.onClose());
subscribers.length = 0;
});
ws.addEventListener('close', () => {
wsState = 3;
subscribers.forEach((s) => s.onClose());
subscribers.length = 0;
});
ws.addEventListener('message', (event) => appendData(event.data)); ws.addEventListener('message', (event) => appendData(event.data));
if (listener) return subscribe(listener); if (listener)
return subscribe({
listner: listener,
onClose,
});
} }
function subscribe(listener: unknown): UnsubscribeFn { function subscribe(subscriber: Subscriber): UnsubscribeFn {
subscribers.push(listener); subscribers.push(subscriber);
return function unsubscribe() { return function unsubscribe() {
const idx = subscribers.indexOf(listener); const idx = subscribers.indexOf(subscriber);
subscribers.splice(idx, 1); subscribers.splice(idx, 1);
}; };
} }

119
src/api/memory.ts Normal file
View file

@ -0,0 +1,119 @@
import { ClashAPIConfig } from '~/types';
import { buildWebSocketURL, getURLAndInit } from '../misc/request-helper';
const endpoint = '/memory';
const textDecoder = new TextDecoder('utf-8');
const Size = 150;
const memory = {
labels: Array(Size).fill(0),
inuse: Array(Size),
oslimit: Array(Size),
size: Size,
subscribers: [],
appendData(o: { inuse: number; oslimit: number }) {
this.inuse.shift();
this.oslimit.shift();
this.labels.shift();
const l = Date.now();
this.inuse.push(o.inuse);
this.oslimit.push(o.oslimit);
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) {
memory.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 /memory 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 memory;
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 memory;
}
function fetchDataWithFetch(apiConfig: ClashAPIConfig) {
if (fetched) return memory;
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 /memory error', err);
fetched = false;
}
);
return memory;
}
export { fetchData };

View file

@ -34,7 +34,7 @@ export async function requestToSwitchProxy(apiConfig, name1, name2) {
export async function requestDelayForProxy( export async function requestDelayForProxy(
apiConfig, apiConfig,
name, name,
latencyTestUrl = 'http://www.gstatic.com/generate_204' latencyTestUrl = 'https://www.gstatic.com/generate_204'
) { ) {
const { url, init } = getURLAndInit(apiConfig); const { url, init } = getURLAndInit(apiConfig);
const qs = `timeout=5000&url=${encodeURIComponent(latencyTestUrl)}`; const qs = `timeout=5000&url=${encodeURIComponent(latencyTestUrl)}`;

View file

@ -15,8 +15,10 @@ import {
flushFakeIPPool, flushFakeIPPool,
getConfigs, getConfigs,
reloadConfigFile, reloadConfigFile,
restartCore,
updateConfigs, updateConfigs,
updateGeoDatabasesFile, updateGeoDatabasesFile,
upgradeCore,
} from '../store/configs'; } from '../store/configs';
import { openModal } from '../store/modals'; import { openModal } from '../store/modals';
import Button from './Button'; import Button from './Button';
@ -206,6 +208,14 @@ function ConfigImpl({
dispatch(reloadConfigFile(apiConfig)); dispatch(reloadConfigFile(apiConfig));
}, [apiConfig, dispatch]); }, [apiConfig, dispatch]);
const handleRestartCore = useCallback(() => {
dispatch(restartCore(apiConfig));
}, [apiConfig, dispatch]);
const handleUpgradeCore = useCallback(() => {
dispatch(upgradeCore(apiConfig));
}, [apiConfig, dispatch]);
const handleUpdateGeoDatabasesFile = useCallback(() => { const handleUpdateGeoDatabasesFile = useCallback(() => {
dispatch(updateGeoDatabasesFile(apiConfig)); dispatch(updateGeoDatabasesFile(apiConfig));
}, [apiConfig, dispatch]); }, [apiConfig, dispatch]);
@ -351,6 +361,22 @@ function ConfigImpl({
onClick={handleFlushFakeIPPool} onClick={handleFlushFakeIPPool}
/> />
</div> </div>
<div>
<div className={s0.label}>Restart</div>
<Button
start={<RotateCw size={16} />}
label={t('restart_core')}
onClick={handleRestartCore}
/>
</div>
<div>
<div className={s0.label}> Upgrade </div>
<Button
start={<RotateCw size={16} />}
label={t('upgrade_core')}
onClick={handleUpgradeCore}
/>
</div>
</div> </div>
<div className={s0.sep}> <div className={s0.sep}>
<div /> <div />

View file

@ -5,15 +5,15 @@
} }
.th { .th {
padding: 8px 10px; padding: 3% 2px;
height: 50px; height: 50px;
background: var(--color-background); background: var(--color-background);
position: sticky; position: sticky;
top: 0; top: 0;
font-size: 0.9em; font-size: 1em;
text-align: center;
user-select: none; user-select: none;
text-align: center;
vertical-align: middle;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -23,10 +23,42 @@
} }
} }
.thdu {
padding: 3% 2px;
height: 50px;
background: var(--color-background);
position: sticky;
top: 0;
font-size: 1em;
user-select: none;
text-align: center;
vertical-align: middle;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--color-text-highlight);
}
}
.break {
word-wrap: break-word;
word-break: break-all;
align-items: center;
text-align: left;
}
.td { .td {
padding: 8px 13px; /* 上边下边 | 左边右边 */
padding: 10px 2px;
font-size: 0.9em; font-size: 0.9em;
// max-width: 14em;
min-width: 9em;
cursor: default; cursor: default;
align-items: center;
text-align: left;
vertical-align: middle;
&:hover { &:hover {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
@ -38,15 +70,16 @@
} }
/* download upload td cells */ /* download upload td cells */
.du { .center {
text-align: right; min-width: 7em;
text-align: center;
} }
.sortIconContainer { .sortIconContainer {
display: inline-flex; display: inline-flex;
margin-left: 10px; margin-left: 0.5em;
width: 16px; width: 1em;
height: 16px; height: 1em;
} }
.rotate180 { .rotate180 {

View file

@ -13,19 +13,19 @@ const sortDescFirst = true;
const columns = [ const columns = [
{ accessor: 'id', show: false }, { accessor: 'id', show: false },
{ Header: 'c_host', accessor: 'host' }, { Header: 'c_type', accessor: 'type' },
{ Header: 'c_sni', accessor: 'sniffHost' },
{ Header: 'c_process', accessor: 'process' }, { Header: 'c_process', accessor: 'process' },
{ Header: 'c_dl', accessor: 'download', sortDescFirst }, { Header: 'c_host', accessor: 'host' },
{ Header: 'c_ul', accessor: 'upload', sortDescFirst }, { Header: 'c_rule', accessor: 'rule' },
{ Header: 'c_chains', accessor: 'chains' },
{ Header: 'c_time', accessor: 'start' },
{ Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst }, { Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst },
{ Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst }, { Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst },
{ Header: 'c_chains', accessor: 'chains' }, { Header: 'c_dl', accessor: 'download', sortDescFirst },
{ Header: 'c_rule', accessor: 'rule' }, { Header: 'c_ul', accessor: 'upload', sortDescFirst },
{ Header: 'c_time', accessor: 'start', sortDescFirst },
{ Header: 'c_source', accessor: 'source' }, { Header: 'c_source', accessor: 'source' },
{ Header: 'c_destination_ip', accessor: 'destinationIP' }, { Header: 'c_destination_ip', accessor: 'destinationIP' },
{ Header: 'c_type', accessor: 'type' }, { Header: 'c_sni', accessor: 'sniffHost' },
]; ];
function renderCell(cell: { column: { id: string }; value: number }, locale: Locale) { function renderCell(cell: { column: { id: string }; value: number }, locale: Locale) {
@ -71,8 +71,11 @@ function Table({ data }) {
{headerGroups.map((headerGroup) => { {headerGroups.map((headerGroup) => {
return ( return (
<div {...headerGroup.getHeaderGroupProps()} className={s.tr}> <div {...headerGroup.getHeaderGroupProps()} className={s.tr}>
{headerGroup.headers.map((column) => ( {headerGroup.headers.map((column, index) => (
<div {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}> <div
{...column.getHeaderProps(column.getSortByToggleProps())}
className={index == 0 || (index >= 5 && index < 10) ? s.thdu : s.th}
>
<span>{t(column.render('Header'))}</span> <span>{t(column.render('Header'))}</span>
<span className={s.sortIconContainer}> <span className={s.sortIconContainer}>
{column.isSorted ? ( {column.isSorted ? (
@ -93,7 +96,8 @@ function Table({ data }) {
className={cx( className={cx(
s.td, s.td,
i % 2 === 0 ? s.odd : false, i % 2 === 0 ? s.odd : false,
j >= 2 && j <= 5 ? s.du : false j == 0 || (j >= 5 && j < 10) ? s.center : true
// j ==1 ? s.break : true
)} )}
> >
{renderCell(cell, locale)} {renderCell(cell, locale)}

View file

@ -47,3 +47,7 @@
.react-tabs__tab-panel--selected { .react-tabs__tab-panel--selected {
display: block; display: block;
} }
._btn_lzu00_1 {
margin-right: 10px;
}

View file

@ -11,6 +11,7 @@ import { State } from '~/store/types';
import * as connAPI from '../api/connections'; import * as connAPI from '../api/connections';
import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
import { getClashAPIConfig } from '../store/app'; import { getClashAPIConfig } from '../store/app';
import Button from './Button';
import s from './Connections.module.scss'; import s from './Connections.module.scss';
import ConnectionTable from './ConnectionTable'; import ConnectionTable from './ConnectionTable';
import ContentHeader from './ContentHeader'; import ContentHeader from './ContentHeader';
@ -58,22 +59,38 @@ function hasSubstring(s: string, pat: string) {
return s.toLowerCase().includes(pat.toLowerCase()); return s.toLowerCase().includes(pat.toLowerCase());
} }
function filterConns(conns: FormattedConn[], keyword: string) { function filterConnIps(conns: FormattedConn[], ipStr: string) {
return !keyword return conns.filter((each) => each.sourceIP === ipStr);
? conns }
: conns.filter((conn) =>
[ function filterConns(conns: FormattedConn[], keyword: string, sourceIp: string) {
conn.host, let result = conns;
conn.sourceIP, if (keyword !== '') {
conn.sourcePort, result = conns.filter((conn) =>
conn.destinationIP, [
conn.chains, conn.host,
conn.rule, conn.sourceIP,
conn.type, conn.sourcePort,
conn.network, conn.destinationIP,
conn.process, conn.chains,
].some((field) => hasSubstring(field, keyword)) conn.rule,
conn.type,
conn.network,
conn.process,
].some((field) => {
return hasSubstring(field, keyword);
})
); );
}
if (sourceIp !== '') {
result = filterConnIps(result, sourceIp);
}
return result;
}
function getConnIpList(conns: FormattedConn[]) {
return Array.from(new Set(conns.map((x) => x.sourceIP))).sort();
} }
function formatConnectionDataItem( function formatConnectionDataItem(
@ -103,7 +120,7 @@ function formatConnectionDataItem(
upload, upload,
download, download,
start: now - new Date(start).valueOf(), start: now - new Date(start).valueOf(),
chains: chains.reverse().join(' / '), chains: modifyChains(chains),
rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`, rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
...metadata, ...metadata,
host: `${host2}:${destinationPort}`, host: `${host2}:${destinationPort}`,
@ -117,6 +134,24 @@ function formatConnectionDataItem(
}; };
return ret; return ret;
} }
function modifyChains(chains: string[]): string {
if (!Array.isArray(chains) || chains.length === 0) {
return '';
}
if (chains.length === 1) {
return chains[0];
}
//倒序
if (chains.length === 2) {
return chains[1] + ' / ' + chains[0];
}
const first = chains.pop();
const last = chains.shift();
return `${first} / ${last}`;
}
function renderTableOrPlaceholder(conns: FormattedConn[]) { function renderTableOrPlaceholder(conns: FormattedConn[]) {
return conns.length > 0 ? ( return conns.length > 0 ? (
@ -134,11 +169,19 @@ function ConnQty({ qty }) {
function Conn({ apiConfig }) { function Conn({ apiConfig }) {
const [refContainer, containerHeight] = useRemainingViewPortHeight(); const [refContainer, containerHeight] = useRemainingViewPortHeight();
const [conns, setConns] = useState([]); const [conns, setConns] = useState([]);
const [closedConns, setClosedConns] = useState([]); const [closedConns, setClosedConns] = useState([]);
const [filterKeyword, setFilterKeyword] = useState(''); const [filterKeyword, setFilterKeyword] = useState('');
const filteredConns = filterConns(conns, filterKeyword); const [filterSourceIpStr, setFilterSourceIpStr] = useState('');
const filteredClosedConns = filterConns(closedConns, filterKeyword);
const filteredConns = filterConns(conns, filterKeyword, filterSourceIpStr);
const filteredClosedConns = filterConns(closedConns, filterKeyword, filterSourceIpStr);
const connIpSet = getConnIpList(conns);
const ClosedConnIpSet = getConnIpList(closedConns);
const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false); const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false);
const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []); const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []);
const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []); const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []);
@ -178,9 +221,15 @@ function Conn({ apiConfig }) {
}, },
[setConns, isRefreshPaused] [setConns, isRefreshPaused]
); );
const [reConnectCount, setReConnectCount] = useState(0);
useEffect(() => { useEffect(() => {
return connAPI.fetchData(apiConfig, read); return connAPI.fetchData(apiConfig, read, () => {
}, [apiConfig, read]); setTimeout(() => {
setReConnectCount((prev) => prev + 1);
}, 1000);
});
}, [apiConfig, read, reConnectCount, setReConnectCount]);
const { t } = useTranslation(); const { t } = useTranslation();
@ -230,7 +279,17 @@ function Conn({ apiConfig }) {
}} }}
> >
<TabPanel> <TabPanel>
<>{renderTableOrPlaceholder(filteredConns)}</> <Button onClick={() => setFilterSourceIpStr('')} kind="minimal">
{t('All')}
</Button>
{connIpSet.map((value, k) => {
return (
<Button key={k} onClick={() => setFilterSourceIpStr(value)} kind="minimal">
{value}
</Button>
);
})}
{renderTableOrPlaceholder(filteredConns)}
<Fab <Fab
icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />} icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />}
mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}} mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}}
@ -243,7 +302,19 @@ function Conn({ apiConfig }) {
</Action> </Action>
</Fab> </Fab>
</TabPanel> </TabPanel>
<TabPanel>{renderTableOrPlaceholder(filteredClosedConns)}</TabPanel> <TabPanel>
<Button onClick={() => setFilterSourceIpStr('')} kind="minimal">
{t('All')}
</Button>
{ClosedConnIpSet.map((value, k) => {
return (
<Button key={k} onClick={() => setFilterSourceIpStr(value)} kind="minimal">
{value}
</Button>
);
})}
{renderTableOrPlaceholder(filteredClosedConns)}
</TabPanel>
</div> </div>
</div> </div>
<ModalCloseAllConnections <ModalCloseAllConnections

View file

@ -2,7 +2,10 @@
.root { .root {
padding: 6px 15px; padding: 6px 15px;
@media (--breakpoint-not-small) { @media screen and (min-width: 30em) {
padding: 10px 40px; padding: 10px 40px;
} }
} }
.chart {
margin-top: 20px;
}

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import ContentHeader from './ContentHeader'; import ContentHeader from './ContentHeader';
import s0 from './Home.module.scss'; import s0 from './Home.module.scss';
import Loading from './Loading'; import Loading from './Loading';
import MemoryChart from './MemoryChart';
import TrafficChart from './TrafficChart'; import TrafficChart from './TrafficChart';
import TrafficNow from './TrafficNow'; import TrafficNow from './TrafficNow';
@ -19,6 +20,7 @@ export default function Home() {
<div className={s0.chart}> <div className={s0.chart}>
<Suspense fallback={<Loading height="200px" />}> <Suspense fallback={<Loading height="200px" />}>
<TrafficChart /> <TrafficChart />
<MemoryChart />
</Suspense> </Suspense>
</div> </div>
</div> </div>

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/memory';
import { useLineChartMemory } from '../hooks/useLineChart';
import {
chartJSResource,
chartStyles,
commonDataSetProps,
memoryChartOptions,
} from '../misc/chart-memory';
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)(MemoryChart);
function MemoryChart({ apiConfig, selectedChartStyleIndex }) {
const ChartMod = chartJSResource.read();
const memory = fetchData(apiConfig);
const { t } = useTranslation();
const data = useMemo(
() => ({
labels: memory.labels,
datasets: [
{
...commonDataSetProps,
...memoryChartOptions,
...chartStyles[selectedChartStyleIndex].inuse,
label: t('Memory'),
data: memory.inuse,
},
],
}),
[memory, selectedChartStyleIndex, t]
);
useLineChartMemory(ChartMod.Chart, 'MemoryChart', data, memory);
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="MemoryChart" />
</div>
);
}

View file

@ -11,11 +11,10 @@ import { connect } from './StateProvider';
const { useMemo } = React; const { useMemo } = React;
const chartWrapperStyle = { const chartWrapperStyle: React.CSSProperties = {
// make chartjs chart responsive // make chartjs chart responsive
position: 'relative', position: 'relative',
maxWidth: 1000, maxWidth: 1000,
marginTop: '1em',
}; };
const mapState = (s: State) => ({ const mapState = (s: State) => ({
@ -53,7 +52,6 @@ function TrafficChart({ apiConfig, selectedChartStyleIndex }) {
useLineChart(ChartMod.Chart, 'trafficChart', data, traffic); useLineChart(ChartMod.Chart, 'trafficChart', data, traffic);
return ( 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}> <div style={chartWrapperStyle}>
<canvas id="trafficChart" /> <canvas id="trafficChart" />
</div> </div>

View file

@ -3,26 +3,25 @@
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
display: grid;
grid-template-columns: repeat(auto-fit, 140px);
grid-gap: 20px;
justify-content: space-around;
max-width: 1000px; max-width: 1000px;
.sec { .sec {
padding: 10px; padding: 10px;
width: 19%;
margin: 3px;
background-color: var(--color-bg-card); background-color: var(--color-bg-card);
border-radius: 10px; border-radius: 10px;
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.1); box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.1);
div:nth-child(1) { div:nth-child(1) {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.65em; font-size: 0.7em;
} }
div:nth-child(2) { div:nth-child(2) {
padding: 10px 0 0; padding: 10px 0 0;
font-size: 1em; font-size: 1.3em;
}
@media (max-width: 768px) {
width: 48%;
} }
} }
} }

View file

@ -18,7 +18,7 @@ export default connect(mapState)(TrafficNow);
function TrafficNow({ apiConfig }) { function TrafficNow({ apiConfig }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { upStr, downStr } = useSpeed(apiConfig); const { upStr, downStr } = useSpeed(apiConfig);
const { upTotal, dlTotal, connNumber } = useConnection(apiConfig); const { upTotal, dlTotal, connNumber, mUsage } = useConnection(apiConfig);
return ( return (
<div className={s0.TrafficNow}> <div className={s0.TrafficNow}>
<div className={s0.sec}> <div className={s0.sec}>
@ -41,6 +41,10 @@ function TrafficNow({ apiConfig }) {
<div>{t('Active Connections')}</div> <div>{t('Active Connections')}</div>
<div>{connNumber}</div> <div>{connNumber}</div>
</div> </div>
<div className={s0.sec}>
<div>{t('Memory Usage')}</div>
<div>{mUsage}</div>
</div>
</div> </div>
); );
} }
@ -63,13 +67,15 @@ function useConnection(apiConfig) {
upTotal: '0 B', upTotal: '0 B',
dlTotal: '0 B', dlTotal: '0 B',
connNumber: 0, connNumber: 0,
mUsage: '0 B',
}); });
const read = useCallback( const read = useCallback(
({ downloadTotal, uploadTotal, connections }) => { ({ downloadTotal, uploadTotal, connections, memory }) => {
setState({ setState({
upTotal: prettyBytes(uploadTotal), upTotal: prettyBytes(uploadTotal),
dlTotal: prettyBytes(downloadTotal), dlTotal: prettyBytes(downloadTotal),
connNumber: connections.length, connNumber: connections.length,
mUsage: prettyBytes(memory),
}); });
}, },
[setState] [setState]

View file

@ -76,8 +76,8 @@
.proxySmall { .proxySmall {
position: relative; position: relative;
width: 10px; width: 12px;
height: 10px; height: 12px;
border-radius: 50%; border-radius: 50%;
.now { .now {

View file

@ -50,7 +50,7 @@ function ProxyGroupImpl({
); );
const isSelectable = useMemo( const isSelectable = useMemo(
() => ['Selector', version.meta && 'Fallback'].includes(type), () => ['Selector', version.meta && 'Fallback', version.meta && 'URLTest'].includes(type),
[type, version.meta] [type, version.meta]
); );

View file

@ -3,8 +3,8 @@
.proxyLatency { .proxyLatency {
border-radius: 20px; border-radius: 20px;
color: #eee; color: #eee;
font-size: 0.6em; font-size: 1em;
@media (--breakpoint-not-small) { @media (--breakpoint-not-small) {
font-size: 0.7em; font-size: 1em;
} }
} }

View file

@ -89,7 +89,7 @@ function ProxyProviderImpl({
const getYear = expire.getFullYear() + '-'; const getYear = expire.getFullYear() + '-';
const getMonth = const getMonth =
(expire.getMonth() + 1 < 10 ? '0' + (expire.getMonth() + 1) : expire.getMonth() + 1) + '-'; (expire.getMonth() + 1 < 10 ? '0' + (expire.getMonth() + 1) : expire.getMonth() + 1) + '-';
const getDate = expire.getDate() + ' '; const getDate = (expire.getDate() < 10 ? '0' + expire.getDate() : expire.getDate()) + ' ';
return getYear + getMonth + getDate; return getYear + getMonth + getDate;
}; };
return ( return (

View file

@ -4,7 +4,7 @@ h2.sectionNameType {
margin: 0; margin: 0;
font-size: 1em; font-size: 1em;
@media (--breakpoint-not-small) { @media (--breakpoint-not-small) {
font-size: 1.1em; font-size: 1.3em;
} }
span:nth-child(2) { span:nth-child(2) {

View file

@ -2,6 +2,7 @@ import type { ChartConfiguration } from 'chart.js';
import React from 'react'; import React from 'react';
import { commonChartOptions } from '~/misc/chart'; import { commonChartOptions } from '~/misc/chart';
import { memoryChartOptions } from '~/misc/chart-memory';
const { useEffect } = React; const { useEffect } = React;
@ -23,3 +24,22 @@ export default function useLineChart(
}; };
}, [chart, elementId, data, subscription, extraChartOptions]); }, [chart, elementId, data, subscription, extraChartOptions]);
} }
export function useLineChartMemory(
chart: typeof import('chart.js').Chart,
elementId: string,
data: ChartConfiguration['data'],
subscription: any,
extraChartOptions = {}
) {
useEffect(() => {
const ctx = (document.getElementById(elementId) as HTMLCanvasElement).getContext('2d');
const options = { ...memoryChartOptions, ...extraChartOptions };
const c = new chart(ctx, { type: 'line', data, options });
const unsubscribe = subscription && subscription.subscribe(() => c.update());
return () => {
unsubscribe && unsubscribe();
c.destroy();
};
}, [chart, elementId, data, subscription, extraChartOptions]);
}

View file

@ -1,4 +1,5 @@
export const data = { export const data = {
All: 'All',
Overview: 'Overview', Overview: 'Overview',
Proxies: 'Proxies', Proxies: 'Proxies',
Rules: 'Rules', Rules: 'Rules',
@ -10,6 +11,7 @@ export const data = {
'Upload Total': 'Upload Total', 'Upload Total': 'Upload Total',
'Download Total': 'Download Total', 'Download Total': 'Download Total',
'Active Connections': 'Active Connections', 'Active Connections': 'Active Connections',
'Memory Usage': 'Memory Usage',
'Pause Refresh': 'Pause Refresh', 'Pause Refresh': 'Pause Refresh',
'Resume Refresh': 'Resume Refresh', 'Resume Refresh': 'Resume Refresh',
close_all_connections: 'Close All Connections', close_all_connections: 'Close All Connections',
@ -39,6 +41,8 @@ export const data = {
update_all_rule_provider: 'Update all rule providers', update_all_rule_provider: 'Update all rule providers',
update_all_proxy_provider: 'Update all proxy providers', update_all_proxy_provider: 'Update all proxy providers',
reload_config_file: 'Reload config file', reload_config_file: 'Reload config file',
restart_core: 'Restart clash core',
upgrade_core: 'Upgrade Alpha core',
update_geo_databases_file: 'Update GEO Databases ', update_geo_databases_file: 'Update GEO Databases ',
flush_fake_ip_pool: 'Flush fake-ip data', flush_fake_ip_pool: 'Flush fake-ip data',
enable_tun_device: 'Enable TUN Device', enable_tun_device: 'Enable TUN Device',

View file

@ -1,4 +1,5 @@
export const data = { export const data = {
All: '所有',
Overview: '概览', Overview: '概览',
Proxies: '代理', Proxies: '代理',
Rules: '规则', Rules: '规则',
@ -10,6 +11,8 @@ export const data = {
'Upload Total': '上传总量', 'Upload Total': '上传总量',
'Download Total': '下载总量', 'Download Total': '下载总量',
'Active Connections': '活动连接', 'Active Connections': '活动连接',
'Memory Usage': '内存使用情况',
Memory: '内存',
'Pause Refresh': '暂停刷新', 'Pause Refresh': '暂停刷新',
'Resume Refresh': '继续刷新', 'Resume Refresh': '继续刷新',
close_all_connections: '关闭所有连接', close_all_connections: '关闭所有连接',
@ -57,4 +60,6 @@ export const data = {
c_source: '来源', c_source: '来源',
c_destination_ip: '目标IP', c_destination_ip: '目标IP',
c_type: '类型', c_type: '类型',
restart_core: '重启 clash 核心',
upgrade_core: '更新 Alpha 核心',
}; };

64
src/misc/chart-memory.ts Normal file
View file

@ -0,0 +1,64 @@
import { createAsset } from 'use-asset';
import prettyBytes from './pretty-bytes';
export const chartJSResource = createAsset(() => {
return import('~/misc/chart-lib');
});
export const commonDataSetProps = { borderWidth: 1, pointRadius: 0, tension: 0.2, fill: true };
export const memoryChartOptions: import('chart.js').ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { labels: { boxWidth: 20 } },
},
scales: {
x: { display: false, type: 'category' },
y: {
type: 'linear',
display: true,
grid: {
display: true,
color: '#555',
drawTicks: false,
},
border: {
dash: [3, 6],
},
ticks: {
maxTicksLimit: 3,
callback(value: number) {
return prettyBytes(value);
},
},
},
},
};
export const chartStyles = [
{
inuse: {
backgroundColor: 'rgba(81, 168, 221, 0.5)',
borderColor: 'rgb(81, 168, 221)',
},
},
{
inuse: {
backgroundColor: 'rgba(245,78,162,0.6)',
borderColor: 'rgba(245,78,162,1)',
},
},
{
inuse: {
backgroundColor: 'rgba(94, 175, 223, 0.3)',
borderColor: 'rgb(94, 175, 223)',
},
},
{
inuse: {
backgroundColor: 'rgba(242, 174, 62, 0.3)',
borderColor: 'rgb(242, 174, 62)',
},
},
];

View file

@ -27,6 +27,7 @@ export const commonChartOptions: import('chart.js').ChartOptions<'line'> = {
dash: [3, 6], dash: [3, 6],
}, },
ticks: { ticks: {
maxTicksLimit: 5,
callback(value: number) { callback(value: number) {
return prettyBytes(value) + '/s '; return prettyBytes(value) + '/s ';
}, },
@ -38,22 +39,22 @@ export const commonChartOptions: import('chart.js').ChartOptions<'line'> = {
export const chartStyles = [ export const chartStyles = [
{ {
down: { down: {
backgroundColor: 'rgba(176, 209, 132, 0.8)', backgroundColor: 'rgba(81, 168, 221, 0.5)',
borderColor: 'rgb(176, 209, 132)', borderColor: 'rgb(81, 168, 221)',
}, },
up: { up: {
backgroundColor: 'rgba(181, 220, 231, 0.8)', backgroundColor: 'rgba(219, 77, 109, 0.5)',
borderColor: 'rgb(181, 220, 231)', borderColor: 'rgb(219, 77, 109)',
}, },
}, },
{ {
up: { up: {
backgroundColor: 'rgb(98, 190, 100)', backgroundColor: 'rgba(245,78,162,0.6)',
borderColor: 'rgb(78,146,79)', borderColor: 'rgba(245,78,162,1)',
}, },
down: { down: {
backgroundColor: 'rgb(160, 230, 66)', backgroundColor: 'rgba(123,59,140,0.6)',
borderColor: 'rgb(110, 156, 44)', borderColor: 'rgba(66,33,142,1)',
}, },
}, },
{ {

View file

@ -47,7 +47,7 @@ i18next
} }
}, },
}, },
supportedLngs: ['en', 'zh'], supportedLngs: ['zh', 'en'],
fallbackLng: 'en', fallbackLng: 'en',
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,

View file

@ -95,7 +95,7 @@ export function updateClashAPIConfig({ baseURL, secret }) {
const rootEl = document.querySelector('html'); const rootEl = document.querySelector('html');
type ThemeType = 'dark' | 'light' | 'auto'; type ThemeType = 'dark' | 'light' | 'auto';
function setTheme(theme: ThemeType = 'dark') { function setTheme(theme: ThemeType = 'light') {
if (theme === 'auto') { if (theme === 'auto') {
rootEl.setAttribute('data-theme', 'auto'); rootEl.setAttribute('data-theme', 'auto');
} else if (theme === 'dark') { } else if (theme === 'dark') {
@ -159,7 +159,7 @@ const defaultState: StateApp = {
selectedClashAPIConfigIndex: 0, selectedClashAPIConfigIndex: 0,
clashAPIConfigs: [defaultClashAPIConfig], clashAPIConfigs: [defaultClashAPIConfig],
latencyTestUrl: 'http://www.gstatic.com/generate_204', latencyTestUrl: 'https://www.gstatic.com/generate_204',
selectedChartStyleIndex: 0, selectedChartStyleIndex: 0,
theme: 'dark', theme: 'dark',

View file

@ -115,6 +115,51 @@ export function reloadConfigFile(apiConfig: ClashAPIConfig) {
}; };
} }
export function restartCore(apiConfig: ClashAPIConfig) {
return async (dispatch: DispatchFn) => {
configsAPI
.restartCore(apiConfig)
.then(
(res) => {
if (res.ok === false) {
// eslint-disable-next-line no-console
console.log('Error restart core', res.statusText);
}
},
(err) => {
// eslint-disable-next-line no-console
console.log('Error restart core', err);
throw err;
}
)
.then(() => {
dispatch(fetchConfigs(apiConfig));
});
};
}
export function upgradeCore(apiConfig: ClashAPIConfig) {
return async (dispatch: DispatchFn) => {
configsAPI
.upgradeCore(apiConfig)
.then(
(res) => {
if (res.ok === false) {
// eslint-disable-next-line no-console
console.log('Error upgrade core', res.statusText);
}
},
(err) => {
// eslint-disable-next-line no-console
console.log('Error upgrade core', err);
throw err;
}
)
.then(() => {
dispatch(fetchConfigs(apiConfig));
});
};
}
export function updateGeoDatabasesFile(apiConfig: ClashAPIConfig) { export function updateGeoDatabasesFile(apiConfig: ClashAPIConfig) {
return async (dispatch: DispatchFn) => { return async (dispatch: DispatchFn) => {
configsAPI configsAPI

View file

@ -3,6 +3,11 @@
@import '@fontsource/roboto-mono/latin-400.css'; @import '@fontsource/roboto-mono/latin-400.css';
@import 'modern-normalize/modern-normalize.css'; @import 'modern-normalize/modern-normalize.css';
@font-face {
font-family: '_Twemoji Mozilla';
src: url('../../assets/Twemoji_Mozilla.ttf') format('truetype');
}
.relative { .relative {
position: relative; position: relative;
} }
@ -59,7 +64,7 @@
:root { :root {
--font-mono: 'Roboto Mono', Menlo, monospace; --font-mono: 'Roboto Mono', Menlo, monospace;
// prettier-ignore // prettier-ignore
--font-normal: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, "Twemoji Mozilla", Segoe UI Emoji, Segoe UI Symbol, 'PingFang SC', 'Microsoft YaHei', '\5fae\8f6f\96c5\9ed1', Arial; --font-normal: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Source Han Sans", "PingFang SC", "Microsoft YaHei" , "微软雅黑", Arial,"Twemoji Mozilla", "_Twemoji Mozilla","Segoe UI Emoji", "Segoe UI Symbol";
--color-focus-blue: #1a73e8; --color-focus-blue: #1a73e8;
--btn-bg: #387cec; --btn-bg: #387cec;
} }