commit 133b29c9dac2209a3c88c3289f84ff709d404392 Author: Haishan Date: Sat Oct 20 20:32:02 2018 +0800 first commit diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..61d1cd0 --- /dev/null +++ b/.babelrc @@ -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" + ] + } + } +} diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..545c610 --- /dev/null +++ b/.eslintrc.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ff72c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.*~ +.DS_Store +node_modules +public/ +deploy_ghpages.sh +tags +*.log diff --git a/assets/yacd-128.png b/assets/yacd-128.png new file mode 100644 index 0000000..4609a14 Binary files /dev/null and b/assets/yacd-128.png differ diff --git a/assets/yacd-64.png b/assets/yacd-64.png new file mode 100644 index 0000000..bbbcf65 Binary files /dev/null and b/assets/yacd-64.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e2da33 --- /dev/null +++ b/package.json @@ -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 (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" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..bedef66 --- /dev/null +++ b/server.js @@ -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'); +}); diff --git a/src/api/configs.js b/src/api/configs.js new file mode 100644 index 0000000..a9ae0e3 --- /dev/null +++ b/src/api/configs.js @@ -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) + }); +} diff --git a/src/api/logs.js b/src/api/logs.js new file mode 100644 index 0000000..9bcab58 --- /dev/null +++ b/src/api/logs.js @@ -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 }; diff --git a/src/api/proxies.js b/src/api/proxies.js new file mode 100644 index 0000000..1789f67 --- /dev/null +++ b/src/api/proxies.js @@ -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 }; diff --git a/src/api/traffic.js b/src/api/traffic.js new file mode 100644 index 0000000..53330a5 --- /dev/null +++ b/src/api/traffic.js @@ -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 }; diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..a0fb266 --- /dev/null +++ b/src/app.js @@ -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(, document.getElementById('app')); +}; + +render(Root, props); diff --git a/src/components/APIConfig.js b/src/components/APIConfig.js new file mode 100644 index 0000000..9e07f65 --- /dev/null +++ b/src/components/APIConfig.js @@ -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 ( +
+
RESTful API config for Clash
+
+ + +
+
+
+
+ ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(APIConfig); diff --git a/src/components/APIConfig.module.scss b/src/components/APIConfig.module.scss new file mode 100644 index 0000000..18527f0 --- /dev/null +++ b/src/components/APIConfig.module.scss @@ -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; +} diff --git a/src/components/APIDiscovery.js b/src/components/APIDiscovery.js new file mode 100644 index 0000000..4aa5fae --- /dev/null +++ b/src/components/APIDiscovery.js @@ -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 ( + closeModal('apiConfig')} + > + + + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(APIDiscovery); diff --git a/src/components/Button.js b/src/components/Button.js new file mode 100644 index 0000000..34fe5fe --- /dev/null +++ b/src/components/Button.js @@ -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 ( + + ); + } +} + +export default Button; diff --git a/src/components/Button.module.scss b/src/components/Button.module.scss new file mode 100644 index 0000000..22214d5 --- /dev/null +++ b/src/components/Button.module.scss @@ -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%); + } +} diff --git a/src/components/Config.js b/src/components/Config.js new file mode 100644 index 0000000..7f7ade4 --- /dev/null +++ b/src/components/Config.js @@ -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 ( +
+ +
+
+
HTTP Proxy Port
+ +
+ +
+
SOCKS5 Proxy Port
+ +
+ +
+
Redir Port
+ +
+ +
+
Allow LAN
+ +
+ +
+
Mode
+ +
+ +
+
Log Level
+ +
+
+
+ ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Config); diff --git a/src/components/Config.module.scss b/src/components/Config.module.scss new file mode 100644 index 0000000..b370678 --- /dev/null +++ b/src/components/Config.module.scss @@ -0,0 +1,16 @@ +.root { + padding: 10px 40px; + color: #ddd; + + // display: flex; + // flex-wrap: wrap; + + > div { + width: 340px; + } +} + +.label { + // color: #aaa; + padding: 16px 0; +} diff --git a/src/components/ContentHeader.js b/src/components/ContentHeader.js new file mode 100644 index 0000000..3e1370b --- /dev/null +++ b/src/components/ContentHeader.js @@ -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 ( +
+

{this.props.title}

+
+ ); + } +} + +export default ContentHeader; diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss new file mode 100644 index 0000000..caf572e --- /dev/null +++ b/src/components/ContentHeader.module.scss @@ -0,0 +1,12 @@ +.root { + height: 76px; + display: flex; + align-items: center; +} + +.h1 { + padding: 0 40px; + color: #ddd; + text-align: left; + margin: 0; +} diff --git a/src/components/Home.js b/src/components/Home.js new file mode 100644 index 0000000..584c1c6 --- /dev/null +++ b/src/components/Home.js @@ -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 ( +
+ +
+
+ +
+
+ +
+
+
+ ); + } +} + +export default Home; diff --git a/src/components/Home.module.scss b/src/components/Home.module.scss new file mode 100644 index 0000000..464f40f --- /dev/null +++ b/src/components/Home.module.scss @@ -0,0 +1,6 @@ +.root { + padding: 10px 40px; +} + +.chart { +} diff --git a/src/components/Icon.js b/src/components/Icon.js new file mode 100644 index 0000000..a9a6393 --- /dev/null +++ b/src/components/Icon.js @@ -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 ( + + + + ); +}; + +Icon.propTypes = { + id: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + className: PropTypes.string +}; + +export default Icon; diff --git a/src/components/Input.js b/src/components/Input.js new file mode 100644 index 0000000..1d66214 --- /dev/null +++ b/src/components/Input.js @@ -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 ( +
+ +
+ ); + } +} + +export default Input; diff --git a/src/components/Input.module.scss b/src/components/Input.module.scss new file mode 100644 index 0000000..147f99b --- /dev/null +++ b/src/components/Input.module.scss @@ -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; +} diff --git a/src/components/Loadable.js b/src/components/Loadable.js new file mode 100644 index 0000000..65843a1 --- /dev/null +++ b/src/components/Loadable.js @@ -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 }) =>
loading
; + +const Loadable = opts => + L({ + loading: Loading, + delay: 200, + ...opts + }); + +export default Loadable; diff --git a/src/components/Loading.js b/src/components/Loading.js new file mode 100644 index 0000000..1b8d373 --- /dev/null +++ b/src/components/Loading.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import style from './Loading.module.scss'; + +const Loading = () => { + return ( +
+
+
+
+ ); +}; + +export default Loading; diff --git a/src/components/Loading.module.scss b/src/components/Loading.module.scss new file mode 100644 index 0000000..7166140 --- /dev/null +++ b/src/components/Loading.module.scss @@ -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; +} diff --git a/src/components/Logs.js b/src/components/Logs.js new file mode 100644 index 0000000..3538979 --- /dev/null +++ b/src/components/Logs.js @@ -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 ( +
  • +
    +
    {time}
    +
    + {type} +
    +
    {payload}
    +
    +
  • + ); + } +} + +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 ( +
    + + {logs.length === 0 ? ( +
    +
    + +
    +
    No logs yet, hang tight...
    +
    + ) : ( +
    +
      + {logs.map(l => ( + + ))} +
    +
    + )} +
    + ); + } +} + +export default Logs; diff --git a/src/components/Logs.module.scss b/src/components/Logs.module.scss new file mode 100644 index 0000000..e587a00 --- /dev/null +++ b/src/components/Logs.module.scss @@ -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; + } +} diff --git a/src/components/Modal.js b/src/components/Modal.js new file mode 100644 index 0000000..2bb6c09 --- /dev/null +++ b/src/components/Modal.js @@ -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 ( + + {children} + + ); + } +} + +export default ModalAPIConfig; diff --git a/src/components/Modal.module.scss b/src/components/Modal.module.scss new file mode 100644 index 0000000..46feef0 --- /dev/null +++ b/src/components/Modal.module.scss @@ -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; +} diff --git a/src/components/Proxies.js b/src/components/Proxies.js new file mode 100644 index 0000000..1e675a8 --- /dev/null +++ b/src/components/Proxies.js @@ -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 ( +
    + + +
    +
    +
    +
    +
    Name
    +
    Type
    +
    All
    +
    + +
    + {Object.keys(proxies).map(k => { + const o = proxies[k]; + return ; + })} +
    +
    +
    + ); + } +} + +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 ( +
    +
    {name}
    +
    {type}
    +
    + {all && + all.map(p => { + return ( +
    + +
    + ); + })} +
    +
    + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Proxies); diff --git a/src/components/Proxies.module.scss b/src/components/Proxies.module.scss new file mode 100644 index 0000000..9652a1e --- /dev/null +++ b/src/components/Proxies.module.scss @@ -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; +} diff --git a/src/components/Proxy.js b/src/components/Proxy.js new file mode 100644 index 0000000..8e2aa5b --- /dev/null +++ b/src/components/Proxy.js @@ -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 ( + + ); + } +} + +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 ( +
    +
    {val}
    +
    ms
    +
    + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Proxy); diff --git a/src/components/Proxy.module.scss b/src/components/Proxy.module.scss new file mode 100644 index 0000000..735c348 --- /dev/null +++ b/src/components/Proxy.module.scss @@ -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; + } +} diff --git a/src/components/Root.js b/src/components/Root.js new file mode 100644 index 0000000..3946381 --- /dev/null +++ b/src/components/Root.js @@ -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 }) => ( + + +
    + + +
    + + + + + +
    +
    +
    +
    +); +// +// + +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); diff --git a/src/components/Root.module.scss b/src/components/Root.module.scss new file mode 100644 index 0000000..c21831d --- /dev/null +++ b/src/components/Root.module.scss @@ -0,0 +1,7 @@ +.app { + display: flex; + background: #202020; + + min-height: 640px; + height: 100vh; +} diff --git a/src/components/Root.scss b/src/components/Root.scss new file mode 100644 index 0000000..ac1b502 --- /dev/null +++ b/src/components/Root.scss @@ -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; +} diff --git a/src/components/SideBar.js b/src/components/SideBar.js new file mode 100644 index 0000000..5e2d8e6 --- /dev/null +++ b/src/components/SideBar.js @@ -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 ( + + +
    {labelText}
    + + ); + } +} + +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 ( +
    +
    + +
    + +
    + + + + +
    +
    + ); + } +} + +export default SideBar; diff --git a/src/components/SideBar.module.scss b/src/components/SideBar.module.scss new file mode 100644 index 0000000..249945a --- /dev/null +++ b/src/components/SideBar.module.scss @@ -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; +} diff --git a/src/components/StyleGuide.js b/src/components/StyleGuide.js new file mode 100644 index 0000000..f631d7d --- /dev/null +++ b/src/components/StyleGuide.js @@ -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 }) =>
    {children}
    ; + +class StyleGuide extends PureComponent { + render() { + return ( +
    + + + + + + + + {}} + /> + + +
    + ); + } +} + +export default StyleGuide; diff --git a/src/components/Switch.js b/src/components/Switch.js new file mode 100644 index 0000000..5a2119a --- /dev/null +++ b/src/components/Switch.js @@ -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 ( +
    + +
    + ); + } +} + +export default Switch; diff --git a/src/components/Switch.module.scss b/src/components/Switch.module.scss new file mode 100644 index 0000000..835de87 --- /dev/null +++ b/src/components/Switch.module.scss @@ -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; + } + } +} diff --git a/src/components/ToggleSwitch.js b/src/components/ToggleSwitch.js new file mode 100644 index 0000000..cdd4726 --- /dev/null +++ b/src/components/ToggleSwitch.js @@ -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 ( +
    +
    + {options.map((o, idx) => { + if (value === o.value) this.idx = idx; + const id = `${name}-${o.label}`; + let className = idx === 0 ? '' : 'border-left'; + return ( + + ); + })} + +
    +
    + ); + } +} + +export default ToggleSwitch; diff --git a/src/components/ToggleSwitch.module.scss b/src/components/ToggleSwitch.module.scss new file mode 100644 index 0000000..3e4bde4 --- /dev/null +++ b/src/components/ToggleSwitch.module.scss @@ -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; +} diff --git a/src/components/TrafficChart.js b/src/components/TrafficChart.js new file mode 100644 index 0000000..8335cdd --- /dev/null +++ b/src/components/TrafficChart.js @@ -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 ( +
    +
    + +
    +
    + ); + } +} + +export default TrafficChart; diff --git a/src/components/TrafficNow.js b/src/components/TrafficNow.js new file mode 100644 index 0000000..ed1e575 --- /dev/null +++ b/src/components/TrafficNow.js @@ -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 ( +
    +
    +
    Upload
    +
    {upStr}
    +
    +
    +
    Download
    +
    {downStr}
    +
    +
    + ); + } +} + +export default TrafficNow; diff --git a/src/components/TrafficNow.module.scss b/src/components/TrafficNow.module.scss new file mode 100644 index 0000000..2fe3348 --- /dev/null +++ b/src/components/TrafficNow.module.scss @@ -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; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..ed1698d --- /dev/null +++ b/src/config.js @@ -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 }; diff --git a/src/ducks/app.js b/src/ducks/app.js new file mode 100644 index 0000000..3ecbbfd --- /dev/null +++ b/src/ducks/app.js @@ -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; + } +} diff --git a/src/ducks/configs.js b/src/ducks/configs.js new file mode 100644 index 0000000..9ccd7bc --- /dev/null +++ b/src/ducks/configs.js @@ -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; + } +} diff --git a/src/ducks/index.js b/src/ducks/index.js new file mode 100644 index 0000000..96f6d25 --- /dev/null +++ b/src/ducks/index.js @@ -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 +}); diff --git a/src/ducks/modals.js b/src/ducks/modals.js new file mode 100644 index 0000000..bf229d4 --- /dev/null +++ b/src/ducks/modals.js @@ -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; + } +} diff --git a/src/ducks/proxies.js b/src/ducks/proxies.js new file mode 100644 index 0000000..840bb24 --- /dev/null +++ b/src/ducks/proxies.js @@ -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; + } +} diff --git a/src/index.template.ejs b/src/index.template.ejs new file mode 100644 index 0000000..672ee9d --- /dev/null +++ b/src/index.template.ejs @@ -0,0 +1,26 @@ + + + + + + + + + + + + <%= htmlWebpackPlugin.options.title %> + + <% for (key in htmlWebpackPlugin.files.css) { %> + + <% } %> + + +
    + + <% for (key in htmlWebpackPlugin.files.chunks) { %> + + <% } %> + + + diff --git a/src/misc/pretty-bytes.js b/src/misc/pretty-bytes.js new file mode 100644 index 0000000..8df6517 --- /dev/null +++ b/src/misc/pretty-bytes.js @@ -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; +}; diff --git a/src/misc/storage.js b/src/misc/storage.js new file mode 100644 index 0000000..ce22038 --- /dev/null +++ b/src/misc/storage.js @@ -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 }; diff --git a/src/store/configureStore.js b/src/store/configureStore.js new file mode 100644 index 0000000..0e4de53 --- /dev/null +++ b/src/store/configureStore.js @@ -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; +} diff --git a/src/svg/activity.svg b/src/svg/activity.svg new file mode 100644 index 0000000..94f3286 --- /dev/null +++ b/src/svg/activity.svg @@ -0,0 +1 @@ + diff --git a/src/svg/cpu.svg b/src/svg/cpu.svg new file mode 100644 index 0000000..2ed16ef --- /dev/null +++ b/src/svg/cpu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/svg/file.svg b/src/svg/file.svg new file mode 100644 index 0000000..f98a389 --- /dev/null +++ b/src/svg/file.svg @@ -0,0 +1 @@ + diff --git a/src/svg/github.svg b/src/svg/github.svg new file mode 100644 index 0000000..ff0af48 --- /dev/null +++ b/src/svg/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/svg/globe.svg b/src/svg/globe.svg new file mode 100644 index 0000000..19d520b --- /dev/null +++ b/src/svg/globe.svg @@ -0,0 +1 @@ + diff --git a/src/svg/heart.svg b/src/svg/heart.svg new file mode 100644 index 0000000..b5b136d --- /dev/null +++ b/src/svg/heart.svg @@ -0,0 +1 @@ + diff --git a/src/svg/settings.svg b/src/svg/settings.svg new file mode 100644 index 0000000..20666ea --- /dev/null +++ b/src/svg/settings.svg @@ -0,0 +1 @@ + diff --git a/src/svg/star.svg b/src/svg/star.svg new file mode 100644 index 0000000..bcdc31a --- /dev/null +++ b/src/svg/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/svg/x.svg b/src/svg/x.svg new file mode 100644 index 0000000..7d5875c --- /dev/null +++ b/src/svg/x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/svg/yacd.svg b/src/svg/yacd.svg new file mode 100644 index 0000000..b8be1ab --- /dev/null +++ b/src/svg/yacd.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/webpack.common.js b/webpack.common.js new file mode 100644 index 0000000..100b6bc --- /dev/null +++ b/webpack.common.js @@ -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; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..4fd6e63 --- /dev/null +++ b/webpack.config.js @@ -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 +}; diff --git a/webpack.dll.config.js b/webpack.dll.config.js new file mode 100644 index 0000000..ba62948 --- /dev/null +++ b/webpack.dll.config.js @@ -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