feat: support proxy provider

This commit is contained in:
Haishan 2019-12-20 17:45:05 +08:00
parent 040c5de04a
commit d81592ec97
22 changed files with 935 additions and 304 deletions

View file

@ -41,7 +41,9 @@
"classnames": "^2.2.6",
"core-js": "^3.4.5",
"date-fns": "^2.8.1",
"framer-motion": "^1.7.0",
"history": "^4.7.2",
"immer": "^5.0.1",
"invariant": "^2.2.4",
"lodash-es": "^4.17.14",
"memoize-one": "^5.1.1",
@ -97,6 +99,7 @@
"postcss-nested": "^4.2.0",
"prettier": "^1.17.1",
"react-hot-loader": "^4.12.18",
"resize-observer-polyfill": "^1.5.1",
"style-loader": "^1.0.1",
"svg-sprite-loader": "^4.1.2",
"terser-webpack-plugin": "^2.2.1",

View file

@ -18,13 +18,13 @@ Vary: Origin
Date: Tue, 16 Oct 2018 16:38:33 GMT
*/
async function fetchProxies(config) {
export async function fetchProxies(config) {
const { url, init } = getURLAndInit(config);
const res = await fetch(url + endpoint, init);
return await res.json();
}
async function requestToSwitchProxy(apiConfig, name1, name2) {
export async function requestToSwitchProxy(apiConfig, name1, name2) {
const body = { name: name2 };
const { url, init } = getURLAndInit(apiConfig);
const fullURL = `${url}${endpoint}/${name1}`;
@ -35,11 +35,27 @@ async function requestToSwitchProxy(apiConfig, name1, name2) {
});
}
async function requestDelayForProxy(apiConfig, name) {
export async function requestDelayForProxy(apiConfig, name) {
const { url, init } = getURLAndInit(apiConfig);
const qs = 'timeout=5000&url=http://www.google.com/generate_204';
const fullURL = `${url}${endpoint}/${name}/delay?${qs}`;
return await fetch(fullURL, init);
}
export { fetchProxies, requestToSwitchProxy, requestDelayForProxy };
export async function fetchProviderProxies(config) {
const { url, init } = getURLAndInit(config);
const res = await fetch(url + '/providers/proxies', init);
if (res.status === 404) {
return { providers: {} };
}
return await res.json();
}
export async function updateProviderByName(config, name) {
const { url, init } = getURLAndInit(config);
const options = {
...init,
method: 'PUT'
};
return await fetch(url + '/providers/proxies/' + name, options);
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import cx from 'classnames';
import s0 from 'c/Button.module.css';
const noop = () => {};
@ -13,6 +14,14 @@ function Button({ children, label, onClick = noop }, ref) {
);
}
export function ButtonPlain({ children, label, onClick = noop }) {
return (
<button className={cx(s0.btn, s0.plain)} onClick={onClick}>
{children || label}
</button>
);
}
function WithIcon({ text, icon, onClick = noop }, ref) {
return (
<button className={s0.btn} ref={ref} onClick={onClick}>

View file

@ -24,6 +24,22 @@
font-size: 1em;
padding: 6px 12px;
}
&.plain {
border-radius: 100%;
padding: 0;
display: flex;
border-color: transparent;
background: none;
&:focus {
border-color: var(--color-focus-blue);
}
&:hover {
background: #387cec;
border: 1px solid #387cec;
color: #fff;
}
}
}
.withIconWrapper {

View file

@ -1,63 +1,81 @@
import React from 'react';
import { useActions, useStoreState } from 'm/store';
import { useStoreState } from 'm/store';
import ContentHeader from 'c/ContentHeader';
import ProxyGroup from 'c/ProxyGroup';
import { ButtonWithIcon } from 'c/Button';
import { connect } from './StateProvider';
import ContentHeader from './ContentHeader';
import ProxyGroup from './ProxyGroup';
import { ButtonWithIcon } from './Button';
import { Zap } from 'react-feather';
import s0 from 'c/Proxies.module.css';
import ProxyProviderList from './ProxyProviderList';
import s0 from './Proxies.module.css';
import {
getProxies,
getDelay,
getProxyGroupNames,
getProxyProviders,
fetchProxies,
requestDelayAll
} from 'd/proxies';
} from '../store/proxies';
const { useEffect, useMemo } = React;
import { getClashAPIConfig } from '../ducks/app';
const { useEffect, useMemo, useCallback } = React;
const mapStateToProps = s => ({
proxies: getProxies(s),
groupNames: getProxyGroupNames(s)
apiConfig: getClashAPIConfig(s)
});
const actions = {
fetchProxies,
requestDelayAll
};
export default function Proxies() {
const { fetchProxies, requestDelayAll } = useActions(actions);
function Proxies({ dispatch, groupNames, proxies, delay, proxyProviders }) {
const { apiConfig } = useStoreState(mapStateToProps);
useEffect(() => {
(async () => {
await fetchProxies();
// await requestDelayAll();
})();
}, [fetchProxies, requestDelayAll]);
const { groupNames } = useStoreState(mapStateToProps);
dispatch(fetchProxies(apiConfig));
}, [dispatch, apiConfig]);
const requestDelayAllFn = useCallback(
() => dispatch(requestDelayAll(apiConfig)),
[apiConfig, dispatch]
);
const icon = useMemo(() => <Zap width={16} />, []);
return (
<>
<ContentHeader title="Proxies" />
<div className={s0.body}>
<div>
<div className="fabgrp">
<ButtonWithIcon
text="Test Latency"
icon={icon}
onClick={requestDelayAll}
onClick={requestDelayAllFn}
/>
{/* <Button onClick={requestDelayAll}>Test Latency</Button> */}
</div>
{groupNames.map(groupName => {
return (
<div className={s0.group} key={groupName}>
<ProxyGroup name={groupName} />
<ProxyGroup
name={groupName}
proxies={proxies}
delay={delay}
apiConfig={apiConfig}
dispatch={dispatch}
/>
</div>
);
})}
</div>
<ProxyProviderList items={proxyProviders} />
<div style={{ height: 60 }} />
</>
);
}
const mapState = s => ({
groupNames: getProxyGroupNames(s),
proxies: getProxies(s),
proxyProviders: getProxyProviders(s),
delay: getDelay(s)
});
export default connect(mapState)(Proxies);

View file

@ -1,7 +1,3 @@
.body {
padding-bottom: 50px;
}
.group {
padding: 10px 15px;
@media (--breakpoint-not-small) {

View file

@ -1,13 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { useStoreState } from 'm/store';
import ProxyLatency from 'c/ProxyLatency';
import { connect } from './StateProvider';
import ProxyLatency from './ProxyLatency';
import { getProxies, getDelay } from '../store/proxies';
import s0 from './Proxy.module.css';
import { getDelay, getProxies } from 'd/proxies';
const { useMemo } = React;
const colorMap = {
// green
good: '#67c23a',
// yellow
normal: '#d4b75c',
// orange
bad: '#e67f3c',
// bad: '#F56C6C',
na: '#909399'
};
function getLabelColor({ number, error } = {}) {
if (number < 200) {
return colorMap.good;
} else if (number < 400) {
return colorMap.normal;
} else if (typeof number === 'number') {
return colorMap.bad;
}
return colorMap.na;
}
/*
const colors = {
@ -22,18 +45,28 @@ const colors = {
};
*/
const mapStateToProps = s => {
return {
proxies: getProxies(s),
delay: getDelay(s)
};
type ProxyProps = {
name: string,
now?: boolean,
// connect injected
// TODO refine type
proxy: any,
latency: any
};
function Proxy({ now, name }) {
const { proxies, delay } = useStoreState(mapStateToProps);
const latency = delay[name];
const proxy = proxies[name];
function ProxySmallImpl({ now, name, proxy, latency }: ProxyProps) {
const color = useMemo(() => getLabelColor(latency), [latency]);
return (
<div
className={cx(s0.proxySmall, { [s0.now]: now })}
style={{ backgroundColor: color }}
/>
);
}
function Proxy({ now, name, proxy, latency }: ProxyProps) {
const color = useMemo(() => getLabelColor(latency), [latency]);
return (
<div
className={cx(s0.proxy, {
@ -46,14 +79,22 @@ function Proxy({ now, name }) {
{proxy.type}
</div>
<div className={s0.proxyLatencyWrap}>
{latency && latency.number ? <ProxyLatency latency={latency} /> : null}
{latency && latency.number ? (
<ProxyLatency number={latency.number} color={color} />
) : null}
</div>
</div>
);
}
Proxy.propTypes = {
now: PropTypes.bool,
name: PropTypes.string
const mapState = (s, { name }) => {
const proxies = getProxies(s);
const delay = getDelay(s);
return {
proxy: proxies[name],
latency: delay[name]
};
};
export default Proxy;
export default connect(mapState)(Proxy);
export const ProxySmall = connect(mapState)(ProxySmallImpl);

View file

@ -2,10 +2,15 @@
position: relative;
padding: 5px;
border-radius: 8px;
overflow: hidden;
max-width: 280px;
@media (--breakpoint-not-small) {
min-width: 150px;
border-radius: 10px;
padding: 10px;
}
background-color: var(--color-bg-proxy-selected);
&.now {
background-color: var(--color-focus-blue);
@ -40,3 +45,12 @@
display: flex;
align-items: flex-end;
}
.proxySmall {
.now {
outline: pink solid 1px;
}
width: 12px;
height: 12px;
border-radius: 8px;
}

View file

@ -1,60 +1,108 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { useActions, useStoreState } from 'm/store';
import Proxy from 'c/Proxy';
import Proxy, { ProxySmall } from './Proxy';
import { SectionNameType } from './shared/Basic';
import s0 from './ProxyGroup.module.css';
import { getProxies, switchProxy } from 'd/proxies';
import { switchProxy } from '../store/proxies';
const mapStateToProps = s => ({
proxies: getProxies(s)
});
const { memo, useCallback, useMemo } = React;
export default function ProxyGroup({ name }) {
const { proxies } = useStoreState(mapStateToProps);
const actions = useActions({ switchProxy });
function ProxyGroup({ name, proxies, apiConfig, dispatch }) {
const group = proxies[name];
const { all } = group;
const { all, type, now } = group;
const isSelectable = useMemo(() => type === 'Selector', [type]);
const itemOnTapCallback = useCallback(
proxyName => {
if (!isSelectable) return;
dispatch(switchProxy(apiConfig, name, proxyName));
// switchProxyFn(name, proxyName);
},
[apiConfig, dispatch, name, isSelectable]
);
return (
<div className={s0.group}>
<div className={s0.header}>
<h2>
<span>{name}</span>
<span>{group.type}</span>
</h2>
</div>
<div className={s0.list}>
{all.map(proxyName => {
const isSelectable = group.type === 'Selector';
const proxyClassName = cx(s0.proxy, {
[s0.proxySelectable]: isSelectable
});
return (
<div
className={proxyClassName}
key={proxyName}
onClick={() => {
if (!isSelectable) return;
actions.switchProxy(name, proxyName);
}}
>
<Proxy
isSelectable={isSelectable}
name={proxyName}
now={proxyName === group.now}
/>
</div>
);
})}
<SectionNameType name={name} type={group.type} />
</div>
<ProxyList
all={all}
now={now}
isSelectable={isSelectable}
itemOnTapCallback={itemOnTapCallback}
/>
</div>
);
}
ProxyGroup.propTypes = {
name: PropTypes.string
type ProxyListProps = {
all: string[],
now?: string,
isSelectable?: boolean,
itemOnTapCallback?: string => void
};
export function ProxyList({
all,
now,
isSelectable,
itemOnTapCallback
}: ProxyListProps) {
return (
<div className={s0.list}>
{all.map(proxyName => {
const proxyClassName = cx(s0.proxy, {
[s0.proxySelectable]: isSelectable
});
return (
<div
className={proxyClassName}
key={proxyName}
onClick={() => {
if (!isSelectable || !itemOnTapCallback) return;
itemOnTapCallback(proxyName);
}}
>
<Proxy name={proxyName} now={proxyName === now} />
</div>
);
})}
</div>
);
}
export function ProxyListSummaryView({
all,
now,
isSelectable,
itemOnTapCallback
}: ProxyListProps) {
return (
<div className={s0.list}>
{all.map(proxyName => {
const proxyClassName = cx(s0.proxy, {
[s0.proxySelectable]: isSelectable
});
return (
<div
className={proxyClassName}
key={proxyName}
onClick={() => {
if (!isSelectable || !itemOnTapCallback) return;
itemOnTapCallback(proxyName);
}}
>
<ProxySmall name={proxyName} now={proxyName === now} />
</div>
);
})}
</div>
);
}
export default memo(ProxyGroup);

View file

@ -1,32 +1,19 @@
.header {
> h2 {
margin-top: 0;
font-size: 1.3em;
@media (--breakpoint-not-small) {
font-size: 1.5em;
}
span:nth-child(2) {
font-size: 12px;
color: #777;
font-weight: normal;
margin: 0 0.3em;
}
}
margin-bottom: 12px;
}
.list {
display: flex;
flex-wrap: wrap;
margin-top: 8px;
}
.proxy {
max-width: 280px;
margin: 2px;
margin-right: 5px;
margin-bottom: 5px;
@media (--breakpoint-not-small) {
min-width: 150px;
margin: 10px;
margin-right: 10px;
margin-bottom: 10px;
}
transition: transform 0.2s ease-in-out;

View file

@ -1,39 +1,16 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import React from 'react';
import s0 from './ProxyLatency.module.css';
const colorMap = {
good: '#67C23A',
normal: '#E6A23C',
bad: '#F56C6C',
na: '#909399'
type ProxyLatencyProps = {
number: number,
color: string
};
function getLabelColor(number, error) {
if (error !== '') {
return colorMap.na;
} else if (number < 200) {
return colorMap.good;
} else if (number < 400) {
return colorMap.normal;
}
return colorMap.bad;
}
export default function ProxyLatency({ latency }) {
const { number, error } = latency;
const color = useMemo(() => getLabelColor(number, error), [number, error]);
export default function ProxyLatency({ number, color }: ProxyLatencyProps) {
return (
<span className={s0.proxyLatency} style={{ color }}>
{error !== '' ? <span>{error}</span> : <span>{number} ms</span>}
<span>{number} ms</span>
</span>
);
}
ProxyLatency.propTypes = {
latency: PropTypes.shape({
number: PropTypes.number,
error: PropTypes.string
})
};

View file

@ -0,0 +1,211 @@
import React from 'react';
import { ChevronDown, RotateCw } from 'react-feather';
import { formatDistance } from 'date-fns';
import ResizeObserver from 'resize-observer-polyfill';
import { motion } from 'framer-motion';
import cx from 'classnames';
import { useStoreState } from '../misc/store';
import { getClashAPIConfig } from '../ducks/app';
import { connect } from './StateProvider';
import { SectionNameType } from './shared/Basic';
import { ProxyList, ProxyListSummaryView } from './ProxyGroup';
import { ButtonWithIcon, ButtonPlain } from './Button';
import { updateProviderByName } from '../store/proxies';
import s from './ProxyProvider.module.css';
const { memo, useState, useRef, useEffect, useCallback } = React;
type Props = {
item: Array<{
name: string,
proxies: Array<string>,
type: 'Proxy' | 'Rule',
vehicleType: 'HTTP' | 'File' | 'Compatible',
updatedAt?: string
}>,
proxies: {
[string]: any
},
dispatch: any => void
};
const mapStateToProps = s => ({
apiConfig: getClashAPIConfig(s)
});
function ProxyProvider({ item, dispatch }: Props) {
const { apiConfig } = useStoreState(mapStateToProps);
const updateProvider = useCallback(
() => dispatch(updateProviderByName(apiConfig, item.name)),
[apiConfig, dispatch, item.name]
);
const [isCollapsibleOpen, setCollapsibleOpen] = useState(false);
const toggle = useCallback(() => setCollapsibleOpen(x => !x), []);
const timeAgo = formatDistance(new Date(item.updatedAt), new Date());
return (
<div className={s.body}>
<div className={s.header} onClick={toggle}>
<SectionNameType name={item.name} type={item.vehicleType} />
<ButtonPlain>
<span className={cx(s.arrow, { [s.isOpen]: isCollapsibleOpen })}>
<ChevronDown />
</span>
</ButtonPlain>
</div>
<div className={s.updatedAt}>
<small>Updated {timeAgo} ago</small>
</div>
<Collapsible2 isOpen={isCollapsibleOpen}>
<ProxyList all={item.proxies} />
<div className={s.actionFooter}>
<ButtonWithIcon
text="Update"
icon={<Refresh />}
onClick={updateProvider}
/>
</div>
</Collapsible2>
<Collapsible2 isOpen={!isCollapsibleOpen}>
<ProxyListSummaryView all={item.proxies} />
</Collapsible2>
</div>
);
}
const button = {
rest: { scale: 1 },
// hover: { scale: 1.1 },
pressed: { scale: 0.95 }
};
const arrow = {
rest: { rotate: 0 },
hover: { rotate: 360, transition: { duration: 0.3 } }
};
function Refresh() {
return (
<motion.div
className={s.refresh}
variants={button}
initial="rest"
whileHover="hover"
whileTap="pressed"
>
<motion.div className="flexCenter" variants={arrow}>
<RotateCw size={16} />
</motion.div>
</motion.div>
);
}
function usePrevious(value) {
const ref = useRef();
useEffect(() => void (ref.current = value), [value]);
return ref.current;
}
function useMeasure() {
const ref = useRef();
const [bounds, set] = useState({ height: 0 });
useEffect(() => {
const ro = new ResizeObserver(([entry]) => set(entry.contentRect));
if (ref.current) ro.observe(ref.current);
return () => ro.disconnect();
}, []);
return [ref, bounds];
}
// import { useSpring, a } from 'react-spring';
// const Collapsible = memo(({ children, isOpen }) => {
// const previous = usePrevious(isOpen);
// const [refToMeature, { height: viewHeight }] = useMeasure();
// const { height, opacity, visibility, transform } = useSpring({
// from: {
// height: 0,
// opacity: 0,
// transform: 'translate3d(20px,0,0)',
// visibility: 'hidden'
// },
// to: {
// height: isOpen ? viewHeight : 0,
// opacity: isOpen ? 1 : 0,
// visibility: isOpen ? 'visible' : 'hidden',
// transform: `translate3d(${isOpen ? 0 : 20}px,0,0)`
// }
// });
// return (
// <div>
// <a.div
// style={{
// opacity,
// willChange: 'transform, opacity, height, visibility',
// visibility,
// height: isOpen && previous === isOpen ? 'auto' : height
// }}>
// <a.div style={{ transform }} ref={refToMeature} children={children} />
// </a.div>
// </div>
// );
// });
const variantsCollpapsibleWrap = {
initialOpen: {
height: 'auto',
transition: { duration: 0 }
},
open: height => ({
height,
opacity: 1,
visibility: 'visible',
transition: { duration: 0.3 }
}),
closed: {
height: 0,
opacity: 0,
visibility: 'hidden',
transition: { duration: 0.3 }
}
};
const variantsCollpapsibleChildContainer = {
open: {
x: 0
},
closed: {
x: 20
}
};
const Collapsible2 = memo(({ children, isOpen }) => {
const previous = usePrevious(isOpen);
const [refToMeature, { height }] = useMeasure();
return (
<div>
<motion.div
animate={
isOpen && previous === isOpen
? 'initialOpen'
: isOpen
? 'open'
: 'closed'
}
custom={height}
variants={variantsCollpapsibleWrap}
>
<motion.div
variants={variantsCollpapsibleChildContainer}
ref={refToMeature}
>
{children}
</motion.div>
</motion.div>
</div>
);
});
const mapState = s => ({
// proxies: getProxies(s)
});
export default connect(mapState)(ProxyProvider);

View file

@ -0,0 +1,43 @@
.header {
display: flex;
align-items: center;
cursor: pointer;
.arrow {
display: inline-flex;
transform: rotate(0deg);
transition: transform 0.3s;
&.isOpen {
transform: rotate(180deg);
}
&:focus {
outline: var(--color-focus-blue) solid 1px;
}
}
}
.updatedAt {
margin-bottom: 12px;
small {
color: #777;
}
}
.body {
padding: 10px 15px;
@media (--breakpoint-not-small) {
padding: 10px 40px;
}
}
.actionFooter {
display: flex;
}
.refresh {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import ContentHeader from './ContentHeader';
import ProxyProvider from './ProxyProvider';
function ProxyProviderList({ items }) {
return (
<>
<ContentHeader title="Proxy Provider" />
<div>
{items.map(item => (
<ProxyProvider key={item.name} item={item} />
))}
</div>
</>
);
}
export default ProxyProviderList;

View file

@ -10,6 +10,13 @@
U+FEFF, U+FFFD;
}
.relative {
position: relative;
}
/* .absolute { */
/* position: absolute; */
/* } */
.border-left,
.border-top,
.border-bottom {
@ -113,6 +120,12 @@ body.light {
--bg-modal: #fbfbfb;
}
.flexCenter {
display: flex;
align-items: center;
justify-content: center;
}
/* TODO remove fabgrp in component css files */
.fabgrp {
position: fixed;

View file

@ -1,5 +1,6 @@
import React, { Suspense } from 'react';
import { Provider } from 'm/store';
import { Provider } from '../misc/store';
import StateProvider from './StateProvider';
import { HashRouter as Router, Route } from 'react-router-dom';
import { hot } from 'react-hot-loader/root';
import Loading2 from 'c/Loading2';
@ -36,30 +37,38 @@ const Rules = React.lazy(() =>
window.store = store;
const initialState = {
proxies: {
proxies: {},
delay: {},
groupNames: []
}
};
const Root = () => (
<ErrorBoundary>
<Provider store={store}>
<Router>
<div className={s0.app}>
<APIDiscovery />
<Route path="/" render={props => <SideBar {...props} />} />
<div className={s0.content}>
<Suspense fallback={<Loading2 />}>
<Route exact path="/" render={() => <Home />} />
<Route exact path="/connections" component={Connections} />
<Route exact path="/overview" render={() => <Home />} />
<Route exact path="/configs" component={Config} />
<Route exact path="/logs" component={Logs} />
<Route exact path="/proxies" render={() => <Proxies />} />
<Route exact path="/rules" render={() => <Rules />} />
</Suspense>
<StateProvider initialState={initialState}>
<Provider store={store}>
<Router>
<div className={s0.app}>
<APIDiscovery />
<Route path="/" render={props => <SideBar {...props} />} />
<div className={s0.content}>
<Suspense fallback={<Loading2 />}>
<Route exact path="/" render={() => <Home />} />
<Route exact path="/connections" component={Connections} />
<Route exact path="/overview" render={() => <Home />} />
<Route exact path="/configs" component={Config} />
<Route exact path="/logs" component={Logs} />
<Route exact path="/proxies" render={() => <Proxies />} />
<Route exact path="/rules" render={() => <Rules />} />
</Suspense>
</div>
</div>
</div>
</Router>
</Provider>
</Router>
</Provider>
</StateProvider>
</ErrorBoundary>
);
// <Route exact path="/__0" render={() => <StyleGuide />} />
// <Route exact path="/__1" component={Loading} />
export default hot(Root);

View file

@ -0,0 +1,79 @@
import React from 'react';
import produce, * as immer from 'immer';
const {
createContext,
memo,
useRef,
useEffect,
useCallback,
useContext,
useState
} = React;
const StateContext = createContext(null);
const DispatchContext = createContext(null);
export { immer };
export function useStoreState() {
return useContext(StateContext);
}
export function useStoreDispatch() {
return useContext(DispatchContext);
}
export default function Provider({ initialState, children }) {
const stateRef = useRef(initialState);
const [state, setState] = useState(initialState);
const getState = useCallback(() => stateRef.current, []);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
window.getState2 = getState;
}
}, [getState]);
const dispatch = useCallback(
(actionId, fn, thunk) => {
// if (thunk) return thunk(dispatch, getState);
if (typeof actionId === 'function') return actionId(dispatch, getState);
const stateNext = produce(getState(), fn);
if (stateNext !== stateRef.current) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.log(actionId, stateNext);
}
stateRef.current = stateNext;
setState(stateNext);
}
},
[getState]
);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
export function connect(mapStateToProps) {
return Component => {
const MemoComponent = memo(Component);
function Connected(props) {
const state = useContext(StateContext);
const dispatch = useContext(DispatchContext);
const mapped = mapStateToProps(state, props);
const nextProps = {
...props,
...mapped,
dispatch
};
return <MemoComponent {...nextProps} />;
}
return Connected;
};
}

View file

@ -0,0 +1,12 @@
import React from 'react';
import s from './Basic.module.css';
export function SectionNameType({ name, type }) {
return (
<h2 className={s.sectionNameType}>
<span>{name}</span>
<span>{type}</span>
</h2>
);
}

View file

@ -0,0 +1,14 @@
h2.sectionNameType {
margin: 0;
font-size: 1.3em;
@media (--breakpoint-not-small) {
font-size: 1.5em;
}
span:nth-child(2) {
font-size: 12px;
color: #777;
font-weight: normal;
margin: 0 0.3em;
}
}

View file

@ -1,7 +1,6 @@
import { combineReducers } from 'redux';
import app from './app';
import modals from './modals';
import proxies from './proxies';
import rules from './rules';
import logs from './logs';
import configs from './configs';
@ -9,7 +8,6 @@ import configs from './configs';
export default combineReducers({
app,
modals,
proxies,
rules,
logs,
configs

View file

@ -1,5 +1,4 @@
import * as proxiesAPI from 'a/proxies';
import { getClashAPIConfig } from 'd/app';
import * as proxiesAPI from '../api/proxies';
// see all types:
// https://github.com/Dreamacro/clash/blob/master/constant/adapters.go
@ -12,10 +11,136 @@ const ProxyTypes = ['Shadowsocks', 'Snell', 'Socks5', 'Http', 'Vmess'];
export const getProxies = s => s.proxies.proxies;
export const getDelay = s => s.proxies.delay;
export const getProxyGroupNames = s => s.proxies.groupNames;
export const getProxyProviders = s => s.proxies.proxyProviders || [];
const CompletedFetchProxies = 'proxies/CompletedFetchProxies';
const OptimisticSwitchProxy = 'proxies/OptimisticSwitchProxy';
const CompletedRequestDelayForProxy = 'proxies/CompletedRequestDelayForProxy';
export function fetchProxies(apiConfig) {
return async (dispatch, getState) => {
const [proxiesData, providersData] = await Promise.all([
proxiesAPI.fetchProxies(apiConfig),
proxiesAPI.fetchProviderProxies(apiConfig)
]);
const [proxyProviders, providerProxies] = formatProxyProviders(
providersData.providers
);
const proxies = { ...providerProxies, ...proxiesData.proxies };
const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies);
const delayPrev = getDelay(getState());
const delayNext = { ...delayPrev };
for (let i = 0; i < proxyNames.length; i++) {
const name = proxyNames[i];
const { history } = proxies[name] || { history: [] };
const h = history[history.length - 1];
if (h) {
const ret = { error: '' };
if (h.delay === 0) {
ret.error = 'LikelyTimeout';
} else {
ret.number = h.delay;
}
delayNext[name] = ret;
}
}
dispatch('store/proxies#fetchProxies', s => {
s.proxies.proxies = proxies;
s.proxies.groupNames = groupNames;
s.proxies.delay = delayNext;
s.proxies.proxyProviders = proxyProviders;
});
};
}
export function updateProviderByName(apiConfig, name) {
return async dispatch => {
try {
await proxiesAPI.updateProviderByName(apiConfig, name);
} catch (x) {
// ignore
}
// should be optimized
// but ¯\_(ツ)_/¯
dispatch(fetchProxies(apiConfig));
};
}
export function switchProxy(apiConfig, name1, name2) {
return async dispatch => {
proxiesAPI
.requestToSwitchProxy(apiConfig, name1, name2)
.then(
res => {
if (res.ok === false) {
// eslint-disable-next-line no-console
console.log('failed to swith proxy', res.statusText);
}
},
err => {
// eslint-disable-next-line no-console
console.log(err, 'failed to swith proxy');
}
)
.then(() => {
dispatch(fetchProxies(apiConfig));
});
// optimistic UI update
dispatch('store/proxies#switchProxy', s => {
const proxies = s.proxies.proxies;
if (proxies[name1] && proxies[name1].now) {
proxies[name1].now = name2;
}
});
};
}
function requestDelayForProxyOnce(apiConfig, name) {
return async (dispatch, getState) => {
const res = await proxiesAPI.requestDelayForProxy(apiConfig, name);
let error = '';
if (res.ok === false) {
error = res.statusText;
}
const { delay } = await res.json();
const delayPrev = getDelay(getState());
const delayNext = {
...delayPrev,
[name]: {
error,
number: delay
}
};
dispatch('requestDelayForProxyOnce', s => {
s.proxies.delay = delayNext;
});
};
}
export function requestDelayForProxy(apiConfig, name) {
return async dispatch => {
await dispatch(requestDelayForProxyOnce(apiConfig, name));
};
}
export function requestDelayAll(apiConfig) {
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(apiConfig, p)))
);
};
}
function retrieveGroupNamesFrom(proxies) {
let groupNames = [];
@ -44,141 +169,26 @@ function retrieveGroupNamesFrom(proxies) {
return [groupNames, proxyNames];
}
export function fetchProxies() {
return async (dispatch, getState) => {
// TODO handle errors
const state = getState();
const apiConfig = getClashAPIConfig(state);
// TODO show loading animation?
const json = await proxiesAPI.fetchProxies(apiConfig);
let { proxies = {} } = json;
const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies);
const delayPrev = getDelay(getState());
const delayNext = { ...delayPrev };
for (let i = 0; i < proxyNames.length; i++) {
const name = proxyNames[i];
const { history } = proxies[name] || { history: [] };
const h = history[history.length - 1];
if (h) {
const ret = { error: '' };
if (h.delay === 0) {
ret.error = 'LikelyTimeout';
} else {
ret.number = h.delay;
}
delayNext[name] = ret;
}
function formatProxyProviders(providersInput) {
const keys = Object.keys(providersInput);
const providers = [];
const proxies = {};
for (let i = 0; i < keys.length; i++) {
const provider = providersInput[keys[i]];
if (provider.name === 'default' || provider.vehicleType === 'Compatible')
continue;
const proxiesArr = provider.proxies;
const names = [];
for (let j = 0; j < proxiesArr.length; j++) {
const proxy = proxiesArr[j];
proxies[proxy.name] = proxy;
names.push(proxy.name);
}
dispatch({
type: CompletedFetchProxies,
payload: { proxies, groupNames, delay: delayNext }
});
};
}
export function switchProxy(name1, name2) {
return async (dispatch, getState) => {
const apiConfig = getClashAPIConfig(getState());
// TODO display error message
proxiesAPI
.requestToSwitchProxy(apiConfig, name1, name2)
.then(
res => {
if (res.ok === false) {
// eslint-disable-next-line no-console
console.log('failed to swith proxy', res.statusText);
}
},
err => {
// eslint-disable-next-line no-console
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 apiConfig = getClashAPIConfig(getState());
const res = await proxiesAPI.requestDelayForProxy(apiConfig, name);
let error = '';
if (res.ok === false) {
error = res.statusText;
}
const { delay } = await res.json();
const delayPrev = getDelay(getState());
const delayNext = {
...delayPrev,
[name]: {
error,
number: delay
}
};
dispatch({
type: CompletedRequestDelayForProxy,
payload: { delay: delayNext }
});
};
}
export function requestDelayForProxy(name) {
return async dispatch => {
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: {},
groupNames: []
};
export default function reducer(state = initialState, { type, payload }) {
switch (type) {
case CompletedRequestDelayForProxy:
case OptimisticSwitchProxy:
case CompletedFetchProxies: {
return { ...state, ...payload };
}
default:
return state;
// mutate directly
provider.proxies = names;
providers.push(provider);
}
return [providers, proxies];
}

100
yarn.lock
View file

@ -875,6 +875,18 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@emotion/is-prop-valid@^0.8.2":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.5.tgz#2dda0791f0eafa12b7a0a5b39858405cc7bde983"
integrity sha512-6ZODuZSFofbxSbcxwsFz+6ioPjb0ISJRRPLZ+WIbjcU2IMU0Io+RGQjjaTgOvNQl007KICBm7zXQaYQEC1r6Bg==
dependencies:
"@emotion/memoize" "0.7.3"
"@emotion/memoize@0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.3.tgz#5b6b1c11d6a6dddf1f2fc996f74cf3b219644d78"
integrity sha512-2Md9mH6mvo+ygq1trTeVp2uzAKwE2P7In0cRpD/M9Q70aH8L+rxMLbb3JCN2JoSWsV2O+DdFjfbbXoMoLBczow==
"@hot-loader/react-dom@16.10.2":
version "16.10.2"
resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.10.2.tgz#91920442252acac6f343eef5df41aca333f7dcea"
@ -911,6 +923,22 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
"@popmotion/easing@^1.0.1", "@popmotion/easing@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@popmotion/easing/-/easing-1.0.2.tgz#17d925c45b4bf44189e5a38038d149df42d8c0b4"
integrity sha512-IkdW0TNmRnWTeWI7aGQIVDbKXPWHVEYdGgd5ZR4SH/Ty/61p63jCjrPxX1XrR7IGkl08bjhJROStD7j+RKgoIw==
"@popmotion/popcorn@^0.4.2", "@popmotion/popcorn@^0.4.4":
version "0.4.4"
resolved "https://registry.yarnpkg.com/@popmotion/popcorn/-/popcorn-0.4.4.tgz#a5f906fccdff84526e3fcb892712d7d8a98d6adc"
integrity sha512-jYO/8319fKoNLMlY4ZJPiPu8Ea8occYwRZhxpaNn/kZsK4QG2E7XFlXZMJBsTWDw7I1i0uaqyC4zn1nwEezLzg==
dependencies:
"@popmotion/easing" "^1.0.1"
framesync "^4.0.1"
hey-listen "^1.0.8"
style-value-types "^3.1.7"
tslib "^1.10.0"
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@ -3522,6 +3550,30 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
framer-motion@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-1.7.0.tgz#36d038874c8e2c45ad6e4824df8a61508bacbda0"
integrity sha512-bE1aWkX0AKgmxMjFnAcXZ1d47TcuKugLn3D5cHvZcQwBiDPJuKD1WzwdTaDb7EHc+vbiWfBgMbECWxQFb9di5g==
dependencies:
"@popmotion/easing" "^1.0.2"
"@popmotion/popcorn" "^0.4.2"
framesync "^4.0.4"
hey-listen "^1.0.8"
popmotion "9.0.0-beta-8"
style-value-types "^3.1.6"
stylefire "^7.0.0"
tslib "^1.10.0"
optionalDependencies:
"@emotion/is-prop-valid" "^0.8.2"
framesync@^4.0.0, framesync@^4.0.1, framesync@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/framesync/-/framesync-4.0.4.tgz#79c42c0118f26821c078570db0ff81fb863516a2"
integrity sha512-mdP0WvVHe0/qA62KG2LFUAOiWLng5GLpscRlwzBxu2VXOp6B8hNs5C5XlFigsMgrfDrr2YbqTsgdWZTc4RXRMQ==
dependencies:
hey-listen "^1.0.8"
tslib "^1.10.0"
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
@ -3871,6 +3923,11 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
hey-listen@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
history@^4.7.2, history@^4.9.0:
version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
@ -4069,6 +4126,11 @@ image-size@^0.5.1:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=
immer@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/immer/-/immer-5.0.1.tgz#1a1184fa758f68f1b5573db840825fb5164cceca"
integrity sha512-KFHV1ivrBmPCVRhjy9oBooypnPfJ876NTrWXMNoUhXFAaWWAViVqZ4l6HxPST52qcN82qqsR38/pCGYRWP5W7w==
import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@ -5873,6 +5935,18 @@ please-upgrade-node@^3.1.1, please-upgrade-node@^3.2.0:
dependencies:
semver-compare "^1.0.0"
popmotion@9.0.0-beta-8:
version "9.0.0-beta-8"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.0.0-beta-8.tgz#f5a709f11737734e84f2a6b73f9bcf25ee30c388"
integrity sha512-6eQzqursPvnP7ePvdfPeY4wFHmS3OLzNP8rJRvmfFfEIfpFqrQgLsM50Gd9AOvGKJtYJOFknNG+dsnzCpgIdAA==
dependencies:
"@popmotion/easing" "^1.0.1"
"@popmotion/popcorn" "^0.4.2"
framesync "^4.0.4"
hey-listen "^1.0.8"
style-value-types "^3.1.6"
tslib "^1.10.0"
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -6808,6 +6882,11 @@ reselect@^4.0.0:
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7"
integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-cwd@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
@ -7488,6 +7567,25 @@ style-loader@^1.0.1:
loader-utils "^1.2.3"
schema-utils "^2.0.1"
style-value-types@^3.1.6, style-value-types@^3.1.7:
version "3.1.7"
resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-3.1.7.tgz#3d7d3cf9cb9f9ee86c00e19ba65d6a181a0db33a"
integrity sha512-jPaG5HcAPs3vetSwOJozrBXxuHo9tjZVnbRyBjxqb00c2saIoeuBJc1/2MtvB8eRZy41u/BBDH0CpfzWixftKg==
dependencies:
hey-listen "^1.0.8"
tslib "^1.10.0"
stylefire@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/stylefire/-/stylefire-7.0.1.tgz#69777dc9ffc48248284ea5c0a3fe03ba5c468339"
integrity sha512-xp7hGiK5xyX7xINQI7qWagcLMyS6aLLbP2kMA3DTS4X31uPI3M7IyOm9TPmXwaMKG9fJ2Cgo4F2okcNB6nzxKQ==
dependencies:
"@popmotion/popcorn" "^0.4.4"
framesync "^4.0.0"
hey-listen "^1.0.8"
style-value-types "^3.1.7"
tslib "^1.10.0"
stylehacks@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5"
@ -7779,7 +7877,7 @@ tryer@^1.0.1:
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==