first commit
27
.babelrc
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"modules": false
|
||||
}
|
||||
],
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-syntax-import-meta",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-json-strings",
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-proposal-export-default-from",
|
||||
"@babel/plugin-proposal-do-expressions"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
"plugins": [
|
||||
"react-hot-loader/babel"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
31
.eslintrc.yml
Normal file
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
env:
|
||||
browser: true
|
||||
node: true
|
||||
jest/globals: true
|
||||
es6: true
|
||||
|
||||
parser: babel-eslint
|
||||
|
||||
plugins:
|
||||
- import
|
||||
- react
|
||||
- jest
|
||||
|
||||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
- plugin:import/errors
|
||||
- plugin:react/recommended
|
||||
|
||||
|
||||
# globals:
|
||||
# Promise: true
|
||||
|
||||
rules:
|
||||
quotes: ["error", "single"]
|
||||
strict: ["error", "never"]
|
||||
no-console: "warn"
|
||||
react/jsx-uses-react: "error"
|
||||
react/jsx-uses-vars: "error"
|
||||
react/react-in-jsx-scope: "error"
|
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
*.*~
|
||||
.DS_Store
|
||||
node_modules
|
||||
public/
|
||||
deploy_ghpages.sh
|
||||
tags
|
||||
*.log
|
BIN
assets/yacd-128.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/yacd-64.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
93
package.json
Normal file
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"name": "yacd",
|
||||
"version": "0.0.1",
|
||||
"description": "Yet another Clash dashboard",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"dll": "webpack --config webpack.dll.config.js",
|
||||
"start": "NODE_ENV=development node server.js",
|
||||
"clean": "rm -rf public && mkdir public",
|
||||
"build:webpack": "NODE_ENV=production webpack --config webpack.config.js --progress --colors",
|
||||
"build:assets": "cp -r assets/* public/",
|
||||
"build": "npm-run-all clean build:webpack build:assets",
|
||||
"pretty": "prettier --single-quote --write 'src/**/*.{js,scss}'"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,scss}": [
|
||||
"prettier --single-quote --write",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"keywords": [
|
||||
"react"
|
||||
],
|
||||
"author": "Han Haishan <haishanhan@gmail.com> (htttp://haishan.me)",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/polyfill": "^7.0.0",
|
||||
"chart.js": "^2.7.3",
|
||||
"chat.js": "^1.0.2",
|
||||
"classnames": "^2.2.6",
|
||||
"history": "^4.7.2",
|
||||
"modern-normalize": "^0.5.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^16.5.2",
|
||||
"react-dom": "^16.5.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-modal": "^3.6.1",
|
||||
"react-redux": "^5.0.6",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"react-router-redux": "^5.0.0-alpha.9",
|
||||
"redux": "^4.0.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.1.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.0.0",
|
||||
"@babel/plugin-proposal-do-expressions": "^7.0.0",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.0.0",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
|
||||
"@babel/plugin-proposal-json-strings": "^7.0.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||
"@babel/plugin-syntax-import-meta": "^7.0.0",
|
||||
"@babel/preset-env": "^7.1.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"autoprefixer": "^9.2.1",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-loader": "^8.0.4",
|
||||
"css-loader": "^1.0.0",
|
||||
"cssnano": "^4.1.5",
|
||||
"eslint": "^5.7.0",
|
||||
"eslint-plugin-import": "2.14.0",
|
||||
"eslint-plugin-jest": "^21.25.1",
|
||||
"eslint-plugin-react": "7.11.1",
|
||||
"file-loader": "^2.0.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"husky": "^1.0.1",
|
||||
"lint-staged": "^7.3.0",
|
||||
"mini-css-extract-plugin": "^0.4.3",
|
||||
"node-sass": "^4.9.4",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"prettier": "^1.14.3",
|
||||
"react-hot-loader": "^4.2.0",
|
||||
"sass-loader": "^7.0.1",
|
||||
"style-loader": "^0.23.0",
|
||||
"svg-sprite-loader": "^4.1.2",
|
||||
"uglifyjs-webpack-plugin": "^2.0.1",
|
||||
"webpack": "^4.21.0",
|
||||
"webpack-bundle-analyzer": "^3.0.3",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-middleware": "3.4.0",
|
||||
"webpack-hot-middleware": "^2.22.2"
|
||||
}
|
||||
}
|
78
server.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const config = require('./webpack.config');
|
||||
const webpack = require('webpack');
|
||||
// const WebpackDevServer = require('webpack-dev-server');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
const devMiddleware = require('webpack-dev-middleware');
|
||||
const hotMiddleware = require('webpack-hot-middleware');
|
||||
|
||||
const { PORT } = process.env;
|
||||
const port = PORT ? Number(PORT) : 3000;
|
||||
|
||||
config.entry.app.unshift(
|
||||
// activate HMR for React
|
||||
// 'react-hot-loader/patch',
|
||||
|
||||
'webpack-hot-middleware/client'
|
||||
// // bundle the client for webpack-dev-server
|
||||
// // and connect to the provided endpoint
|
||||
// 'webpack-dev-server/client?http://0.0.0.0:' + port,
|
||||
// // bundle the client for hot reloading
|
||||
// // only- means to only hot reload for successful updates
|
||||
// 'webpack/hot/only-dev-server'
|
||||
);
|
||||
config.plugins.push(
|
||||
// enable HMR globally
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
// prints more readable module names in the browser console on HMR updates
|
||||
new webpack.NamedModulesPlugin()
|
||||
);
|
||||
|
||||
const compiler = webpack(config);
|
||||
// webpack-dev-server config
|
||||
const publicPath = config.output.publicPath;
|
||||
const stats = {
|
||||
colors: true,
|
||||
cached: false,
|
||||
cachedAssets: false,
|
||||
chunks: false,
|
||||
chunkModules: false
|
||||
};
|
||||
|
||||
// webpack-dev-server options
|
||||
// const options = {
|
||||
// hotOnly: true,
|
||||
// host: '0.0.0.0',
|
||||
// contentBase: path.join(__dirname, 'public'),
|
||||
// publicPath,
|
||||
// stats,
|
||||
// overlay: {
|
||||
// warnings: true,
|
||||
// errors: true
|
||||
// },
|
||||
// historyApiFallback: true
|
||||
// };
|
||||
|
||||
const options = { publicPath, stats };
|
||||
|
||||
app.use(devMiddleware(compiler, options));
|
||||
app.use(hotMiddleware(compiler));
|
||||
|
||||
app.use('*', (req, res, next) => {
|
||||
const filename = path.join(compiler.outputPath, 'index.html');
|
||||
compiler.outputFileSystem.readFile(filename, (err, result) => {
|
||||
if (err) return next(err);
|
||||
|
||||
res.set('content-type', 'text/html');
|
||||
res.send(result);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log('\n>> Listening at http://0.0.0.0:' + port + '\n');
|
||||
});
|
22
src/api/configs.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
const { getAPIURL } = require('../config');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
export async function fetchConfigs() {
|
||||
const apiURL = getAPIURL();
|
||||
return await fetch(apiURL.configs);
|
||||
}
|
||||
|
||||
export async function updateConfigs(o) {
|
||||
const apiURL = getAPIURL();
|
||||
return await fetch(apiURL.configs, {
|
||||
method: 'PUT',
|
||||
// mode: 'cors',
|
||||
headers,
|
||||
body: JSON.stringify(o)
|
||||
});
|
||||
}
|
64
src/api/logs.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
'use strict';
|
||||
|
||||
const { getAPIURL } = require('../config');
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
|
||||
const Size = 300;
|
||||
|
||||
const store = {
|
||||
logs: [],
|
||||
size: Size,
|
||||
updateCallback: null,
|
||||
appendData(o) {
|
||||
const now = new Date();
|
||||
const time = now.toLocaleString('zh-Hans');
|
||||
// mutate input param in place intentionally
|
||||
o.time = time;
|
||||
o.id = now - 0;
|
||||
this.logs.unshift(o);
|
||||
if (this.logs.length > this.size) this.logs.pop();
|
||||
// TODO consider throttle this
|
||||
if (this.updateCallback) this.updateCallback();
|
||||
}
|
||||
};
|
||||
|
||||
function pump(reader) {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
console.log('done');
|
||||
return;
|
||||
}
|
||||
const t = textDecoder.decode(value);
|
||||
// console.log(t);
|
||||
const l = t[t.length - 1];
|
||||
let o;
|
||||
try {
|
||||
o = JSON.parse(t);
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'lastchar',
|
||||
t.length,
|
||||
' is r',
|
||||
l === '\r',
|
||||
' is n',
|
||||
l === '\n'
|
||||
);
|
||||
}
|
||||
store.appendData(o);
|
||||
return pump(reader);
|
||||
});
|
||||
}
|
||||
|
||||
let fetched = false;
|
||||
function fetchLogs() {
|
||||
if (fetched) return store;
|
||||
const apiURL = getAPIURL();
|
||||
fetch(apiURL.logs).then(response => {
|
||||
fetched = true;
|
||||
const reader = response.body.getReader();
|
||||
pump(reader);
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
export { fetchLogs };
|
50
src/api/proxies.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
'use strict';
|
||||
|
||||
const { getAPIURL } = require('../config');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
/*
|
||||
$ curl "http://127.0.0.1:8080/proxies/Proxy" -XPUT -d '{ "name": "ss3" }' -i
|
||||
HTTP/1.1 400 Bad Request
|
||||
Vary: Origin
|
||||
Date: Tue, 16 Oct 2018 16:38:20 GMT
|
||||
Content-Length: 56
|
||||
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
|
||||
Vary: Origin
|
||||
Date: Tue, 16 Oct 2018 16:38:33 GMT
|
||||
*/
|
||||
|
||||
async function fetchProxies() {
|
||||
const apiURL = getAPIURL();
|
||||
const res = await fetch(apiURL.proxies);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function requestToSwitchProxy(name1, name2) {
|
||||
const body = { name: name2 };
|
||||
const apiURL = getAPIURL();
|
||||
const url = `${apiURL.proxies}/${name1}`;
|
||||
return await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async function requestDelayForProxy(name) {
|
||||
const apiURL = getAPIURL();
|
||||
const qs = `timeout=5000&url=http://www.google.com/generate_204`;
|
||||
const url = `${apiURL.proxies}/${name}/delay?${qs}`;
|
||||
return await fetch(url);
|
||||
}
|
||||
|
||||
export { fetchProxies, requestToSwitchProxy, requestDelayForProxy };
|
67
src/api/traffic.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
'use strict';
|
||||
|
||||
const { getAPIURL } = require('../config');
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
|
||||
const Size = 150;
|
||||
|
||||
const traffic = {
|
||||
labels: Array(Size),
|
||||
// labels: [],
|
||||
up: Array(Size),
|
||||
down: Array(Size),
|
||||
|
||||
size: Size,
|
||||
subscribers: [],
|
||||
appendData(o) {
|
||||
this.up.push(o.up);
|
||||
this.down.push(o.down);
|
||||
const t = new Date();
|
||||
const l = '' + t.getMinutes() + t.getSeconds();
|
||||
this.labels.push(l);
|
||||
if (this.up.length > this.size) this.up.shift();
|
||||
if (this.down.length > this.size) this.down.shift();
|
||||
if (this.labels.length > this.size) this.labels.shift();
|
||||
|
||||
this.subscribers.forEach(f => f(o));
|
||||
},
|
||||
|
||||
subscribe(listener) {
|
||||
const me = this;
|
||||
this.subscribers.push(listener);
|
||||
return function unsubscribe() {
|
||||
const idx = me.subscribers.indexOf(listener);
|
||||
me.subscribers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function pump(reader) {
|
||||
return reader.read().then(({ done, value }) => {
|
||||
if (done) {
|
||||
console.log('done');
|
||||
return;
|
||||
}
|
||||
const t = textDecoder.decode(value);
|
||||
// console.log('response', t);
|
||||
const o = JSON.parse(t);
|
||||
|
||||
traffic.appendData(o);
|
||||
|
||||
return pump(reader);
|
||||
});
|
||||
}
|
||||
|
||||
let fetched = false;
|
||||
function fetchData() {
|
||||
if (fetched) return traffic;
|
||||
const apiURL = getAPIURL();
|
||||
fetch(apiURL.traffic).then(response => {
|
||||
fetched = true;
|
||||
const reader = response.body.getReader();
|
||||
pump(reader);
|
||||
});
|
||||
return traffic;
|
||||
}
|
||||
|
||||
export { fetchData };
|
21
src/app.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'modern-normalize/modern-normalize.css';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Modal from 'react-modal';
|
||||
import createHistory from 'history/createHashHistory';
|
||||
// import createHistory from 'history/createBrowserHistory';
|
||||
import configureStore from './store/configureStore';
|
||||
|
||||
import Root from './components/Root';
|
||||
|
||||
const history = createHistory();
|
||||
const store = configureStore(history);
|
||||
const props = { history, store };
|
||||
|
||||
Modal.setAppElement('#app');
|
||||
const render = (Component, props = {}) => {
|
||||
ReactDOM.render(<Component {...props} />, document.getElementById('app'));
|
||||
};
|
||||
|
||||
render(Root, props);
|
83
src/components/APIConfig.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Input from 'c/Input';
|
||||
import Button from 'c/Button';
|
||||
|
||||
import s0 from './APIConfig.module.scss';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { getClashAPIConfig, updateClashAPIConfig } from 'd/app';
|
||||
|
||||
const mapStateToProps = s => {
|
||||
const apiConfig = getClashAPIConfig(s);
|
||||
return { apiConfig };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
updateClashAPIConfig: bindActionCreators(updateClashAPIConfig, dispatch)
|
||||
};
|
||||
};
|
||||
|
||||
class APIConfig extends Component {
|
||||
static propTypes = {
|
||||
apiConfig: PropTypes.object.isRequired,
|
||||
updateClashAPIConfig: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
state = {
|
||||
hostname: this.props.apiConfig.hostname,
|
||||
port: this.props.apiConfig.port
|
||||
};
|
||||
|
||||
handleInputOnChange = e => {
|
||||
const target = e.target;
|
||||
const { name } = target;
|
||||
|
||||
let value;
|
||||
if (name === 'port') {
|
||||
if (Number(target.value) < 0 || Number(target.value) > 65535) return;
|
||||
}
|
||||
value = target.value.trim();
|
||||
if (value === '') return;
|
||||
this.setState({ [name]: value });
|
||||
};
|
||||
|
||||
handleConfirmOnClick = e => {
|
||||
const { hostname, port } = this.state;
|
||||
this.props.updateClashAPIConfig(hostname, port);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hostname, port } = this.state;
|
||||
return (
|
||||
<div className={s0.root}>
|
||||
<div className={s0.header}>RESTful API config for Clash</div>
|
||||
<div className={s0.body}>
|
||||
<Input
|
||||
type="text"
|
||||
name="hostname"
|
||||
value={hostname}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
name="port"
|
||||
value={port}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={s0.footer}>
|
||||
<Button label="Confirm" onClick={this.handleConfirmOnClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(APIConfig);
|
25
src/components/APIConfig.module.scss
Normal file
|
@ -0,0 +1,25 @@
|
|||
.root {
|
||||
//
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
div:nth-child(2) {
|
||||
width: 120px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 0 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
52
src/components/APIDiscovery.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Modal from 'c/Modal';
|
||||
import APIConfig from 'c/APIConfig';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { closeModal } from 'd/modals';
|
||||
import { fetchConfigs } from 'd/configs';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const modals = state.modals;
|
||||
return { modals };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
closeModal: bindActionCreators(closeModal, dispatch),
|
||||
fetchConfigs: bindActionCreators(fetchConfigs, dispatch)
|
||||
};
|
||||
};
|
||||
|
||||
class APIDiscovery extends Component {
|
||||
// static propTypes = {
|
||||
// isOpen: PropTypes.bool.isRequired,
|
||||
// onRequestClose: PropTypes.func.isRequired
|
||||
// };
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchConfigs();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { modals, closeModal } = this.props;
|
||||
return (
|
||||
<Modal
|
||||
isOpen={modals.apiConfig}
|
||||
shouldCloseOnOverlayClick={false}
|
||||
shouldCloseOnEsc={false}
|
||||
onRequestClose={() => closeModal('apiConfig')}
|
||||
>
|
||||
<APIConfig />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(APIDiscovery);
|
25
src/components/Button.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from 'c/Button.module.scss';
|
||||
|
||||
class Button extends Component {
|
||||
static propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onClick: () => {}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button className={s0.btn} onClick={this.props.onClick}>
|
||||
{this.props.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Button;
|
12
src/components/Button.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.btn {
|
||||
-webkit-appearance: none;
|
||||
outline: none;
|
||||
color: #ddd;
|
||||
background: #606060;
|
||||
border: 1px solid #555;
|
||||
border-radius: 100px;
|
||||
padding: 6px 12px;
|
||||
&:hover {
|
||||
background: darken(#555, 3%);
|
||||
}
|
||||
}
|
165
src/components/Config.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { getConfigs, fetchConfigs, updateConfigs } from 'd/configs';
|
||||
|
||||
import ContentHeader from 'c/ContentHeader';
|
||||
|
||||
import Switch from 'c/Switch';
|
||||
import ToggleSwitch from 'c/ToggleSwitch';
|
||||
import Input from 'c/Input';
|
||||
import s0 from 'c/Config.module.scss';
|
||||
|
||||
const optionsRule = [
|
||||
{
|
||||
label: 'Global',
|
||||
value: 'Global'
|
||||
},
|
||||
{
|
||||
label: 'Rule',
|
||||
value: 'Rule'
|
||||
},
|
||||
{
|
||||
label: 'Direct',
|
||||
value: 'Direct'
|
||||
}
|
||||
];
|
||||
|
||||
const optionsLogLevel = [
|
||||
{
|
||||
label: 'info',
|
||||
value: 'info'
|
||||
},
|
||||
{
|
||||
label: 'warning',
|
||||
value: 'warning'
|
||||
},
|
||||
{
|
||||
label: 'error',
|
||||
value: 'error'
|
||||
},
|
||||
{
|
||||
label: 'debug',
|
||||
value: 'debug'
|
||||
}
|
||||
];
|
||||
|
||||
const actions = {
|
||||
fetchConfigs,
|
||||
updateConfigs
|
||||
};
|
||||
|
||||
const mapStateToProps = s => {
|
||||
return {
|
||||
configs: getConfigs(s)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return bindActionCreators(actions, dispatch);
|
||||
};
|
||||
|
||||
class Config extends Component {
|
||||
static propTypes = {
|
||||
configs: PropTypes.object,
|
||||
fetchConfigs: PropTypes.func,
|
||||
updateConfigs: PropTypes.func
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchConfigs();
|
||||
}
|
||||
|
||||
handleInputOnChange = ev => {
|
||||
const { configs } = this.props;
|
||||
const target = ev.target;
|
||||
const { name } = target;
|
||||
|
||||
let value;
|
||||
switch (target.type) {
|
||||
case 'checkbox':
|
||||
value = target.checked;
|
||||
break;
|
||||
case 'number':
|
||||
value = Number(target.value);
|
||||
break;
|
||||
default:
|
||||
value = target.value;
|
||||
}
|
||||
if (configs[name] === value) return;
|
||||
this.props.updateConfigs({ [name]: value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { configs } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title="Config" />
|
||||
<div className={s0.root}>
|
||||
<div>
|
||||
<div className={s0.label}>HTTP Proxy Port</div>
|
||||
<Input
|
||||
name="port"
|
||||
value={configs.port}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>SOCKS5 Proxy Port</div>
|
||||
<Input
|
||||
name="socket-port"
|
||||
value={configs['socket-port']}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Redir Port</div>
|
||||
<Input
|
||||
name="redir-port"
|
||||
value={configs['redir-port']}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Allow LAN</div>
|
||||
<Switch
|
||||
name="allow-lan"
|
||||
checked={configs['allow-lan']}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Mode</div>
|
||||
<ToggleSwitch
|
||||
options={optionsRule}
|
||||
name="mode"
|
||||
value={configs.mode}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={s0.label}>Log Level</div>
|
||||
<ToggleSwitch
|
||||
options={optionsLogLevel}
|
||||
name="log-level"
|
||||
value={configs['log-level']}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Config);
|
16
src/components/Config.module.scss
Normal file
|
@ -0,0 +1,16 @@
|
|||
.root {
|
||||
padding: 10px 40px;
|
||||
color: #ddd;
|
||||
|
||||
// display: flex;
|
||||
// flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
// color: #aaa;
|
||||
padding: 16px 0;
|
||||
}
|
20
src/components/ContentHeader.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from './ContentHeader.module.scss';
|
||||
|
||||
class ContentHeader extends Component {
|
||||
static propTypes = {
|
||||
title: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={s0.root}>
|
||||
<h1 className={s0.h1}>{this.props.title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentHeader;
|
12
src/components/ContentHeader.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.root {
|
||||
height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.h1 {
|
||||
padding: 0 40px;
|
||||
color: #ddd;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
32
src/components/Home.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React, { Component } from 'react';
|
||||
// import PropTypes from 'prop-types';
|
||||
|
||||
import ContentHeader from 'c/ContentHeader';
|
||||
import TrafficChart from 'c/TrafficChart';
|
||||
import TrafficNow from 'c/TrafficNow';
|
||||
import s0 from 'c/Home.module.scss';
|
||||
|
||||
class Home extends Component {
|
||||
// static propTypes = {
|
||||
// match: PropTypes.object
|
||||
// };
|
||||
|
||||
render() {
|
||||
// const { match } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title="Overview" />
|
||||
<div className={s0.root}>
|
||||
<div>
|
||||
<TrafficNow />
|
||||
</div>
|
||||
<div className={s0.chart}>
|
||||
<TrafficChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Home;
|
6
src/components/Home.module.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
.root {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
}
|
22
src/components/Icon.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
|
||||
const Icon = ({ id, width = 20, height = 20, className, ...props }) => {
|
||||
const c = cx('icon', id, className);
|
||||
const href = '#' + id;
|
||||
return (
|
||||
<svg className={c} width={width} height={height} {...props}>
|
||||
<use xlinkHref={href} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
Icon.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
export default Icon;
|
61
src/components/Input.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from './Input.module.scss';
|
||||
|
||||
class Input extends Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
type: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
name: PropTypes.string,
|
||||
placeholder: PropTypes.string
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: 'number',
|
||||
placeholder: 'Please input'
|
||||
};
|
||||
|
||||
state = {
|
||||
value: this.props.value,
|
||||
lastValueFromProps: this.props.value
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.value !== state.lastValueFromProps) {
|
||||
return {
|
||||
lastValueFromProps: props.value,
|
||||
value: props.value
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleInputOnChange = e => {
|
||||
const value = e.target.value;
|
||||
const int = parseInt(value, 10);
|
||||
if (int < 0 || int > 65535) return;
|
||||
this.setState({ value: e.target.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onChange, name, type, placeholder } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
className={s0.input}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onBlur={onChange}
|
||||
onChange={this.handleInputOnChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Input;
|
23
src/components/Input.module.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
.input {
|
||||
-webkit-appearance: none;
|
||||
background-color: #2d2d30;
|
||||
background-image: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3f3f3f;
|
||||
box-sizing: border-box;
|
||||
color: #c1c1c1;
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
outline: none;
|
||||
padding: 0 15px;
|
||||
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
15
src/components/Loadable.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import L from 'react-loadable';
|
||||
|
||||
import Loading from './Loading';
|
||||
|
||||
// error will passed if the component failed to load
|
||||
// const Loading = ({ error }) => <div>loading</div>;
|
||||
|
||||
const Loadable = opts =>
|
||||
L({
|
||||
loading: Loading,
|
||||
delay: 200,
|
||||
...opts
|
||||
});
|
||||
|
||||
export default Loadable;
|
14
src/components/Loading.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import style from './Loading.module.scss';
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className={style.loading}>
|
||||
<div className={style.left + ' ' + style.circle} />
|
||||
<div className={style.right + ' ' + style.circle} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
50
src/components/Loading.module.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
$color1: #2a477a;
|
||||
$color2: #dddddd;
|
||||
|
||||
@keyframes moveRight {
|
||||
0% {
|
||||
transform: translate(-50px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveLeft {
|
||||
0% {
|
||||
transform: translate(50px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 0 auto;
|
||||
height: 30vh;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.left {
|
||||
background-color: $color1;
|
||||
left: 50%;
|
||||
animation: moveRight 1s ease-in-out 0s infinite alternate;
|
||||
}
|
||||
|
||||
.right {
|
||||
background-color: $color2;
|
||||
right: 50%;
|
||||
animation: moveLeft 1s ease-in-out 0s infinite alternate;
|
||||
}
|
89
src/components/Logs.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Icon from 'c/Icon';
|
||||
import ContentHeader from 'c/ContentHeader';
|
||||
import { fetchLogs } from '../api/logs';
|
||||
|
||||
import yacd from 's/yacd.svg';
|
||||
|
||||
import s0 from 'c/Logs.module.scss';
|
||||
const colors = {
|
||||
debug: '#8a8a8a',
|
||||
info: '#147d14',
|
||||
warning: '#b99105',
|
||||
error: '#c11c1c'
|
||||
};
|
||||
class LogLine extends Component {
|
||||
static propTypes = {
|
||||
time: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
payload: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { time, type, payload } = this.props;
|
||||
return (
|
||||
<li>
|
||||
<div className={s0.logMeta}>
|
||||
<div className={s0.logTime}>{time}</div>
|
||||
<div className={s0.logType} style={{ backgroundColor: colors[type] }}>
|
||||
{type}
|
||||
</div>
|
||||
<div className={s0.logText}>{payload}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Logs extends Component {
|
||||
// static propTypes = {
|
||||
// isOpen: PropTypes.bool.isRequired,
|
||||
// onRequestClose: PropTypes.func.isRequired
|
||||
// };
|
||||
state = {
|
||||
logs: []
|
||||
};
|
||||
|
||||
handle = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.handle = fetchLogs();
|
||||
this.setState({ logs: this.handle.logs });
|
||||
this.handle.updateCallback = () => {
|
||||
this.setState({ logs: this.handle.logs });
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.handle.updateCallback = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { logs } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title="Logs" />
|
||||
{logs.length === 0 ? (
|
||||
<div className={s0.logPlaceholder}>
|
||||
<div>
|
||||
<Icon id={yacd.id} width={200} height={200} />
|
||||
</div>
|
||||
<div>No logs yet, hang tight...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s0.logs}>
|
||||
<ul className={s0.logUl}>
|
||||
{logs.map(l => (
|
||||
<LogLine key={l.id} {...l} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logs;
|
64
src/components/Logs.module.scss
Normal file
|
@ -0,0 +1,64 @@
|
|||
$colorf: #eee;
|
||||
$heightHeader: 76px;
|
||||
|
||||
.logMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logType {
|
||||
color: $colorf;
|
||||
text-align: center;
|
||||
width: 56px;
|
||||
font-size: 10px;
|
||||
background: green;
|
||||
border-radius: 5px;
|
||||
padding: 3px 5px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.logTime {
|
||||
// color: $colorf;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logText {
|
||||
display: flex;
|
||||
font-family: 'Source Code Pro', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
//////////
|
||||
|
||||
.logUl {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: $colorf;
|
||||
}
|
||||
|
||||
/////////
|
||||
|
||||
.logs {
|
||||
padding: 10px 40px;
|
||||
height: calc(100vh - #{$heightHeader});
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.logPlaceholder {
|
||||
height: calc(100vh - #{$heightHeader});
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2d2d30;
|
||||
opacity: 0.3;
|
||||
|
||||
div:nth-child(2) {
|
||||
color: #ddd;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
34
src/components/Modal.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import s0 from './Modal.module.scss';
|
||||
|
||||
class ModalAPIConfig extends Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isOpen, onRequestClose, children, ...rest } = this.props;
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
contentLabel="test"
|
||||
className={s0.content}
|
||||
overlayClassName={s0.overlay}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ModalAPIConfig;
|
22
src/components/Modal.module.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.content {
|
||||
outline: none;
|
||||
position: absolute;
|
||||
color: #ddd;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #444;
|
||||
padding: 20px;
|
||||
// border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
}
|
103
src/components/Proxies.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ContentHeader from 'c/ContentHeader';
|
||||
import Proxy from 'c/Proxy';
|
||||
import Button from 'c/Button';
|
||||
|
||||
import cx from 'classnames';
|
||||
import s0 from 'c/Proxies.module.scss';
|
||||
|
||||
const th = cx(s0.row, s0.th, 'border-bottom');
|
||||
const colItem = cx(s0.colItem, 'border-bottom');
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { getProxies, fetchProxies, requestDelayAll } from 'd/proxies';
|
||||
|
||||
function mapStateToProps(s) {
|
||||
return {
|
||||
proxies: getProxies(s)
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
fetchProxies: bindActionCreators(fetchProxies, dispatch),
|
||||
requestDelayAll: bindActionCreators(requestDelayAll, dispatch)
|
||||
};
|
||||
}
|
||||
|
||||
class Proxies extends Component {
|
||||
static propTypes = {
|
||||
proxies: PropTypes.object.isRequired,
|
||||
fetchProxies: PropTypes.func.isRequired,
|
||||
requestDelayAll: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchProxies();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { proxies, requestDelayAll } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContentHeader title="Proxies" />
|
||||
|
||||
<div className={s0.root}>
|
||||
<div className={s0.btnGroup}>
|
||||
<Button label="Test Latency" onClick={requestDelayAll} />
|
||||
</div>
|
||||
<div className={th}>
|
||||
<div className={s0.col1}>Name</div>
|
||||
<div className={s0.col2}>Type</div>
|
||||
<div className={s0.col3}>All</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{Object.keys(proxies).map(k => {
|
||||
const o = proxies[k];
|
||||
return <ProxyRow name={k} key={k} {...o} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyRow extends Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
all: PropTypes.array,
|
||||
now: PropTypes.string
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, type, all, now } = this.props;
|
||||
return (
|
||||
<div className={s0.row}>
|
||||
<div className={s0.col1}>{name}</div>
|
||||
<div className={s0.col2}>{type}</div>
|
||||
<div className={s0.col3}>
|
||||
{all &&
|
||||
all.map(p => {
|
||||
return (
|
||||
<div className={colItem} key={p}>
|
||||
<Proxy name={p} parentName={name} checked={p === now} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Proxies);
|
47
src/components/Proxies.module.scss
Normal file
|
@ -0,0 +1,47 @@
|
|||
$heightHeader: 76px;
|
||||
|
||||
.root {
|
||||
color: #eee;
|
||||
padding: 10px 40px;
|
||||
height: calc(100vh - #{$heightHeader});
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.col1 {
|
||||
width: 150px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.col2 {
|
||||
width: 150px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
width: 350px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.colItem {
|
||||
padding: 8px 0;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.btnGroup {
|
||||
color: #eee;
|
||||
// padding: 10px 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
105
src/components/Proxy.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from 'c/Proxy.module.scss';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { getDelay, switchProxy, requestDelayForProxy } from 'd/proxies';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const delay = getDelay(state);
|
||||
return { delay };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
switchProxy: bindActionCreators(switchProxy, dispatch),
|
||||
requestDelay: bindActionCreators(requestDelayForProxy, dispatch)
|
||||
};
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
good: '#67C23A',
|
||||
normal: '#E6A23C',
|
||||
bad: '#F56C6C',
|
||||
na: '#909399'
|
||||
};
|
||||
|
||||
class Proxy extends Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
parentName: PropTypes.string,
|
||||
checked: PropTypes.bool,
|
||||
switchProxy: PropTypes.func,
|
||||
requestDelay: PropTypes.func,
|
||||
delay: PropTypes.object
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { name, delay, requestDelay } = this.props;
|
||||
if (delay[name]) return;
|
||||
|
||||
requestDelay(name);
|
||||
}
|
||||
|
||||
handleRadioOnChange = ev => {
|
||||
const { name, parentName, checked, switchProxy } = this.props;
|
||||
if (checked) return;
|
||||
switchProxy(parentName, name);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, parentName, checked, switchProxy, delay } = this.props;
|
||||
const id = parentName + ':' + name;
|
||||
// XXX default to 0 might not a good idea
|
||||
const latency = delay[name] || 0;
|
||||
return (
|
||||
<label className={s0.Proxy} htmlFor={id}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
checked={checked}
|
||||
value={name}
|
||||
onChange={this.handleRadioOnChange}
|
||||
/>
|
||||
<div className={s0.name}>{name}</div>
|
||||
<LatencyLabel val={latency} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LatencyLabel extends Component {
|
||||
static propTypes = {
|
||||
val: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { val } = this.props;
|
||||
let bg = colorMap.na;
|
||||
if (val < 100) {
|
||||
bg = colorMap.good;
|
||||
} else if (val < 300) {
|
||||
bg = colorMap.normal;
|
||||
} else {
|
||||
bg = colorMap.bad;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={s0.LatencyLabel}
|
||||
style={{
|
||||
background: bg
|
||||
}}
|
||||
>
|
||||
<div>{val}</div>
|
||||
<div>ms</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Proxy);
|
22
src/components/Proxy.module.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
.Proxy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
// width: 100px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.LatencyLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: #e6a23c;
|
||||
|
||||
div:nth-child(2) {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
61
src/components/Root.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ConnectedRouter } from 'react-router-redux';
|
||||
import { Route, Link } from 'react-router-dom';
|
||||
import { hot } from 'react-hot-loader';
|
||||
|
||||
import SideBar from 'c/SideBar';
|
||||
import Home from 'c/Home';
|
||||
import Logs from 'c/Logs';
|
||||
import Proxies from 'c/Proxies';
|
||||
import Config from 'c/Config';
|
||||
|
||||
import APIDiscovery from 'c/APIDiscovery';
|
||||
|
||||
// testing...
|
||||
// import StyleGuide from 'c/StyleGuide';
|
||||
// import Loading from 'c/Loading';
|
||||
|
||||
// for loading async chunk...not used yet
|
||||
|
||||
// import Loadable from './Loadable';
|
||||
// const delay = t => new Promise(r => setTimeout(r, t));
|
||||
// const AsyncAbout = Loadable({
|
||||
// loader: () => delay(800).then(() => import('./About'))
|
||||
// });
|
||||
// const AsyncHello = Loadable({
|
||||
// loader: () => import('./Hello')
|
||||
// });
|
||||
|
||||
import './Root.scss';
|
||||
import s0 from './Root.module.scss';
|
||||
|
||||
const Root = ({ store, history }) => (
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<div className={s0.app}>
|
||||
<APIDiscovery />
|
||||
<Route path="/" component={SideBar} />
|
||||
<div style={{ flexGrow: '1', overflow: 'scroll' }}>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/overview" component={Home} />
|
||||
<Route exact path="/configs" component={Config} />
|
||||
<Route exact path="/logs" component={Logs} />
|
||||
<Route exact path="/proxies" component={Proxies} />
|
||||
</div>
|
||||
</div>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
);
|
||||
// <Route exact path="/__0" component={StyleGuide} />
|
||||
// <Route exact path="/__1" component={Loading} />
|
||||
|
||||
Root.propTypes = {
|
||||
store: PropTypes.object,
|
||||
history: PropTypes.object
|
||||
};
|
||||
|
||||
// hot export Root
|
||||
// https://github.com/gaearon/react-hot-loader/tree/v4.0.1#getting-started
|
||||
export default hot(module)(Root);
|
7
src/components/Root.module.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
.app {
|
||||
display: flex;
|
||||
background: #202020;
|
||||
|
||||
min-height: 640px;
|
||||
height: 100vh;
|
||||
}
|
55
src/components/Root.scss
Normal file
|
@ -0,0 +1,55 @@
|
|||
.border-left,
|
||||
.border-top,
|
||||
.border-bottom {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
%border {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
transform: scaleY(0.5) translateZ(0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
%border1 {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
transform: scaleX(0.5) translateZ(0);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.border-top::before {
|
||||
@extend %border;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.border-bottom::after {
|
||||
@extend %border;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.border-left::before {
|
||||
@extend %border1;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
68
src/components/SideBar.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
// import { bindActionCreators } from 'redux';
|
||||
|
||||
import Icon from 'c/Icon';
|
||||
|
||||
import activity from 's/activity.svg';
|
||||
import settings from 's/settings.svg';
|
||||
import globe from 's/globe.svg';
|
||||
import file from 's/file.svg';
|
||||
import yacd from 's/yacd.svg';
|
||||
|
||||
import s from 'c/SideBar.module.scss';
|
||||
|
||||
class SideBarRowDump extends Component {
|
||||
static propTypes = {
|
||||
to: PropTypes.string.isRequired,
|
||||
iconId: PropTypes.string,
|
||||
labelText: PropTypes.string,
|
||||
pathname: PropTypes.string
|
||||
};
|
||||
|
||||
render() {
|
||||
const { iconId, labelText, to, pathname } = this.props;
|
||||
const cls = pathname === to ? s.rowActive : s.row;
|
||||
return (
|
||||
<Link to={to} className={cls}>
|
||||
<Icon id={iconId} width={28} height={28} />
|
||||
<div className={s.label}>{labelText}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { pathname } = state.router.location;
|
||||
return { pathname };
|
||||
};
|
||||
const mapDispatchToProps = null;
|
||||
|
||||
const SideBarRow = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SideBarRowDump);
|
||||
|
||||
class SideBar extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.logo}>
|
||||
<Icon id={yacd.id} width={80} height={80} />
|
||||
</div>
|
||||
|
||||
<div className={s.rows}>
|
||||
<SideBarRow to="/" iconId={activity.id} labelText="Overview" />
|
||||
<SideBarRow to="/proxies" iconId={globe.id} labelText="Proxies" />
|
||||
<SideBarRow to="/configs" iconId={settings.id} labelText="Config" />
|
||||
<SideBarRow to="/logs" iconId={file.id} labelText="Logs" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SideBar;
|
50
src/components/SideBar.module.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
.root {
|
||||
background: #2d2d30;
|
||||
// width: 220px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
color: #2a477a;
|
||||
transition: color 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: #1f52ac;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
// a router linke
|
||||
.rowActive,
|
||||
.row {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
|
||||
// &:hover {
|
||||
// color: white;
|
||||
// background: #494b4e;
|
||||
// }
|
||||
|
||||
svg {
|
||||
color: #c7c7c7;
|
||||
}
|
||||
}
|
||||
.rowActive {
|
||||
color: white;
|
||||
background: #494b4e;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding-left: 14px;
|
||||
}
|
60
src/components/StyleGuide.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
|
||||
import ToggleSwitch from 'c/ToggleSwitch';
|
||||
import Input from 'c/Input';
|
||||
import Switch from 'c/Switch';
|
||||
import Button from 'c/Button';
|
||||
import Modal from 'c/Modal';
|
||||
import APIConfig from 'c/APIConfig';
|
||||
|
||||
const paneStyle = {
|
||||
padding: '20px 0'
|
||||
};
|
||||
|
||||
const optionsRule = [
|
||||
{
|
||||
label: 'Global',
|
||||
value: 'Global'
|
||||
},
|
||||
{
|
||||
label: 'Rule',
|
||||
value: 'Rule'
|
||||
},
|
||||
{
|
||||
label: 'Direct',
|
||||
value: 'Direct'
|
||||
}
|
||||
];
|
||||
|
||||
const Pane = ({ children }) => <div style={paneStyle}>{children}</div>;
|
||||
|
||||
class StyleGuide extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Pane>
|
||||
<Switch />
|
||||
</Pane>
|
||||
<Pane>
|
||||
<Input />
|
||||
</Pane>
|
||||
<Pane>
|
||||
<ToggleSwitch
|
||||
name="test"
|
||||
options={optionsRule}
|
||||
value="Rule"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane>
|
||||
<Button label="Test Latency" />
|
||||
</Pane>
|
||||
<Modal isOpen={true} onRequestClose={() => {}}>
|
||||
<APIConfig />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StyleGuide;
|
35
src/components/Switch.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from 'c/Switch.module.scss';
|
||||
|
||||
class Switch extends Component {
|
||||
static propTypes = {
|
||||
checked: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
name: PropTypes.string
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
checked: false,
|
||||
name: '',
|
||||
onChange: () => {}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { checked, onChange, name } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={checked}
|
||||
className={s0.switch}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Switch;
|
50
src/components/Switch.module.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
// steal from https://codepen.io/joshnh/pen/hjbuH
|
||||
|
||||
$white: #fff;
|
||||
// $green: #53d76a;
|
||||
$grey: #d3d3d3;
|
||||
$color-theme: #047aff;
|
||||
|
||||
input.switch[type='checkbox'] {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
background-color: darken($white, 2%);
|
||||
border: 1px solid $grey;
|
||||
border-radius: 26px;
|
||||
box-shadow: inset 0 0 0 1px $grey;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
transition: border 0.25s 0.15s, box-shadow 0.25s 0.3s, padding 0.25s;
|
||||
width: 44px;
|
||||
vertical-align: top;
|
||||
|
||||
&:after {
|
||||
background-color: $white;
|
||||
border: 1px solid $grey;
|
||||
border-radius: 24px;
|
||||
box-shadow: inset 0 -3px 3px hsla(0, 0%, 0%, 0.025),
|
||||
0 1px 4px hsla(0, 0%, 0%, 0.15), 0 4px 4px hsla(0, 0%, 0%, 0.1);
|
||||
content: '';
|
||||
display: block;
|
||||
height: 26px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 0;
|
||||
transition: border 0.25s 0.15s, left 0.25s 0.1s, right 0.15s 0.175s;
|
||||
}
|
||||
&:checked {
|
||||
border-color: $color-theme;
|
||||
box-shadow: inset 0 0 0 13px $color-theme;
|
||||
padding-left: 18px;
|
||||
transition: border 0.25s, box-shadow 0.25s, padding 0.25s 0.15s;
|
||||
|
||||
&:after {
|
||||
border-color: $color-theme;
|
||||
left: 16px;
|
||||
right: 0;
|
||||
transition: border 0.25s, left 0.15s 0.25s, right 0.25s 0.175s;
|
||||
}
|
||||
}
|
||||
}
|
77
src/components/ToggleSwitch.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import s0 from 'c/ToggleSwitch.module.scss';
|
||||
|
||||
class ToggleSwitch extends Component {
|
||||
static propTypes = {
|
||||
options: PropTypes.array,
|
||||
value: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
options: [
|
||||
{
|
||||
label: 'Global',
|
||||
value: 'Global'
|
||||
},
|
||||
{
|
||||
label: 'Rule',
|
||||
value: 'Rule'
|
||||
},
|
||||
{
|
||||
label: 'Direct',
|
||||
value: 'Direct'
|
||||
}
|
||||
],
|
||||
value: 'Rule',
|
||||
name: 'rand0'
|
||||
};
|
||||
|
||||
// handleRadioOnChange = ev => {
|
||||
// ev.preventDefault();
|
||||
// const value = ev.target.value;
|
||||
// if (this.state.value === value) return;
|
||||
// this.setState({ value });
|
||||
// };
|
||||
|
||||
render() {
|
||||
const { options, name, value, onChange } = this.props;
|
||||
const w = (100 / options.length).toPrecision(3);
|
||||
return (
|
||||
<div>
|
||||
<div className={s0.ToggleSwitch}>
|
||||
{options.map((o, idx) => {
|
||||
if (value === o.value) this.idx = idx;
|
||||
const id = `${name}-${o.label}`;
|
||||
let className = idx === 0 ? '' : 'border-left';
|
||||
return (
|
||||
<label htmlFor={id} key={id} className={className}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="radio"
|
||||
value={o.value}
|
||||
checked={value === o.value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div>{o.label}</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
className={s0.slider}
|
||||
style={{
|
||||
width: w + '%',
|
||||
left: this.idx * w + '%'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ToggleSwitch;
|
34
src/components/ToggleSwitch.module.scss
Normal file
|
@ -0,0 +1,34 @@
|
|||
.ToggleSwitch {
|
||||
user-select: none;
|
||||
border: 1px solid #525252;
|
||||
color: #eee;
|
||||
background: #353535;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
flex: 1;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: left 0.2s ease-out;
|
||||
background: #181818;
|
||||
}
|
161
src/components/TrafficChart.js
Normal file
|
@ -0,0 +1,161 @@
|
|||
import React, { Component } from 'react';
|
||||
import Chart from 'chart.js/dist/Chart.min.js';
|
||||
import prettyBytes from 'm/pretty-bytes';
|
||||
|
||||
import { fetchData } from '../api/traffic';
|
||||
|
||||
const colorCombo = {
|
||||
0: {
|
||||
down: {
|
||||
backgroundColor: 'rgba(176, 209, 132, 0.8)',
|
||||
borderColor: 'rgb(176, 209, 132)'
|
||||
},
|
||||
up: {
|
||||
backgroundColor: 'rgba(181, 220, 231, 0.8)',
|
||||
borderColor: 'rgb(181, 220, 231)'
|
||||
}
|
||||
},
|
||||
1: {
|
||||
up: {
|
||||
backgroundColor: 'rgba(242, 174, 62, 0.3)',
|
||||
borderColor: 'rgb(242, 174, 62)'
|
||||
},
|
||||
down: {
|
||||
backgroundColor: 'rgba(69, 154, 248, 0.3)',
|
||||
borderColor: 'rgb(69, 154, 248)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const upProps = {
|
||||
...colorCombo['0'].up,
|
||||
label: 'Up',
|
||||
borderWidth: 1,
|
||||
lineTension: 0,
|
||||
pointRadius: 0
|
||||
};
|
||||
|
||||
const downProps = {
|
||||
...colorCombo['0'].down,
|
||||
label: 'Down',
|
||||
borderWidth: 1,
|
||||
lineTension: 0,
|
||||
pointRadius: 0
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
fontColor: '#ccc',
|
||||
boxWidth: 20
|
||||
}
|
||||
},
|
||||
tooltips: {
|
||||
// it's hard to follow the tooltip while the data is streaming
|
||||
// so disable it for now
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
animationDuration: 100
|
||||
// callbacks: {
|
||||
// label(tooltipItem, data) {
|
||||
// console.log(tooltipItem);
|
||||
// const { datasetIndex, yLabel } = tooltipItem;
|
||||
// const l = data.datasets[tooltipItem.datasetIndex].label;
|
||||
// console.log(yLabel);
|
||||
// const b = prettyBytes(parseInt(yLabel, 10));
|
||||
// return l + b;
|
||||
// }
|
||||
// }
|
||||
},
|
||||
hover: {
|
||||
mode: 'nearest',
|
||||
intersect: true
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
display: false,
|
||||
gridLines: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
display: true,
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: '#555',
|
||||
borderDash: [3, 6],
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback(value) {
|
||||
return prettyBytes(value) + '/s ';
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
class TrafficChart extends Component {
|
||||
traffic = {
|
||||
labels: [],
|
||||
up: [],
|
||||
down: []
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const ctx = document.getElementById('myChart').getContext('2d');
|
||||
this.traffic = fetchData();
|
||||
const data = {
|
||||
labels: this.traffic.labels,
|
||||
datasets: [
|
||||
{
|
||||
...upProps,
|
||||
data: this.traffic.up
|
||||
},
|
||||
{
|
||||
...downProps,
|
||||
data: this.traffic.down
|
||||
}
|
||||
]
|
||||
};
|
||||
const c = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data,
|
||||
options
|
||||
});
|
||||
this.unsubscribe = this.traffic.subscribe(() => c.update());
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '80%'
|
||||
}}
|
||||
>
|
||||
<canvas id="myChart" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TrafficChart;
|
45
src/components/TrafficNow.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React, { Component } from 'react';
|
||||
import prettyBytes from 'm/pretty-bytes';
|
||||
|
||||
import { fetchData } from '../api/traffic';
|
||||
|
||||
import s0 from 'c/TrafficNow.module.scss';
|
||||
|
||||
class TrafficNow extends Component {
|
||||
state = {
|
||||
upStr: '',
|
||||
downStr: ''
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.traffic = fetchData();
|
||||
this.unsubscribe = this.traffic.subscribe(o => {
|
||||
this.setState({
|
||||
upStr: prettyBytes(o.up) + '/s',
|
||||
downStr: prettyBytes(o.down) + '/s'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { upStr, downStr } = this.state;
|
||||
return (
|
||||
<div className={s0.TrafficNow}>
|
||||
<div className={s0.up}>
|
||||
<div>Upload</div>
|
||||
<div>{upStr}</div>
|
||||
</div>
|
||||
<div className={s0.down}>
|
||||
<div>Download</div>
|
||||
<div>{downStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TrafficNow;
|
26
src/components/TrafficNow.module.scss
Normal file
|
@ -0,0 +1,26 @@
|
|||
.TrafficNow {
|
||||
color: #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.up,
|
||||
.down {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
width: 200px;
|
||||
|
||||
div:nth-child(1) {
|
||||
color: #ccc;
|
||||
}
|
||||
div:nth-child(2) {
|
||||
padding: 10px 0 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
.up {
|
||||
padding-right: 20px;
|
||||
}
|
||||
.down {
|
||||
padding-left: 20px;
|
||||
}
|
13
src/config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
'use strict';
|
||||
|
||||
// const apiBaseURL = 'http://127.0.0.1:1234';
|
||||
let apiBaseURL = 'http://127.0.0.1:7899';
|
||||
const updateAPIBaseURL = url => (apiBaseURL = url);
|
||||
const getAPIURL = () => ({
|
||||
proxies: apiBaseURL + '/proxies',
|
||||
logs: apiBaseURL + '/logs',
|
||||
traffic: apiBaseURL + '/traffic',
|
||||
configs: apiBaseURL + '/configs'
|
||||
});
|
||||
|
||||
export { apiBaseURL, getAPIURL, updateAPIBaseURL };
|
64
src/ducks/app.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { loadState, saveState } from 'm/storage';
|
||||
import { fetchConfigs } from 'd/configs';
|
||||
import { closeModal } from 'd/modals';
|
||||
import { apiBaseURL, updateAPIBaseURL } from '../config';
|
||||
|
||||
const UpdateClashAPIConfig = 'app/UpdateClashAPIConfig';
|
||||
|
||||
const StorageKey = 'yacd.haishan.me';
|
||||
|
||||
export const getClashAPIConfig = s => s.app.clashAPIConfig;
|
||||
|
||||
// TODO to support secret
|
||||
export function updateClashAPIConfig(iHostname, iPort) {
|
||||
return async (dispatch, getState) => {
|
||||
const hostname = iHostname.trim().replace(/^http(s)\:\/\//, '');
|
||||
const port = iPort;
|
||||
|
||||
dispatch({
|
||||
type: UpdateClashAPIConfig,
|
||||
payload: { hostname, port }
|
||||
});
|
||||
|
||||
// side effect
|
||||
updateAPIBaseURL('http://' + hostname + ':' + port);
|
||||
saveState(StorageKey, getState().app);
|
||||
|
||||
dispatch(closeModal('apiConfig'));
|
||||
dispatch(fetchConfigs());
|
||||
};
|
||||
}
|
||||
|
||||
function retrieveAPIHostnameAndPort(apiBaseURL) {
|
||||
const match = /^http:\/\/(\S+?):(\d+)/.exec(apiBaseURL);
|
||||
if (!match) return {};
|
||||
return {
|
||||
hostname: match[1],
|
||||
port: match[2]
|
||||
};
|
||||
}
|
||||
const defaultState = {
|
||||
clashAPIConfig: retrieveAPIHostnameAndPort(apiBaseURL)
|
||||
};
|
||||
function getInitialState() {
|
||||
let s = loadState(StorageKey);
|
||||
if (!s) s = defaultState;
|
||||
|
||||
// FIXME using data from multi source is NOT OK
|
||||
const { hostname, port } = s.clashAPIConfig;
|
||||
updateAPIBaseURL('http://' + hostname + ':' + port);
|
||||
|
||||
return s;
|
||||
}
|
||||
const initialState = getInitialState();
|
||||
|
||||
export default function reducer(state = initialState, { type, payload }) {
|
||||
switch (type) {
|
||||
case UpdateClashAPIConfig: {
|
||||
return { ...state, clashAPIConfig: { ...payload } };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
97
src/ducks/configs.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
'use strict';
|
||||
|
||||
import * as configsAPI from 'a/configs';
|
||||
import { openModal } from 'd/modals';
|
||||
|
||||
const CompletedFetchConfigs = 'configs/CompletedFetchConfigs';
|
||||
const OptimisticUpdateConfigs = 'proxies/OptimisticUpdateConfigs';
|
||||
|
||||
// const CompletedRequestDelayForProxy = 'proxies/CompletedRequestDelayForProxy';
|
||||
|
||||
export const getConfigs = s => s.configs;
|
||||
|
||||
export function fetchConfigs() {
|
||||
return async (dispatch, getState) => {
|
||||
let res;
|
||||
try {
|
||||
res = await configsAPI.fetchConfigs();
|
||||
} catch (err) {
|
||||
// FIXME
|
||||
console.log('Error fetch configs', err);
|
||||
dispatch(openModal('apiConfig'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
dispatch(openModal('apiConfig'));
|
||||
} else {
|
||||
console.log('Error fetch configs', res.statusText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
|
||||
dispatch({
|
||||
type: CompletedFetchConfigs,
|
||||
payload
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function updateConfigs(partialConfg) {
|
||||
return async (dispatch, getState) => {
|
||||
configsAPI
|
||||
.updateConfigs(partialConfg)
|
||||
.then(
|
||||
res => {
|
||||
if (res.ok === false) {
|
||||
console.log('Error update configs', res.statusText);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.log('Error update configs', err);
|
||||
throw err;
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
// fetch updated configs to refresh to UI
|
||||
dispatch(fetchConfigs());
|
||||
});
|
||||
const configsCurr = getConfigs(getState());
|
||||
|
||||
dispatch({
|
||||
type: OptimisticUpdateConfigs,
|
||||
payload: {
|
||||
...configsCurr,
|
||||
...partialConfg
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
port: 7890,
|
||||
'socket-port': 7891,
|
||||
'redir-port': 0,
|
||||
'allow-lan': false,
|
||||
mode: 'Rule',
|
||||
'log-level': 'info'
|
||||
|
||||
/////
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, { type, payload }) {
|
||||
switch (type) {
|
||||
// case CompletedRequestDelayForProxy:
|
||||
// case OptimisticSwitchProxy:
|
||||
case OptimisticUpdateConfigs:
|
||||
case CompletedFetchConfigs: {
|
||||
return { ...state, ...payload };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
14
src/ducks/index.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import { routerReducer as router } from 'react-router-redux';
|
||||
import app from './app';
|
||||
import modals from './modals';
|
||||
import proxies from './proxies';
|
||||
import configs from './configs';
|
||||
|
||||
export default combineReducers({
|
||||
router,
|
||||
app,
|
||||
modals,
|
||||
proxies,
|
||||
configs
|
||||
});
|
31
src/ducks/modals.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
const OpenModal = 'modals/OpenModal';
|
||||
const CloseModal = 'modals/CloseModal';
|
||||
|
||||
export function openModal(modalName) {
|
||||
return {
|
||||
type: OpenModal,
|
||||
payload: modalName
|
||||
};
|
||||
}
|
||||
|
||||
export function closeModal(modalName) {
|
||||
return {
|
||||
type: CloseModal,
|
||||
payload: modalName
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
apiConfig: false
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, { type, payload }) {
|
||||
switch (type) {
|
||||
case OpenModal:
|
||||
return { ...initialState, [payload]: true };
|
||||
case CloseModal:
|
||||
return { ...initialState, [payload]: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
128
src/ducks/proxies.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
import * as proxiesAPI from 'a/proxies';
|
||||
|
||||
export const getProxies = s => s.proxies.proxies;
|
||||
export const getDelay = s => s.proxies.delay;
|
||||
|
||||
const CompletedFetchProxies = 'proxies/CompletedFetchProxies';
|
||||
const OptimisticSwitchProxy = 'proxies/OptimisticSwitchProxy';
|
||||
const CompletedRequestDelayForProxy = 'proxies/CompletedRequestDelayForProxy';
|
||||
|
||||
export function fetchProxies() {
|
||||
return async (dispatch, getState) => {
|
||||
// TODO handle errors
|
||||
|
||||
const proxiesCurr = getProxies(getState());
|
||||
// TODO this is too aggressive...
|
||||
if (Object.keys(proxiesCurr).length > 0) return;
|
||||
|
||||
// TODO show loading animation?
|
||||
const json = await proxiesAPI.fetchProxies();
|
||||
let { proxies = {} } = json;
|
||||
dispatch({
|
||||
type: CompletedFetchProxies,
|
||||
payload: { proxies }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function switchProxy(name1, name2) {
|
||||
return async (dispatch, getState) => {
|
||||
// TODO display error message
|
||||
proxiesAPI
|
||||
.requestToSwitchProxy(name1, name2)
|
||||
.then(
|
||||
res => {
|
||||
if (res.ok === false) {
|
||||
console.log('failed to swith proxy', res.statusText);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.log(err, 'failed to swith proxy');
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
// fetchProxies again
|
||||
dispatch(fetchProxies());
|
||||
});
|
||||
// optimistic UI update
|
||||
const proxiesCurr = getProxies(getState());
|
||||
const proxiesNext = { ...proxiesCurr };
|
||||
if (proxiesNext[name1] && proxiesNext[name1].now) {
|
||||
proxiesNext[name1].now = name2;
|
||||
}
|
||||
dispatch({
|
||||
type: OptimisticSwitchProxy,
|
||||
payload: { proxies: proxiesNext }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function requestDelayForProxyOnce(name) {
|
||||
return async (dispatch, getState) => {
|
||||
const res = await proxiesAPI.requestDelayForProxy(name);
|
||||
if (res.ok === false) {
|
||||
console.log('Error', res.statusText);
|
||||
return;
|
||||
}
|
||||
const { delay } = await res.json();
|
||||
|
||||
const delayPrev = getDelay(getState());
|
||||
const delayNext = {
|
||||
...delayPrev,
|
||||
[name]: delay
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: CompletedRequestDelayForProxy,
|
||||
payload: { delay: delayNext }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// const proxyTypeListTo = [
|
||||
// 'Vmess',
|
||||
// 'Shadowsocks'
|
||||
// ];
|
||||
|
||||
export function requestDelayForProxy(name) {
|
||||
return async dispatch => {
|
||||
await dispatch(requestDelayForProxyOnce(name));
|
||||
await dispatch(requestDelayForProxyOnce(name));
|
||||
await dispatch(requestDelayForProxyOnce(name));
|
||||
};
|
||||
}
|
||||
|
||||
export function requestDelayAll() {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const proxies = getProxies(state);
|
||||
const keys = Object.keys(proxies);
|
||||
const proxyNames = [];
|
||||
keys.forEach(k => {
|
||||
if (proxies[k].type === 'Vmess' || proxies[k].type === 'Shadowsocks') {
|
||||
proxyNames.push(k);
|
||||
}
|
||||
});
|
||||
await Promise.all(proxyNames.map(p => dispatch(requestDelayForProxy(p))));
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
proxies: {},
|
||||
delay: {}
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, { type, payload }) {
|
||||
switch (type) {
|
||||
case CompletedRequestDelayForProxy:
|
||||
case OptimisticSwitchProxy:
|
||||
case CompletedFetchProxies: {
|
||||
return { ...state, ...payload };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
26
src/index.template.ejs
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
<link id="favicon" rel="icon" type="image/png" sizes="64x64" href="yacd-64.png">
|
||||
<link id="favicon" rel="icon" type="image/png" sizes="128x128" href="yacd-128.png">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
|
||||
<% for (key in htmlWebpackPlugin.files.css) { %>
|
||||
<link href="<%= htmlWebpackPlugin.files.css[key] %>" rel="stylesheet">
|
||||
<% } %>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<% for (key in htmlWebpackPlugin.files.chunks) { %>
|
||||
<script src="<%= htmlWebpackPlugin.files.chunks[key].entry %>" type="text/javascript"></script>
|
||||
<% } %>
|
||||
|
||||
</body>
|
||||
</html>
|
17
src/misc/pretty-bytes.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
'use strict';
|
||||
// steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js
|
||||
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
export default number => {
|
||||
if (number < 1000) {
|
||||
return number + ' B';
|
||||
}
|
||||
const exponent = Math.min(
|
||||
Math.floor(Math.log10(number) / 3),
|
||||
UNITS.length - 1
|
||||
);
|
||||
number = Number((number / Math.pow(1000, exponent)).toPrecision(3));
|
||||
const unit = UNITS[exponent];
|
||||
return number + ' ' + unit;
|
||||
};
|
30
src/misc/storage.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
// manage localStorage
|
||||
|
||||
function loadState(key) {
|
||||
try {
|
||||
const serialized = localStorage.getItem(key);
|
||||
if (!serialized) return undefined;
|
||||
return JSON.parse(serialized);
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(key, state) {
|
||||
try {
|
||||
const serialized = JSON.stringify(state);
|
||||
localStorage.setItem(key, serialized);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export { loadState, saveState, clearState };
|
27
src/store/configureStore.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import rootReducer from '../ducks';
|
||||
// import { loadState } from '../utils';
|
||||
import { routerMiddleware } from 'react-router-redux';
|
||||
|
||||
// const preloadedState = loadState();
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
export default function configureStore(history) {
|
||||
const store = createStore(
|
||||
rootReducer,
|
||||
composeEnhancers(
|
||||
applyMiddleware(thunkMiddleware, routerMiddleware(history))
|
||||
)
|
||||
);
|
||||
|
||||
if (module.hot) {
|
||||
// Enable Webpack hot module replacement for reducers
|
||||
module.hot.accept('../ducks', () => {
|
||||
const nextRootReducer = require('../ducks').default;
|
||||
store.replaceReducer(nextRootReducer);
|
||||
});
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
1
src/svg/activity.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
After Width: | Height: | Size: 250 B |
1
src/svg/cpu.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>
|
After Width: | Height: | Size: 667 B |
1
src/svg/file.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
After Width: | Height: | Size: 440 B |
1
src/svg/github.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-github"><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"></path></svg>
|
After Width: | Height: | Size: 527 B |
1
src/svg/globe.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
After Width: | Height: | Size: 380 B |
1
src/svg/heart.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-heart"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
|
After Width: | Height: | Size: 372 B |
1
src/svg/settings.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
After Width: | Height: | Size: 979 B |
1
src/svg/star.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
After Width: | Height: | Size: 339 B |
1
src/svg/x.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
After Width: | Height: | Size: 299 B |
8
src/svg/yacd.svg
Normal file
|
@ -0,0 +1,8 @@
|
|||
<svg width="320" height="320" viewBox="0 0 320 320" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path d="M68.142 52.056c9.396-1.515 29.798 27.762 45.809 57.699 18.907-8.164 75.199-7.859 97.664 0 4.799-15.898 28.706-58.455 38.546-57.699 4.065.09 13.145 32.285 23.568 96.695 2.793 17.259 6.883 48.36 12.271 93.305C245.489 260.019 203.995 269 161.518 269c-42.477 0-84.983-8.981-127.518-26.944 16.497-125.657 27.878-188.99 34.142-190z" stroke="#EEE" stroke-width="5" fill="currentColor"/>
|
||||
<circle stroke="#EEE" fill="#EEE" cx="226" cy="184" r="15"/>
|
||||
<circle stroke="#EEE" fill="#EEE" cx="103" cy="184" r="15"/>
|
||||
<path d="M163 214.042c2.067 0 4.637 6.54 10.108 6.54 4.567.16 5.383-1.65 7.074-1.677 1.656 0 1.818 1.174 1.818 1.677.027 2.166-5.347 3.686-8.349 3.418-7.475.101-9.703-5.451-10.651-5.451-.91 0-3.833 5.451-10.194 5.451-7.143-.113-8.799-1.471-8.799-3.099.01-.783.802-1.54 1.685-1.505 1.39-.041 4.576 1.027 7.35 1.038 3.585-.016 8.163-6.392 9.958-6.392z" fill="#FFF"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1 KiB |
207
webpack.common.js
Normal file
|
@ -0,0 +1,207 @@
|
|||
'use strict';
|
||||
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
process.env.BABEL_ENV = process.env.NODE_ENV;
|
||||
|
||||
// ----- devtool
|
||||
|
||||
let devtool;
|
||||
if (isDev) {
|
||||
// https://webpack.js.org/configuration/devtool/
|
||||
devtool = 'eval';
|
||||
} else {
|
||||
// https://webpack.js.org/configuration/devtool/
|
||||
devtool = 'source-map';
|
||||
}
|
||||
module.exports.devtool = devtool;
|
||||
|
||||
// ---- entry
|
||||
|
||||
const entry = {
|
||||
// vendor: ['babel-polyfill'],
|
||||
app: ['./src/index.js']
|
||||
};
|
||||
|
||||
module.exports.entry = entry;
|
||||
|
||||
// ---- output
|
||||
|
||||
const output = {
|
||||
path: path.resolve(__dirname, 'public'),
|
||||
filename: '[name].bundle.js',
|
||||
publicPath: ''
|
||||
};
|
||||
|
||||
module.exports.output = output;
|
||||
|
||||
// ----- rules
|
||||
const jsRule = {
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: ['babel-loader']
|
||||
};
|
||||
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const cssExtractPlugin = new MiniCssExtractPlugin({
|
||||
filename: isDev ? '[name].bundle.css' : '[name].[chunkhash].css'
|
||||
});
|
||||
|
||||
const LOCAL_IDENT_NAME_DEV = '[path]---[name]---[local]---[hash:base64:5]';
|
||||
const LOCAL_IDENT_NAME_PROD = '[hash:base64:10]';
|
||||
const localIdentName = isDev ? LOCAL_IDENT_NAME_DEV : LOCAL_IDENT_NAME_PROD;
|
||||
const getCssLoaderOptions = (opt = {}) => ({
|
||||
minimize: true,
|
||||
localIdentName,
|
||||
...opt
|
||||
});
|
||||
|
||||
const cssnano = require('cssnano');
|
||||
const loaders = {
|
||||
style: { loader: 'style-loader' },
|
||||
css: { loader: 'css-loader', options: getCssLoaderOptions() },
|
||||
cssModule: {
|
||||
loader: 'css-loader',
|
||||
options: getCssLoaderOptions({ modules: true })
|
||||
},
|
||||
postcss: {
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
plugins: () => [require('autoprefixer'), cssnano()]
|
||||
}
|
||||
},
|
||||
sass: {
|
||||
loader: 'sass-loader'
|
||||
}
|
||||
};
|
||||
|
||||
const cssDevRule = {
|
||||
test: /\.css$/,
|
||||
exclude: /\.module\.css$/,
|
||||
use: [loaders.style, loaders.css, loaders.postcss]
|
||||
};
|
||||
const cssProdRule = {
|
||||
test: /\.css$/,
|
||||
exclude: /\.module\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, loaders.css, loaders.postcss]
|
||||
};
|
||||
const cssModulesDevRule = {
|
||||
test: /\.module\.css$/,
|
||||
use: [loaders.style, loaders.cssModule, loaders.postcss]
|
||||
};
|
||||
|
||||
const cssModulesProdRule = {
|
||||
test: /\.module\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, loaders.cssModule, loaders.postcss]
|
||||
};
|
||||
|
||||
const fileRule = {
|
||||
test: /\.(ttf|eot|woff|woff2)(\?.+)?$/,
|
||||
use: ['file-loader']
|
||||
};
|
||||
|
||||
const sassDevRule = {
|
||||
test: /\.scss$/,
|
||||
exclude: /\.module\.scss$/,
|
||||
use: [loaders.style, loaders.css, loaders.postcss, loaders.sass]
|
||||
};
|
||||
|
||||
const sassProdRule = {
|
||||
test: /\.scss$/,
|
||||
exclude: /\.module\.scss$/,
|
||||
use: [MiniCssExtractPlugin.loader, loaders.css, loaders.postcss, loaders.sass]
|
||||
};
|
||||
|
||||
const sassCssModuleDevRule = {
|
||||
test: /\.module\.scss$/,
|
||||
use: [loaders.style, loaders.cssModule, loaders.postcss, loaders.sass]
|
||||
};
|
||||
|
||||
const sassCssModuleProdRule = {
|
||||
test: /\.module\.scss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
loaders.cssModule,
|
||||
loaders.postcss,
|
||||
loaders.sass
|
||||
]
|
||||
};
|
||||
|
||||
module.exports.jsRule = jsRule;
|
||||
module.exports.fileRule = fileRule;
|
||||
module.exports.cssDevRule = cssDevRule;
|
||||
module.exports.cssProdRule = cssProdRule;
|
||||
module.exports.cssModulesDevRule = cssModulesDevRule;
|
||||
module.exports.cssModulesProdRule = cssModulesProdRule;
|
||||
module.exports.sassDevRule = sassDevRule;
|
||||
module.exports.sassProdRule = sassProdRule;
|
||||
module.exports.sassCssModuleDevRule = sassCssModuleDevRule;
|
||||
module.exports.sassCssModuleProdRule = sassCssModuleProdRule;
|
||||
|
||||
const rules = {
|
||||
js: jsRule,
|
||||
file: fileRule,
|
||||
css: isDev ? cssDevRule : cssProdRule,
|
||||
cssModules: isDev ? cssModulesDevRule : cssModulesProdRule,
|
||||
sass: isDev ? sassDevRule : sassProdRule,
|
||||
sassCssModules: isDev ? sassCssModuleDevRule : sassCssModuleProdRule
|
||||
};
|
||||
module.exports.rules = rules;
|
||||
|
||||
// ----- plugins
|
||||
|
||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
|
||||
|
||||
const definePlugin = new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify('production')
|
||||
}
|
||||
});
|
||||
|
||||
// webpack 4 enable optimization concatenateModules by default
|
||||
// https://medium.com/webpack/webpack-4-mode-and-optimization-5423a6bc597a
|
||||
// const moduleConcatPlugin = new webpack.optimize.ModuleConcatenationPlugin();
|
||||
|
||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||
const bundleAnalyzerPlugin = new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
reportFilename: 'report.html'
|
||||
});
|
||||
|
||||
// prints more readable module names in the browser console on HMR updates
|
||||
|
||||
module.exports.definePlugin = definePlugin;
|
||||
|
||||
let plugins = [];
|
||||
let pluginsCommon = [];
|
||||
if (isDev) {
|
||||
// in webpack 4 / namedModules will be enabled by default
|
||||
plugins = [...pluginsCommon];
|
||||
} else {
|
||||
plugins = [
|
||||
...pluginsCommon,
|
||||
new webpack.HashedModuleIdsPlugin(),
|
||||
definePlugin,
|
||||
// see https://github.com/webpack-contrib/uglifyjs-webpack-plugin
|
||||
new UglifyJSPlugin({
|
||||
// enable parallelization.
|
||||
// default number of concurrent runs: os.cpus().length - 1.
|
||||
parallel: true,
|
||||
// enable file caching.
|
||||
// default path to cache directory:
|
||||
// node_modules/.cache/uglifyjs-webpack-plugin.
|
||||
cache: true
|
||||
// debug
|
||||
// uglifyOptions: {
|
||||
// compress: false,
|
||||
// mangle: false
|
||||
// }
|
||||
}),
|
||||
cssExtractPlugin,
|
||||
bundleAnalyzerPlugin
|
||||
];
|
||||
}
|
||||
module.exports.plugins = plugins;
|
116
webpack.config.js
Normal file
|
@ -0,0 +1,116 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const { rules, plugins } = require('./webpack.common');
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const resolveDir = dir => path.resolve(__dirname, dir);
|
||||
const HTMLPlugin = require('html-webpack-plugin');
|
||||
const html = new HTMLPlugin({
|
||||
title: 'yacd - Yet Another Clash Dashboard',
|
||||
template: 'src/index.template.ejs',
|
||||
inject: false,
|
||||
filename: 'index.html'
|
||||
});
|
||||
|
||||
const svgSpriteRule = {
|
||||
test: /\.svg$/,
|
||||
use: ['svg-sprite-loader']
|
||||
};
|
||||
|
||||
// ---- entry
|
||||
|
||||
const entry = {
|
||||
app: ['@babel/polyfill', './src/app.js']
|
||||
};
|
||||
|
||||
// ---- output
|
||||
|
||||
const output = {
|
||||
path: path.resolve(__dirname, 'public'),
|
||||
filename: isDev ? '[name].bundle.js' : '[name].[chunkhash].js',
|
||||
publicPath: ''
|
||||
};
|
||||
|
||||
// const polyfill = ['whatwg-fetch'];
|
||||
// entry.polyfill = polyfill;
|
||||
|
||||
const vendor = ['redux', 'react', 'react-dom', 'react-router-dom'];
|
||||
|
||||
// if (!isDev) entry.vendor = vendor; // generate common vendor bundle in prod
|
||||
|
||||
// if (isDev) {
|
||||
// const dllRefPlugin = new webpack.DllReferencePlugin({
|
||||
// context: '.',
|
||||
// manifest: require('./public/vendor-manifest.json')
|
||||
// });
|
||||
// plugins.push(dllRefPlugin);
|
||||
// }
|
||||
|
||||
// since we don't use dll plugin for now - we still get vendor's bundled in a separate bundle
|
||||
// entry.vendor = vendor;
|
||||
// entry.react = react;
|
||||
|
||||
const mode = isDev ? 'development' : 'production';
|
||||
|
||||
const definePlugin = new webpack.DefinePlugin({
|
||||
__DEV__: JSON.stringify(isDev),
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify(process.env.NODE_ENV)
|
||||
}
|
||||
});
|
||||
|
||||
plugins.push(html);
|
||||
plugins.push(definePlugin);
|
||||
|
||||
let devtool;
|
||||
if (isDev) {
|
||||
devtool = 'eval-source-map';
|
||||
} else {
|
||||
// devtool = 'source-map';
|
||||
devtool = false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
devtool,
|
||||
entry,
|
||||
output,
|
||||
mode,
|
||||
resolve: {
|
||||
alias: {
|
||||
a: resolveDir('src/api'),
|
||||
s: resolveDir('src/svg'),
|
||||
m: resolveDir('src/misc'),
|
||||
d: resolveDir('src/ducks'),
|
||||
c: resolveDir('src/components')
|
||||
}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
svgSpriteRule,
|
||||
rules.js,
|
||||
rules.file,
|
||||
rules.css,
|
||||
rules.cssModules,
|
||||
rules.sass,
|
||||
rules.sassCssModules
|
||||
]
|
||||
},
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
// see https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693#optimizationruntimechunk
|
||||
cacheGroups: {
|
||||
core: {
|
||||
test: module => {
|
||||
if (/\/node_modules\/core-js\//.test(module.resource)) return true;
|
||||
},
|
||||
chunks: 'all'
|
||||
}
|
||||
}
|
||||
},
|
||||
runtimeChunk: true
|
||||
},
|
||||
plugins
|
||||
};
|
42
webpack.dll.config.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
'use strict';
|
||||
|
||||
const webpack = require('webpack');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx']
|
||||
},
|
||||
entry: {
|
||||
vendor: [
|
||||
'babel-polyfill',
|
||||
'react',
|
||||
'react-dom',
|
||||
'redux',
|
||||
'react-router-dom'
|
||||
]
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public'),
|
||||
// this will generate vendor.bundle.js
|
||||
// commons chunk plugin will also vendor.bundle.js
|
||||
// the benefit is we reuse the vendor <script /> tag in our index.html
|
||||
//
|
||||
// since this dll plugin is only used in dev
|
||||
// and commons chunk plugin is only used in prod
|
||||
// there is no conflict
|
||||
filename: '[name].dll.js',
|
||||
library: '[name]_[hash]'
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DllPlugin({
|
||||
path: path.join(__dirname, 'public', '[name]-manifest.json'),
|
||||
name: '[name]_[hash]'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
// const output = {
|
||||
// filename: '[name].bundle.js',
|
||||
// publicPath: '/assets'
|
||||
// };
|