update github

This commit is contained in:
liyp 2023-04-30 10:26:08 +08:00
parent b658f11737
commit c9770c14d2
38 changed files with 5975 additions and 2807 deletions

View file

@ -28,4 +28,3 @@ steps:
when:
event: tag
tag: v*

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">
<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>
> 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.
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).
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).
**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,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="yacd.ico" />

View file

@ -1,8 +1,8 @@
{
"name": "yacd",
"version": "0.3.5",
"version": "0.3.6",
"description": "Yet another Clash dashboard",
"author": "Haishan <haishanhan@gmail.com> (https://haishan.me)",
"author": "MetaCubeX (https://github.com/MetaCubeX)",
"license": "MIT",
"private": true,
"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 updateGeoDatabasesFileEndpoint = '/configs/geo';
const flushFakeIPPoolEndpoint = '/cache/fakeip/flush';
const restartCoreEndpoint = '/restart';
const upgradeCoreEndpoint = '/upgrade';
export async function fetchConfigs(apiConfig: ClashAPIConfig) {
const { url, init } = getURLAndInit(apiConfig);
@ -42,6 +44,18 @@ export async function updateGeoDatabasesFile(apiConfig: ClashAPIConfig) {
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) {
const { url, init } = getURLAndInit(apiConfig);
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 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 = [];
// 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
console.log('JSON.parse error', JSON.parse(s));
}
subscribers.forEach((f) => f(o));
subscribers.forEach((s) => s.listner(o));
}
type UnsubscribeFn = () => void;
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 (listener) return subscribe(listener);
if (listener)
return subscribe({
listner: listener,
onClose,
});
}
wsState = 1;
const url = buildWebSocketURL(apiConfig, endpoint);
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));
if (listener) return subscribe(listener);
if (listener)
return subscribe({
listner: listener,
onClose,
});
}
function subscribe(listener: unknown): UnsubscribeFn {
subscribers.push(listener);
function subscribe(subscriber: Subscriber): UnsubscribeFn {
subscribers.push(subscriber);
return function unsubscribe() {
const idx = subscribers.indexOf(listener);
const idx = subscribers.indexOf(subscriber);
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(
apiConfig,
name,
latencyTestUrl = 'http://www.gstatic.com/generate_204'
latencyTestUrl = 'https://www.gstatic.com/generate_204'
) {
const { url, init } = getURLAndInit(apiConfig);
const qs = `timeout=5000&url=${encodeURIComponent(latencyTestUrl)}`;

View file

@ -15,8 +15,10 @@ import {
flushFakeIPPool,
getConfigs,
reloadConfigFile,
restartCore,
updateConfigs,
updateGeoDatabasesFile,
upgradeCore,
} from '../store/configs';
import { openModal } from '../store/modals';
import Button from './Button';
@ -206,6 +208,14 @@ function ConfigImpl({
dispatch(reloadConfigFile(apiConfig));
}, [apiConfig, dispatch]);
const handleRestartCore = useCallback(() => {
dispatch(restartCore(apiConfig));
}, [apiConfig, dispatch]);
const handleUpgradeCore = useCallback(() => {
dispatch(upgradeCore(apiConfig));
}, [apiConfig, dispatch]);
const handleUpdateGeoDatabasesFile = useCallback(() => {
dispatch(updateGeoDatabasesFile(apiConfig));
}, [apiConfig, dispatch]);
@ -351,6 +361,22 @@ function ConfigImpl({
onClick={handleFlushFakeIPPool}
/>
</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 className={s0.sep}>
<div />

View file

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

View file

@ -13,19 +13,19 @@ const sortDescFirst = true;
const columns = [
{ accessor: 'id', show: false },
{ Header: 'c_host', accessor: 'host' },
{ Header: 'c_sni', accessor: 'sniffHost' },
{ Header: 'c_type', accessor: 'type' },
{ Header: 'c_process', accessor: 'process' },
{ Header: 'c_dl', accessor: 'download', sortDescFirst },
{ Header: 'c_ul', accessor: 'upload', sortDescFirst },
{ Header: 'c_host', accessor: 'host' },
{ Header: 'c_rule', accessor: 'rule' },
{ Header: 'c_chains', accessor: 'chains' },
{ Header: 'c_time', accessor: 'start' },
{ 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_dl', accessor: 'download', sortDescFirst },
{ Header: 'c_ul', accessor: 'upload', sortDescFirst },
{ Header: 'c_source', accessor: 'source' },
{ 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) {
@ -71,8 +71,11 @@ function Table({ data }) {
{headerGroups.map((headerGroup) => {
return (
<div {...headerGroup.getHeaderGroupProps()} className={s.tr}>
{headerGroup.headers.map((column) => (
<div {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}>
{headerGroup.headers.map((column, index) => (
<div
{...column.getHeaderProps(column.getSortByToggleProps())}
className={index == 0 || (index >= 5 && index < 10) ? s.thdu : s.th}
>
<span>{t(column.render('Header'))}</span>
<span className={s.sortIconContainer}>
{column.isSorted ? (
@ -93,7 +96,8 @@ function Table({ data }) {
className={cx(
s.td,
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)}

View file

@ -47,3 +47,7 @@
.react-tabs__tab-panel--selected {
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 useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
import { getClashAPIConfig } from '../store/app';
import Button from './Button';
import s from './Connections.module.scss';
import ConnectionTable from './ConnectionTable';
import ContentHeader from './ContentHeader';
@ -58,22 +59,38 @@ 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 filterConnIps(conns: FormattedConn[], ipStr: string) {
return conns.filter((each) => each.sourceIP === ipStr);
}
function filterConns(conns: FormattedConn[], keyword: string, sourceIp: string) {
let result = conns;
if (keyword !== '') {
result = conns.filter((conn) =>
[
conn.host,
conn.sourceIP,
conn.sourcePort,
conn.destinationIP,
conn.chains,
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(
@ -103,7 +120,7 @@ function formatConnectionDataItem(
upload,
download,
start: now - new Date(start).valueOf(),
chains: chains.reverse().join(' / '),
chains: modifyChains(chains),
rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
...metadata,
host: `${host2}:${destinationPort}`,
@ -117,6 +134,24 @@ function formatConnectionDataItem(
};
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[]) {
return conns.length > 0 ? (
@ -134,11 +169,19 @@ function ConnQty({ qty }) {
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 [filterSourceIpStr, setFilterSourceIpStr] = useState('');
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 openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []);
const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []);
@ -178,9 +221,15 @@ function Conn({ apiConfig }) {
},
[setConns, isRefreshPaused]
);
const [reConnectCount, setReConnectCount] = useState(0);
useEffect(() => {
return connAPI.fetchData(apiConfig, read);
}, [apiConfig, read]);
return connAPI.fetchData(apiConfig, read, () => {
setTimeout(() => {
setReConnectCount((prev) => prev + 1);
}, 1000);
});
}, [apiConfig, read, reConnectCount, setReConnectCount]);
const { t } = useTranslation();
@ -230,7 +279,17 @@ function Conn({ apiConfig }) {
}}
>
<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
icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />}
mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}}
@ -243,7 +302,19 @@ function Conn({ apiConfig }) {
</Action>
</Fab>
</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>
<ModalCloseAllConnections

View file

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

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import ContentHeader from './ContentHeader';
import s0 from './Home.module.scss';
import Loading from './Loading';
import MemoryChart from './MemoryChart';
import TrafficChart from './TrafficChart';
import TrafficNow from './TrafficNow';
@ -19,6 +20,7 @@ export default function Home() {
<div className={s0.chart}>
<Suspense fallback={<Loading height="200px" />}>
<TrafficChart />
<MemoryChart />
</Suspense>
</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 chartWrapperStyle = {
const chartWrapperStyle: React.CSSProperties = {
// make chartjs chart responsive
position: 'relative',
maxWidth: 1000,
marginTop: '1em',
};
const mapState = (s: State) => ({
@ -53,7 +52,6 @@ function TrafficChart({ apiConfig, selectedChartStyleIndex }) {
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

@ -3,26 +3,25 @@
display: flex;
align-items: center;
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;
.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;
font-size: 0.7em;
}
div:nth-child(2) {
padding: 10px 0 0;
font-size: 1em;
}
@media (max-width: 768px) {
width: 48%;
font-size: 1.3em;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -89,7 +89,7 @@ function ProxyProviderImpl({
const getYear = expire.getFullYear() + '-';
const getMonth =
(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 (

View file

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

View file

@ -2,6 +2,7 @@ import type { ChartConfiguration } from 'chart.js';
import React from 'react';
import { commonChartOptions } from '~/misc/chart';
import { memoryChartOptions } from '~/misc/chart-memory';
const { useEffect } = React;
@ -23,3 +24,22 @@ export default function useLineChart(
};
}, [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 = {
All: 'All',
Overview: 'Overview',
Proxies: 'Proxies',
Rules: 'Rules',
@ -10,6 +11,7 @@ export const data = {
'Upload Total': 'Upload Total',
'Download Total': 'Download Total',
'Active Connections': 'Active Connections',
'Memory Usage': 'Memory Usage',
'Pause Refresh': 'Pause Refresh',
'Resume Refresh': 'Resume Refresh',
close_all_connections: 'Close All Connections',
@ -39,6 +41,8 @@ export const data = {
update_all_rule_provider: 'Update all rule providers',
update_all_proxy_provider: 'Update all proxy providers',
reload_config_file: 'Reload config file',
restart_core: 'Restart clash core',
upgrade_core: 'Upgrade Alpha core',
update_geo_databases_file: 'Update GEO Databases ',
flush_fake_ip_pool: 'Flush fake-ip data',
enable_tun_device: 'Enable TUN Device',

View file

@ -1,4 +1,5 @@
export const data = {
All: '所有',
Overview: '概览',
Proxies: '代理',
Rules: '规则',
@ -10,6 +11,8 @@ export const data = {
'Upload Total': '上传总量',
'Download Total': '下载总量',
'Active Connections': '活动连接',
'Memory Usage': '内存使用情况',
Memory: '内存',
'Pause Refresh': '暂停刷新',
'Resume Refresh': '继续刷新',
close_all_connections: '关闭所有连接',
@ -57,4 +60,6 @@ export const data = {
c_source: '来源',
c_destination_ip: '目标IP',
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],
},
ticks: {
maxTicksLimit: 5,
callback(value: number) {
return prettyBytes(value) + '/s ';
},
@ -38,22 +39,22 @@ export const commonChartOptions: import('chart.js').ChartOptions<'line'> = {
export const chartStyles = [
{
down: {
backgroundColor: 'rgba(176, 209, 132, 0.8)',
borderColor: 'rgb(176, 209, 132)',
backgroundColor: 'rgba(81, 168, 221, 0.5)',
borderColor: 'rgb(81, 168, 221)',
},
up: {
backgroundColor: 'rgba(181, 220, 231, 0.8)',
borderColor: 'rgb(181, 220, 231)',
backgroundColor: 'rgba(219, 77, 109, 0.5)',
borderColor: 'rgb(219, 77, 109)',
},
},
{
up: {
backgroundColor: 'rgb(98, 190, 100)',
borderColor: 'rgb(78,146,79)',
backgroundColor: 'rgba(245,78,162,0.6)',
borderColor: 'rgba(245,78,162,1)',
},
down: {
backgroundColor: 'rgb(160, 230, 66)',
borderColor: 'rgb(110, 156, 44)',
backgroundColor: 'rgba(123,59,140,0.6)',
borderColor: 'rgba(66,33,142,1)',
},
},
{

View file

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

View file

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

View file

@ -3,6 +3,11 @@
@import '@fontsource/roboto-mono/latin-400.css';
@import 'modern-normalize/modern-normalize.css';
@font-face {
font-family: '_Twemoji Mozilla';
src: url('../../assets/Twemoji_Mozilla.ttf') format('truetype');
}
.relative {
position: relative;
}
@ -59,7 +64,7 @@
:root {
--font-mono: 'Roboto Mono', Menlo, monospace;
// 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;
--btn-bg: #387cec;
}