diff options
| author | Haishan <[email protected]> | 2020-07-01 22:06:26 +0800 |
|---|---|---|
| committer | Haishan <[email protected]> | 2020-07-04 17:58:56 +0800 |
| commit | 32bed273c83f0593187110d2b08a0f9ec5a7efd7 (patch) | |
| tree | 0b47da752de3ee0d87945c1122b2cf9d3bf8043f /src | |
| parent | 55e928a87f561ab927774834b50e099a0758522d (diff) | |
feat: support rule provider
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/rule-provider.ts | 74 | ||||
| -rw-r--r-- | src/api/rules.js | 8 | ||||
| -rw-r--r-- | src/api/rules.ts | 41 | ||||
| -rw-r--r-- | src/components/Button.module.css | 4 | ||||
| -rw-r--r-- | src/components/Button.tsx | 9 | ||||
| -rw-r--r-- | src/components/RuleSearch.js | 6 | ||||
| -rw-r--r-- | src/components/Rules.js | 115 | ||||
| -rw-r--r-- | src/components/Rules.module.css | 22 | ||||
| -rw-r--r-- | src/components/StateProvider.js | 5 | ||||
| -rw-r--r-- | src/components/proxies/TextFilter.tsx | 21 | ||||
| -rw-r--r-- | src/components/rules/RuleProviderItem.module.css | 43 | ||||
| -rw-r--r-- | src/components/rules/RuleProviderItem.tsx | 71 | ||||
| -rw-r--r-- | src/components/rules/TextFilter.tsx | 18 | ||||
| -rw-r--r-- | src/hooks/useTextInput.ts | 23 | ||||
| -rw-r--r-- | src/store/index.js | 2 | ||||
| -rw-r--r-- | src/store/logs.js | 16 | ||||
| -rw-r--r-- | src/store/rules.js | 62 | ||||
| -rw-r--r-- | src/types.ts | 5 |
18 files changed, 420 insertions, 125 deletions
diff --git a/src/api/rule-provider.ts b/src/api/rule-provider.ts new file mode 100644 index 0000000..5d39527 --- /dev/null +++ b/src/api/rule-provider.ts @@ -0,0 +1,74 @@ +import { getURLAndInit } from 'src/misc/request-helper'; +import { ClashAPIConfig } from 'src/types'; + +export type RuleProvider = RuleProviderAPIItem & { idx: number }; + +export type RuleProviderAPIItem = { + behavior: string; + name: string; + ruleCount: number; + type: 'Rule'; + // example value "2020-06-30T16:23:01.44143802+08:00" + updatedAt: string; + vehicleType: 'HTTP' | 'File'; +}; + +type RuleProviderAPIData = { + providers: Record<string, RuleProviderAPIItem>; +}; + +function normalizeAPIResponse(data: RuleProviderAPIData) { + const providers = data.providers; + const names = Object.keys(providers); + const byName: Record<string, RuleProvider> = {}; + + // attach an idx to each item + for (let i = 0; i < names.length; i++) { + const name = names[i]; + byName[name] = { ...providers[name], idx: i }; + } + + return { byName, names }; +} + +export async function fetchRuleProviders( + endpoint: string, + apiConfig: ClashAPIConfig +) { + const { url, init } = getURLAndInit(apiConfig); + + let data = { providers: {} }; + try { + const res = await fetch(url + endpoint, init); + if (res.ok) { + data = await res.json(); + } + } catch (err) { + // log and ignore + // eslint-disable-next-line no-console + console.log('failed to GET /providers/rules', err); + } + return normalizeAPIResponse(data); +} + +export async function refreshRuleProviderByName({ + name, + apiConfig, +}: { + name: string; + apiConfig: ClashAPIConfig; +}) { + const { url, init } = getURLAndInit(apiConfig); + try { + const res = await fetch(url + `/providers/rules/${name}`, { + method: 'PUT', + ...init, + }); + return res.ok; + } catch (err) { + // log and ignore + // eslint-disable-next-line no-console + console.log('failed to PUT /providers/rules/:name', err); + return false; + } +} diff --git a/src/api/rules.js b/src/api/rules.js deleted file mode 100644 index 574de96..0000000 --- a/src/api/rules.js +++ /dev/null @@ -1,8 +0,0 @@ -import { getURLAndInit } from '../misc/request-helper'; - -const endpoint = '/rules'; - -export async function fetchRules(apiConfig) { - const { url, init } = getURLAndInit(apiConfig); - return await fetch(url + endpoint, init); -} diff --git a/src/api/rules.ts b/src/api/rules.ts new file mode 100644 index 0000000..b57b0e3 --- /dev/null +++ b/src/api/rules.ts @@ -0,0 +1,41 @@ +import invariant from 'invariant'; +import { getURLAndInit } from 'src/misc/request-helper'; +import { ClashAPIConfig } from 'src/types'; + +// const endpoint = '/rules'; + +type RuleItem = RuleAPIItem & { id: number }; + +type RuleAPIItem = { + type: string; + payload: string; + proxy: string; +}; + +function normalizeAPIResponse(json: { + rules: Array<RuleAPIItem>; +}): Array<RuleItem> { + invariant( + json.rules && json.rules.length >= 0, + 'there is no valid rules list in the rules API response' + ); + + // attach an id + return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i })); +} + +export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { + let json = { rules: [] }; + try { + const { url, init } = getURLAndInit(apiConfig); + const res = await fetch(url + endpoint, init); + if (res.ok) { + json = await res.json(); + } + } catch (err) { + // log and ignore + // eslint-disable-next-line no-console + console.log('failed to fetch rules', err); + } + return normalizeAPIResponse(json); +} diff --git a/src/components/Button.module.css b/src/components/Button.module.css index 719a9aa..83da6af 100644 --- a/src/components/Button.module.css +++ b/src/components/Button.module.css @@ -43,6 +43,10 @@ } } +.btn:disabled { + opacity: 0.5; +} + .btnStart { margin-right: 5px; display: inline-flex; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7409c58..9450969 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -16,6 +16,7 @@ type ButtonInternalProps = { type ButtonProps = { isLoading?: boolean; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; + disabled?: boolean; kind?: 'primary' | 'minimal'; className?: string; } & ButtonInternalProps; @@ -23,6 +24,7 @@ type ButtonProps = { function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) { const { onClick, + disabled = false, isLoading, kind = 'primary', className, @@ -43,7 +45,12 @@ function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) { className ); return ( - <button className={btnClassName} ref={ref} onClick={internalOnClick}> + <button + className={btnClassName} + ref={ref} + onClick={internalOnClick} + disabled={disabled} + > {isLoading ? ( <> <span diff --git a/src/components/RuleSearch.js b/src/components/RuleSearch.js deleted file mode 100644 index bc58642..0000000 --- a/src/components/RuleSearch.js +++ /dev/null @@ -1,6 +0,0 @@ -import { getSearchText, updateSearchText } from '../store/rules'; -import Search from './Search'; -import { connect } from './StateProvider'; - -const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText }); -export default connect(mapState)(Search); diff --git a/src/components/Rules.js b/src/components/Rules.js index a04116a..949e5e9 100644 --- a/src/components/Rules.js +++ b/src/components/Rules.js @@ -1,27 +1,63 @@ import React from 'react'; import { RotateCw } from 'react-feather'; -import { areEqual, FixedSizeList as List } from 'react-window'; +import { queryCache, useQuery } from 'react-query'; +import { areEqual, VariableSizeList } from 'react-window'; +import { useRecoilState } from 'recoil'; +import { fetchRuleProviders } from 'src/api/rule-provider'; +import { fetchRules } from 'src/api/rules'; +import { RuleProviderItem } from 'src/components/rules/RuleProviderItem'; +import { TextFilter } from 'src/components/rules/TextFilter'; +import { ruleFilterText } from 'src/store/rules'; import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import { getClashAPIConfig } from '../store/app'; -import { fetchRules, fetchRulesOnce, getRules } from '../store/rules'; import ContentHeader from './ContentHeader'; import Rule from './Rule'; -import RuleSearch from './RuleSearch'; +import s from './Rules.module.css'; import { Fab, position as fabPosition } from './shared/Fab'; import { connect } from './StateProvider'; -const { memo, useEffect, useMemo, useCallback } = React; +const { memo, useMemo, useCallback } = React; const paddingBottom = 30; -function itemKey(index, data) { - const item = data[index]; +function itemKey(index, { rules, provider }) { + const providerQty = provider.names.length; + + if (index < providerQty) { + return provider.names[index]; + } + const item = rules[index - providerQty]; return item.id; } +function getItemSizeFactory({ provider }) { + return function getItemSize(idx) { + const providerQty = provider.names.length; + if (idx < providerQty) { + // provider + return 90; + } + // rule + return 80; + }; +} + const Row = memo(({ index, style, data }) => { - const r = data[index]; + const { rules, provider, apiConfig } = data; + const providerQty = provider.names.length; + + if (index < providerQty) { + const name = provider.names[index]; + const item = provider.byName[name]; + return ( + <div style={style} className={s.RuleProviderItemWrapper}> + <RuleProviderItem apiConfig={apiConfig} {...item} /> + </div> + ); + } + + const r = rules[index - providerQty]; return ( <div style={style}> <Rule {...r} /> @@ -31,42 +67,75 @@ const Row = memo(({ index, style, data }) => { const mapState = (s) => ({ apiConfig: getClashAPIConfig(s), - rules: getRules(s), }); export default connect(mapState)(Rules); -function Rules({ dispatch, apiConfig, rules }) { - const fetchRulesHooked = useCallback(() => { - dispatch(fetchRules(apiConfig)); - }, [apiConfig, dispatch]); - useEffect(() => { - dispatch(fetchRulesOnce(apiConfig)); - }, [dispatch, apiConfig]); +function useRuleAndProvider(apiConfig) { + const { data: rules } = useQuery(['/rules', apiConfig], fetchRules, { + suspense: true, + }); + const { data: provider } = useQuery( + ['/providers/rules', apiConfig], + fetchRuleProviders, + { suspense: true } + ); + + const [filterText] = useRecoilState(ruleFilterText); + if (filterText === '') { + return { rules, provider }; + } else { + const f = filterText.toLowerCase(); + return { + rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0), + provider: { + byName: provider.byName, + names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0), + }, + }; + } +} + +function useInvalidateQueries() { + return useCallback(() => { + queryCache.invalidateQueries('/rules'); + queryCache.invalidateQueries('/providers/rules'); + }, []); +} + +function Rules({ apiConfig }) { const [refRulesContainer, containerHeight] = useRemainingViewPortHeight(); const refreshIcon = useMemo(() => <RotateCw width={16} />, []); + + const { rules, provider } = useRuleAndProvider(apiConfig); + const invalidateQueries = useInvalidateQueries(); + + const getItemSize = getItemSizeFactory({ rules, provider }); + return ( <div> - <ContentHeader title="Rules" /> - <RuleSearch /> + <div className={s.header}> + <ContentHeader title="Rules" /> + <TextFilter /> + </div> <div ref={refRulesContainer} style={{ paddingBottom }}> - <List + <VariableSizeList height={containerHeight - paddingBottom} width="100%" - itemCount={rules.length} - itemSize={80} - itemData={rules} + itemCount={rules.length + provider.names.length} + itemSize={getItemSize} + itemData={{ rules, provider, apiConfig }} itemKey={itemKey} > {Row} - </List> + </VariableSizeList> </div> <Fab icon={refreshIcon} text="Refresh" - onClick={fetchRulesHooked} position={fabPosition} + onClick={invalidateQueries} /> </div> ); diff --git a/src/components/Rules.module.css b/src/components/Rules.module.css index 79a9626..6459e17 100644 --- a/src/components/Rules.module.css +++ b/src/components/Rules.module.css @@ -1 +1,21 @@ -/* */ +.header { + display: grid; + grid-template-columns: 1fr minmax(auto, 330px); + align-items: center; + + /* + * the content header has some padding + * we need to apply some right padding to this container then + */ + padding-right: 15px; + @media (--breakpoint-not-small) { + padding-right: 40px; + } +} + +.RuleProviderItemWrapper { + padding: 6px 15px; + @media (--breakpoint-not-small) { + padding: 10px 40px; + } +} diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js index 56527aa..e905d98 100644 --- a/src/components/StateProvider.js +++ b/src/components/StateProvider.js @@ -1,6 +1,11 @@ import produce, * as immer from 'immer'; import React from 'react'; +// in logs store we update logs in place +// outside of immer produce +// this is just workaround +immer.setAutoFreeze(false); + const { createContext, memo, diff --git a/src/components/proxies/TextFilter.tsx b/src/components/proxies/TextFilter.tsx index 6465572..75f1d51 100644 --- a/src/components/proxies/TextFilter.tsx +++ b/src/components/proxies/TextFilter.tsx @@ -1,28 +1,11 @@ -import debounce from 'lodash-es/debounce'; import * as React from 'react'; -import { useRecoilState } from 'recoil'; +import { useTextInut } from 'src/hooks/useTextInput'; import { proxyFilterText } from '../../store/proxies'; import shared from '../shared.module.css'; -const { useCallback, useState, useMemo } = React; - export function TextFilter() { - const [, setTextGlobal] = useRecoilState(proxyFilterText); - const [text, setText] = useState(''); - - const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [ - setTextGlobal, - ]); - - const onChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - setText(e.target.value); - setTextDebounced(e.target.value); - }, - [setTextDebounced] - ); - + const [onChange, text] = useTextInut(proxyFilterText); return ( <input className={shared.input} diff --git a/src/components/rules/RuleProviderItem.module.css b/src/components/rules/RuleProviderItem.module.css new file mode 100644 index 0000000..f4f52c8 --- /dev/null +++ b/src/components/rules/RuleProviderItem.module.css @@ -0,0 +1,43 @@ +.RuleProviderItem { + display: grid; + grid-template-columns: 40px 1fr 46px; + height: 100%; +} + +.left { + display: inline-flex; + align-items: center; + color: var(--color-text-secondary); + opacity: 0.4; +} + +.middle { + display: grid; + grid-template-rows: 1fr auto auto; + align-items: center; +} + +.gray { + color: #777; +} + +.refreshButtonWrapper { + display: grid; + place-items: center; +} + +.rotate { + display: inline-flex; +} +.isRotating { + animation: rotating 3s infinite linear; +} + +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx new file mode 100644 index 0000000..3b6d93d --- /dev/null +++ b/src/components/rules/RuleProviderItem.tsx @@ -0,0 +1,71 @@ +import cx from 'clsx'; +import { formatDistance } from 'date-fns'; +import * as React from 'react'; +import { RotateCw } from 'react-feather'; +import { queryCache, useMutation } from 'react-query'; +import { refreshRuleProviderByName } from 'src/api/rule-provider'; +import Button from 'src/components/Button'; +import { SectionNameType } from 'src/components/shared/Basic'; +import { ClashAPIConfig } from 'src/types'; + +import s from './RuleProviderItem.module.css'; + +function useRefresh( + name: string, + apiConfig: ClashAPIConfig +): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] { + const [mutate, { isLoading }] = useMutation(refreshRuleProviderByName, { + onSuccess: () => { + queryCache.invalidateQueries('/providers/rules'); + }, + }); + + const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => { + ev.preventDefault(); + mutate({ name, apiConfig }); + }; + + return [onClickRefreshButton, isLoading]; +} + +function RotatableRotateCw({ isRotating }: { isRotating: boolean }) { + const cls = cx(s.rotate, { + [s.isRotating]: isRotating, + }); + return ( + <span className={cls}> + <RotateCw width={16} /> + </span> + ); +} + +export function RuleProviderItem({ + idx, + name, + vehicleType, + behavior, + updatedAt, + ruleCount, + apiConfig, +}) { + const [onClickRefreshButton, isRefreshing] = useRefresh(name, apiConfig); + const timeAgo = formatDistance(new Date(updatedAt), new Date()); + return ( + <div className={s.RuleProviderItem}> + <span className={s.left}>{idx}</span> + <div className={s.middle}> + <SectionNameType name={name} type={`${vehicleType} / ${behavior}`} /> + <div className={s.gray}> + {ruleCount < 2 ? `${ruleCount} rule` : `${ruleCount} rules`} + </div> + <small className={s.gray}>Updated {timeAgo} ago</small> + </div> + <span className={s.refreshButtonWrapper}> + <Button onClick={onClickRefreshButton} disabled={isRefreshing}> + <RotatableRotateCw isRotating={isRefreshing} /> + </Button> + </span> + </div> + ); +} + diff --git a/src/components/rules/TextFilter.tsx b/src/components/rules/TextFilter.tsx new file mode 100644 index 0000000..a3cc29e --- /dev/null +++ b/src/components/rules/TextFilter.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { useTextInut } from 'src/hooks/useTextInput'; +import { ruleFilterText } from 'src/store/rules'; + +import shared from '../shared.module.css'; + +export function TextFilter() { + const [onChange, text] = useTextInut(ruleFilterText); + return ( + <input + className={shared.input} + type="text" + value={text} + onChange={onChange} + placeholder="Filter" + /> + ); +} diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts new file mode 100644 index 0000000..1fa19f7 --- /dev/null +++ b/src/hooks/useTextInput.ts @@ -0,0 +1,23 @@ +import debounce from 'lodash-es/debounce'; +import * as React from 'react'; +import { RecoilState, useRecoilState } from 'recoil'; + +const { useCallback, useState, useMemo } = React; + +export function useTextInut( + x: RecoilState<string> +): [(e: React.ChangeEvent<HTMLInputElement>) => void, string] { + const [, setTextGlobal] = useRecoilState(x); + const [text, setText] = useState(''); + const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [ + setTextGlobal, + ]); + const onChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setText(e.target.value); + setTextDebounced(e.target.value); + }, + [setTextDebounced] + ); + return [onChange, text]; +} diff --git a/src/store/index.js b/src/store/index.js index 0e559ad..bd4e7a9 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -8,14 +8,12 @@ import { initialState as configs } from './configs'; import { initialState as logs } from './logs'; import { initialState as modals } from './modals'; import { actions as proxiesActions, initialState as proxies } from './proxies'; -import { initialState as rules } from './rules'; export const initialState = { app: app(), modals, configs, proxies, - rules, logs, }; diff --git a/src/store/logs.js b/src/store/logs.js index c3b3b9a..6da2b33 100644 --- a/src/store/logs.js +++ b/src/store/logs.js @@ -2,9 +2,9 @@ import { createSelector } from 'reselect'; const LogSize = 300; -const getLogs = s => s.logs.logs; -const getTail = s => s.logs.tail; -export const getSearchText = s => s.logs.searchText; +const getLogs = (s) => s.logs.logs; +const getTail = (s) => s.logs.tail; +export const getSearchText = (s) => s.logs.searchText; export const getLogsForDisplay = createSelector( getLogs, getTail, @@ -21,13 +21,13 @@ export const getLogsForDisplay = createSelector( } if (searchText === '') return x; - return x.filter(r => r.payload.toLowerCase().indexOf(searchText) >= 0); + return x.filter((r) => r.payload.toLowerCase().indexOf(searchText) >= 0); } ); export function updateSearchText(text) { - return dispatch => { - dispatch('logsUpdateSearchText', s => { + return (dispatch) => { + dispatch('logsUpdateSearchText', (s) => { s.logs.searchText = text.toLowerCase(); }); }; @@ -42,7 +42,7 @@ export function appendLog(log) { // mutate intentionally for performance logs[tail] = log; - dispatch('logsAppendLog', s => { + dispatch('logsAppendLog', (s) => { s.logs.tail = tail; }); }; @@ -52,5 +52,5 @@ export const initialState = { searchText: '', logs: [], // tail's initial value must be -1 - tail: -1 + tail: -1, }; diff --git a/src/store/rules.js b/src/store/rules.js index 61e3328..bdd835d 100644 --- a/src/store/rules.js +++ b/src/store/rules.js @@ -1,58 +1,6 @@ -import invariant from 'invariant'; -import { createSelector } from 'reselect'; +import { atom } from 'recoil'; -import * as rulesAPI from '../api/rules'; - -export const getAllRules = (s) => s.rules.allRules; -export const getSearchText = (s) => s.rules.searchText; -export const getRules = createSelector( - getSearchText, - getAllRules, - (searchText, allRules) => { - if (searchText === '') return allRules; - return allRules.filter((r) => r.payload.indexOf(searchText) >= 0); - } -); -export function updateSearchText(text) { - return (dispatch) => { - dispatch('rulesUpdateSearchText', (s) => { - s.rules.searchText = text.toLowerCase(); - }); - }; -} - -export function fetchRules(apiConfig) { - return async (dispatch) => { - const res = await rulesAPI.fetchRules(apiConfig); - const json = await res.json(); - invariant( - json.rules && json.rules.length >= 0, - 'there is no valid rules list in the rules API response' - ); - - // attach an id - const allRules = json.rules.map((r, i) => { - r.id = i; - return r; - }); - - dispatch('rulesFetchRules', (s) => { - s.rules.allRules = allRules; - }); - }; -} - -export function fetchRulesOnce(apiConfig) { - return async (dispatch, getState) => { - const allRules = getAllRules(getState()); - if (allRules.length === 0) return await dispatch(fetchRules(apiConfig)); - }; -} - -// {"type":"FINAL","payload":"","proxy":"Proxy"} -// {"type":"IPCIDR","payload":"172.16.0.0/12","proxy":"DIRECT"} -export const initialState = { - // filteredRules: [], - allRules: [], - searchText: '', -}; +export const ruleFilterText = atom({ + key: 'ruleFilterText', + default: '', +}); diff --git a/src/types.ts b/src/types.ts index e69de29..047f3fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -0,0 +1,5 @@ +export type ClashAPIConfig = { + hostname: string; + port: number; + secret?: string; +}; |
