first commit

This commit is contained in:
Haishan 2018-10-20 20:32:02 +08:00
commit 133b29c9da
74 changed files with 11628 additions and 0 deletions

27
.babelrc Normal file
View 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
View 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
View file

@ -0,0 +1,7 @@
*.*~
.DS_Store
node_modules
public/
deploy_ghpages.sh
tags
*.log

BIN
assets/yacd-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
assets/yacd-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

93
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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);

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

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

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

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

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

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

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

View file

@ -0,0 +1,6 @@
.root {
padding: 10px 40px;
}
.chart {
}

22
src/components/Icon.js Normal file
View 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
View 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;

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

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

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

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

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

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

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

View file

@ -0,0 +1,7 @@
.app {
display: flex;
background: #202020;
min-height: 640px;
height: 100vh;
}

55
src/components/Root.scss Normal file
View 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
View 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;

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

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

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

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

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

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

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

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

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

8491
yarn.lock Normal file

File diff suppressed because it is too large Load diff