summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/rules.ts43
-rw-r--r--src/components/rules/Rule.module.scss28
-rw-r--r--src/components/rules/Rule.tsx77
-rw-r--r--src/components/rules/Rules.tsx2
-rw-r--r--src/i18n/en.ts5
-rw-r--r--src/i18n/zh-cn.ts5
-rw-r--r--src/modules/rules/hooks.ts18
-rw-r--r--src/modules/rules/utils.ts2
8 files changed, 168 insertions, 12 deletions
diff --git a/src/api/rules.ts b/src/api/rules.ts
index 5537c31..4e65e9d 100644
--- a/src/api/rules.ts
+++ b/src/api/rules.ts
@@ -5,13 +5,23 @@ import { ClashAPIConfig } from '~/types';
// const endpoint = '/rules';
-type RuleItem = RuleAPIItem & { id: number };
+export type RuleExtra = {
+ disabled: boolean;
+ hitCount: number;
+ hitAt: string;
+ missCount: number;
+ missAt: string;
+};
+
+export type RuleItem = RuleAPIItem & { id: number };
-type RuleAPIItem = {
+export type RuleAPIItem = {
+ index?: number;
type: string;
payload: string;
proxy: string;
size: number;
+ extra?: RuleExtra;
};
function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleItem> {
@@ -20,8 +30,11 @@ function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleIt
'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 }));
+ // attach an id, preferring the backend-provided index over array position
+ return json.rules.map((r: RuleAPIItem, i: number) => ({
+ ...r,
+ id: typeof r.index === 'number' ? r.index : i,
+ }));
}
export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) {
@@ -34,8 +47,28 @@ export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) {
}
} catch (err) {
// log and ignore
-
+
console.log('failed to fetch rules', err);
}
return normalizeAPIResponse(json);
}
+
+export async function updateRuleDisabledStatus(
+ apiConfig: ClashAPIConfig,
+ updates: Record<number, boolean>
+) {
+ const { url, init } = getURLAndInit(apiConfig);
+ try {
+ const res = await fetch(url + '/rules/disable', {
+ method: 'PATCH',
+ ...init,
+ body: JSON.stringify(updates),
+ });
+ return res.ok;
+ } catch (err) {
+ // log and ignore
+
+ console.log('failed to PATCH /rules/disable', err);
+ return false;
+ }
+}
diff --git a/src/components/rules/Rule.module.scss b/src/components/rules/Rule.module.scss
index 48a22e7..f13cab1 100644
--- a/src/components/rules/Rule.module.scss
+++ b/src/components/rules/Rule.module.scss
@@ -10,6 +10,10 @@
&:hover {
background-color: var(--bg-near-transparent);
}
+
+ &.disabled {
+ opacity: 0.45;
+ }
}
.left {
@@ -79,3 +83,27 @@
font-weight: 600;
letter-spacing: 0.02em;
}
+
+.hitInfo {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-text-secondary);
+ cursor: default;
+}
+
+.spacer {
+ flex: 1;
+}
+
+.wrapSwitch {
+ display: flex;
+ align-items: center;
+ transform: scale(0.7);
+ transform-origin: right center;
+
+ &.pending {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+}
diff --git a/src/components/rules/Rule.tsx b/src/components/rules/Rule.tsx
index e1fd047..e53ff23 100644
--- a/src/components/rules/Rule.tsx
+++ b/src/components/rules/Rule.tsx
@@ -1,6 +1,13 @@
+import cx from 'clsx';
+import { formatDistanceToNow } from 'date-fns';
import React from 'react';
+import { useTranslation } from 'react-i18next';
-import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons';
+import { RuleExtra } from '~/api/rules';
+import { Activity, FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons';
+import SwitchThemed from '~/components/SwitchThemed';
+import { useToggleRuleDisabled } from '~/modules/rules/hooks';
+import { ClashAPIConfig } from '~/types';
import s0 from './Rule.module.scss';
@@ -39,23 +46,66 @@ function getIconFor(type: string) {
}
}
+type RuleProviderLookup = {
+ byName: Record<string, { ruleCount?: number }>;
+};
+
type Props = {
id?: number;
type?: string;
payload?: string;
proxy?: string;
size?: number;
+ extra?: RuleExtra;
+ apiConfig?: ClashAPIConfig;
+ provider?: RuleProviderLookup;
};
-function Rule({ type, payload, proxy, id, size }: Props) {
+function getEntryCount({
+ type,
+ payload,
+ size,
+ provider,
+}: {
+ type: string;
+ payload: string;
+ size: number;
+ provider?: RuleProviderLookup;
+}): number | undefined {
+ if ((type === 'GeoSite' || type === 'GeoIP') && size >= 0) {
+ return size;
+ }
+ if (type === 'RuleSet') {
+ return provider?.byName?.[payload]?.ruleCount;
+ }
+ return undefined;
+}
+
+function Rule({ type, payload, proxy, id, size, extra, apiConfig, provider }: Props) {
+ const { t } = useTranslation();
const styleProxy = getStyleFor({ proxy });
+ const { toggleRule, isPending } = useToggleRuleDisabled(apiConfig);
+ const disabled = extra?.disabled ?? false;
+ const entryCount = getEntryCount({ type, payload, size, provider });
+
+ const hitTitle = extra
+ ? extra.hitCount > 0
+ ? t('rule_hit_tip', {
+ count: extra.hitCount,
+ time: formatDistanceToNow(new Date(extra.hitAt), { addSuffix: true }),
+ })
+ : t('rule_never_hit')
+ : undefined;
+
return (
- <div className={s0.rule}>
+ <div className={cx(s0.rule, { [s0.disabled]: disabled })}>
<div className={s0.left}>{id}</div>
<div className={s0.right}>
<div className={s0.payloadRow}>
<div className={s0.payload}>{payload}</div>
- {(type === 'GeoSite' || type === 'GeoIP') && <div className={s0.size}>size: {size}</div>}
+ {typeof entryCount === 'number' && (
+ <div className={s0.size}>{t('rule_entry_count', { count: entryCount })}</div>
+ )}
</div>
<div className={s0.metaRow}>
<div className={s0.typeTag}>
@@ -65,6 +115,25 @@ function Rule({ type, payload, proxy, id, size }: Props) {
<div className={s0.proxyTag} style={styleProxy}>
{proxy}
</div>
+ {extra && (
+ <div className={s0.hitInfo} title={hitTitle}>
+ <Activity size={12} />
+ <span>{extra.hitCount}</span>
+ </div>
+ )}
+ <div className={s0.spacer} />
+ {extra && (
+ <div
+ className={cx(s0.wrapSwitch, { [s0.pending]: isPending })}
+ title={disabled ? t('rule_enable') : t('rule_disable')}
+ >
+ <SwitchThemed
+ name={`rule-${id}`}
+ checked={!disabled}
+ onChange={(checked: boolean) => toggleRule(id, !checked)}
+ />
+ </div>
+ )}
</div>
</div>
</div>
diff --git a/src/components/rules/Rules.tsx b/src/components/rules/Rules.tsx
index c0b02ae..45c55fc 100644
--- a/src/components/rules/Rules.tsx
+++ b/src/components/rules/Rules.tsx
@@ -36,7 +36,7 @@ function Row({ index, style, data }: RowComponentProps<RulesRowProps>) {
const r = rules[index];
return (
<div style={style}>
- <Rule {...r} />
+ <Rule {...r} apiConfig={apiConfig} provider={provider} />
</div>
);
}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 534869c..1474d5a 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -93,4 +93,9 @@ export const data = {
internel: 'Internal Connection',
Clear: 'Clear',
group_fixed_tip: 'This group has a manually fixed selection; run a latency test to release it',
+ rule_entry_count: '{{count}} entries',
+ rule_hit_tip: 'Hit {{count}} times, last {{time}}',
+ rule_never_hit: 'Never hit',
+ rule_enable: 'Enable rule',
+ rule_disable: 'Disable rule',
};
diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts
index f394279..664becb 100644
--- a/src/i18n/zh-cn.ts
+++ b/src/i18n/zh-cn.ts
@@ -94,4 +94,9 @@ export const data = {
internel: '内部链接',
Clear: '清空',
group_fixed_tip: '该组已手动固定选择,点击测速可解除固定',
+ rule_entry_count: '{{count}} 条规则',
+ rule_hit_tip: '已命中 {{count}} 次,最近一次{{time}}',
+ rule_never_hit: '从未命中',
+ rule_enable: '启用规则',
+ rule_disable: '禁用规则',
};
diff --git a/src/modules/rules/hooks.ts b/src/modules/rules/hooks.ts
index e13ea65..20c675e 100644
--- a/src/modules/rules/hooks.ts
+++ b/src/modules/rules/hooks.ts
@@ -7,7 +7,7 @@ import {
refreshRuleProviderByName,
updateRuleProviders,
} from '~/api/rule-provider';
-import { fetchRules } from '~/api/rules';
+import { fetchRules, updateRuleDisabledStatus } from '~/api/rules';
import { ruleFilterText } from '~/store/rules';
import type { ClashAPIConfig } from '~/types';
@@ -49,6 +49,22 @@ export function useUpdateAllRuleProviderItems(
return [onClickRefreshButton, isPending];
}
+export function useToggleRuleDisabled(apiConfig: ClashAPIConfig) {
+ const queryClient = useQueryClient();
+ const { mutate, isPending } = useMutation({
+ mutationFn: ({ index, disabled }: { index: number; disabled: boolean }) =>
+ updateRuleDisabledStatus(apiConfig, { [index]: disabled }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/rules'] });
+ },
+ });
+ const toggleRule = useCallback(
+ (index: number, disabled: boolean) => mutate({ index, disabled }),
+ [mutate]
+ );
+ return { toggleRule, isPending };
+}
+
export function useInvalidateQueries() {
const queryClient = useQueryClient();
return useCallback(() => {
diff --git a/src/modules/rules/utils.ts b/src/modules/rules/utils.ts
index c1d1464..8ce0fc1 100644
--- a/src/modules/rules/utils.ts
+++ b/src/modules/rules/utils.ts
@@ -15,7 +15,7 @@ export function itemKey(index: number, { rules, provider }: RulesListItemData) {
export function getItemSizeFactory({ isRulesTab }: { isRulesTab: boolean }) {
return function getItemSize() {
- return isRulesTab ? 70 : 100;
+ return isRulesTab ? 88 : 100;
};
}