feat(routing): Mihomo rule-providers with hot update
Store rule-providers on server-routing, emit official rule-providers
block into serving config, and expose PUT hot-update via core
/providers/rules/{name}. Panel UI on Routing → Server egress.
This commit is contained in:
1 parent
95961c6cc2
commit
96d4a8a432
11 files changed
+493
-4
No files matched your search
@@ -1432,6 +1432,29 @@ security:
|
||||
responses:
|
||||
"200":
|
||||
description: Saved ServerRoutingConfig
|
||||
/config/rule-providers/status:
|
||||
get:
|
||||
tags: [config]
|
||||
summary: Live rule-provider status from Mihomo (GET /providers/rules)
|
||||
responses:
|
||||
"200":
|
||||
description: providers object from core
|
||||
"502":
|
||||
description: core API unreachable
|
||||
/config/rule-providers/{name}/update:
|
||||
put:
|
||||
tags: [config]
|
||||
summary: Hot-update one rule-set (Mihomo PUT /providers/rules/{name})
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: ok
|
||||
"502":
|
||||
description: update failed
|
||||
/system/warp:
|
||||
get:
|
||||
tags: [system]
|
||||
|
||||
@@ -1522,6 +1522,29 @@ security:
|
||||
responses:
|
||||
"200":
|
||||
description: Saved ServerRoutingConfig
|
||||
/config/rule-providers/status:
|
||||
get:
|
||||
tags: [config]
|
||||
summary: Live rule-provider status from Mihomo (GET /providers/rules)
|
||||
responses:
|
||||
"200":
|
||||
description: providers object from core
|
||||
"502":
|
||||
description: core API unreachable
|
||||
/config/rule-providers/{name}/update:
|
||||
put:
|
||||
tags: [config]
|
||||
summary: Hot-update one rule-set (Mihomo PUT /providers/rules/{name})
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: ok
|
||||
"502":
|
||||
description: update failed
|
||||
/system/warp:
|
||||
get:
|
||||
tags: [system]
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -84,3 +85,53 @@ func (api *ExternalControllerAPI) ReloadConfig(payload map[string]interface{}) e
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRuleProviders GET /providers/rules
|
||||
func (api *ExternalControllerAPI) ListRuleProviders() (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, api.BaseURL+"/providers/rules", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if api.Secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+api.Secret)
|
||||
}
|
||||
resp, err := api.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mihomo core API offline: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("list rule providers: status %s", resp.Status)
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpdateRuleProvider PUT /providers/rules/{name} — hot-reload one rule-set (official Clash API).
|
||||
func (api *ExternalControllerAPI) UpdateRuleProvider(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("rule provider name is empty")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, api.BaseURL+"/providers/rules/"+url.PathEscape(name), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if api.Secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+api.Secret)
|
||||
}
|
||||
// Rule-set download can be slow
|
||||
client := *api.client
|
||||
client.Timeout = 120 * time.Second
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mihomo core API offline: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("update rule provider %q: status %s", name, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,11 +18,27 @@ const serverRoutingName = "server-routing"
|
||||
|
||||
// ServerRoutingConfig controls how traffic leaves the VPS after hitting a
|
||||
// listener — server-side Mihomo proxies, groups, and rules after traffic hits listeners.
|
||||
// RuleProvider is one Mihomo rule-providers entry
|
||||
// (https://wiki.metacubex.one/config/rule-providers/).
|
||||
type RuleProvider struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // http | file | inline
|
||||
Behavior string `json:"behavior"` // domain | ipcidr | classical
|
||||
Format string `json:"format,omitempty"` // yaml | text | mrs (default yaml)
|
||||
URL string `json:"url,omitempty"` // required when type=http
|
||||
Path string `json:"path,omitempty"` // under Mihomo -d home; auto if empty
|
||||
Interval int `json:"interval,omitempty"` // seconds; http update interval
|
||||
Proxy string `json:"proxy,omitempty"` // download via this outbound (e.g. DIRECT)
|
||||
Payload []string `json:"payload,omitempty"` // inline only
|
||||
SizeLimit int `json:"sizeLimit,omitempty"` // bytes; 0 = unlimited
|
||||
}
|
||||
|
||||
type ServerRoutingConfig struct {
|
||||
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
|
||||
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
|
||||
Rules []string `json:"rules" yaml:"rules"`
|
||||
WarpDomains []string `json:"warpDomains" yaml:"warp-domains,omitempty"`
|
||||
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
|
||||
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
|
||||
Rules []string `json:"rules" yaml:"rules"`
|
||||
WarpDomains []string `json:"warpDomains" yaml:"warp-domains,omitempty"`
|
||||
RuleProviders []RuleProvider `json:"ruleProviders" yaml:"rule-providers,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultServerRouting is pure direct egress (historical 3m-ui behaviour).
|
||||
@@ -185,6 +201,87 @@ func applyServerRouting(merged map[string]interface{}, sr ServerRoutingConfig, w
|
||||
rules = []interface{}{"MATCH,DIRECT"}
|
||||
}
|
||||
merged["rules"] = rules
|
||||
applyRuleProviders(merged, sr.RuleProviders)
|
||||
}
|
||||
|
||||
// applyRuleProviders writes top-level rule-providers per MetaCubeX wiki.
|
||||
func applyRuleProviders(merged map[string]interface{}, providers []RuleProvider) {
|
||||
if len(providers) == 0 {
|
||||
delete(merged, "rule-providers")
|
||||
return
|
||||
}
|
||||
out := make(map[string]interface{}, len(providers))
|
||||
for _, p := range providers {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
typ := strings.ToLower(strings.TrimSpace(p.Type))
|
||||
if typ == "" {
|
||||
typ = "http"
|
||||
}
|
||||
behavior := strings.ToLower(strings.TrimSpace(p.Behavior))
|
||||
if behavior == "" {
|
||||
behavior = "classical"
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(p.Format))
|
||||
if format == "" {
|
||||
format = "yaml"
|
||||
}
|
||||
entry := map[string]interface{}{
|
||||
"type": typ,
|
||||
"behavior": behavior,
|
||||
}
|
||||
if format != "yaml" || typ == "http" || typ == "file" {
|
||||
entry["format"] = format
|
||||
}
|
||||
switch typ {
|
||||
case "http":
|
||||
if u := strings.TrimSpace(p.URL); u != "" {
|
||||
entry["url"] = u
|
||||
}
|
||||
path := strings.TrimSpace(p.Path)
|
||||
if path == "" {
|
||||
// Relative to Mihomo -d home (SAFE_PATHS). Official docs allow omitting path
|
||||
// (url MD5 filename); we pin a stable path for operators.
|
||||
ext := format
|
||||
if ext == "" {
|
||||
ext = "yaml"
|
||||
}
|
||||
path = "./rule-providers/" + name + "." + ext
|
||||
}
|
||||
entry["path"] = path
|
||||
if p.Interval > 0 {
|
||||
entry["interval"] = p.Interval
|
||||
} else {
|
||||
entry["interval"] = 86400
|
||||
}
|
||||
if px := strings.TrimSpace(p.Proxy); px != "" {
|
||||
entry["proxy"] = px
|
||||
} else {
|
||||
entry["proxy"] = "DIRECT"
|
||||
}
|
||||
if p.SizeLimit > 0 {
|
||||
entry["size-limit"] = p.SizeLimit
|
||||
}
|
||||
case "file":
|
||||
path := strings.TrimSpace(p.Path)
|
||||
if path == "" {
|
||||
path = "./rule-providers/" + name + ".yaml"
|
||||
}
|
||||
entry["path"] = path
|
||||
case "inline":
|
||||
if len(p.Payload) > 0 {
|
||||
entry["payload"] = p.Payload
|
||||
}
|
||||
}
|
||||
out[name] = entry
|
||||
}
|
||||
if len(out) == 0 {
|
||||
delete(merged, "rule-providers")
|
||||
return
|
||||
}
|
||||
merged["rule-providers"] = out
|
||||
}
|
||||
|
||||
func proxyEntriesToYAML(entries []ProxyEntry) []interface{} {
|
||||
|
||||
@@ -73,3 +73,25 @@ func TestApplyServerRoutingWarpDomains(t *testing.T) {
|
||||
t.Fatalf("rule2: %v", rules[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRuleProviders(t *testing.T) {
|
||||
merged := map[string]interface{}{}
|
||||
sr := ServerRoutingConfig{
|
||||
Rules: []string{"RULE-SET,gfw,DIRECT", "MATCH,DIRECT"},
|
||||
RuleProviders: []RuleProvider{
|
||||
{Name: "gfw", Type: "http", Behavior: "domain", Format: "mrs", URL: "https://example.com/gfw.mrs"},
|
||||
},
|
||||
}
|
||||
applyServerRouting(merged, sr, nil)
|
||||
rp, ok := merged["rule-providers"].(map[string]interface{})
|
||||
if !ok || rp["gfw"] == nil {
|
||||
t.Fatalf("rule-providers: %#v", merged["rule-providers"])
|
||||
}
|
||||
entry := rp["gfw"].(map[string]interface{})
|
||||
if entry["type"] != "http" || entry["behavior"] != "domain" {
|
||||
t.Fatalf("entry: %#v", entry)
|
||||
}
|
||||
if entry["path"] == nil || entry["url"] != "https://example.com/gfw.mrs" {
|
||||
t.Fatalf("path/url: %#v", entry)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
mihomo "github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -89,4 +92,39 @@ func registerRoutingRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
}
|
||||
c.JSON(http.StatusOK, saved)
|
||||
})
|
||||
|
||||
// Rule providers (rule-set) — stored in server-routing; hot-update via Mihomo API.
|
||||
group.GET("/rule-providers/status", func(c *gin.Context) {
|
||||
controller := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER"))
|
||||
if controller == "" {
|
||||
controller = "127.0.0.1:9090"
|
||||
}
|
||||
base := "http://" + controller
|
||||
api := mihomo.NewExternalControllerAPI(base, mihomoConfig.ControllerSecret())
|
||||
data, err := api.ListRuleProviders()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error(), "providers": map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
})
|
||||
group.PUT("/rule-providers/:name/update", func(c *gin.Context) {
|
||||
name := strings.TrimSpace(c.Param("name"))
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
controller := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER"))
|
||||
if controller == "" {
|
||||
controller = "127.0.0.1:9090"
|
||||
}
|
||||
base := "http://" + controller
|
||||
api := mihomo.NewExternalControllerAPI(base, mihomoConfig.ControllerSecret())
|
||||
if err := api.UpdateRuleProvider(name); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "name": name})
|
||||
})
|
||||
|
||||
}
|
||||
@@ -42,3 +42,19 @@ WARP 在 **设置** 里一键注册,把 YAML 作为出站合并进服务端规
|
||||
|
||||
|
||||
See embedded OpenAPI (`GET /api/v1/openapi.yaml`) for `/config/server-routing` and `/system/warp`.
|
||||
|
||||
|
||||
## Rule providers (rule-set)
|
||||
|
||||
Server egress can declare Mihomo **`rule-providers`** ([official docs](https://wiki.metacubex.one/config/rule-providers/)).
|
||||
|
||||
1. **Routing → Server egress → Rule providers → Add** (`http` / `file` / `inline`, behavior `domain` | `ipcidr` | `classical`, format `yaml` | `text` | `mrs`)
|
||||
2. **Save → Generate & apply** so `rule-providers:` is written into the serving config (path under Mihomo `-d` home, default `./rule-providers/{name}.{format}`)
|
||||
3. Add a rule: `RULE-SET,<name>,<TARGET>` (e.g. `RULE-SET,gfw,WARP`)
|
||||
4. **Hot update**: button calls Mihomo `PUT /providers/rules/{name}` (Clash API) to refresh that set without a full process restart for that provider payload
|
||||
|
||||
API:
|
||||
|
||||
- Stored in `GET/PUT /api/v1/config/server-routing` field `ruleProviders`
|
||||
- `PUT /api/v1/config/rule-providers/{name}/update` — hot reload
|
||||
- `GET /api/v1/config/rule-providers/status` — live status from core
|
||||
@@ -17,11 +17,26 @@ export const fetchRules = () =>
|
||||
export const saveRules = (rules: string[]) =>
|
||||
withNetworkRetry(() => client.put<string[]>('/config/rules', rules).then((r) => r.data));
|
||||
|
||||
/** Mihomo rule-providers entry (wiki.metacubex.one/config/rule-providers). */
|
||||
export interface RuleProvider {
|
||||
name: string;
|
||||
type: 'http' | 'file' | 'inline' | string;
|
||||
behavior: 'domain' | 'ipcidr' | 'classical' | string;
|
||||
format?: 'yaml' | 'text' | 'mrs' | string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
interval?: number;
|
||||
proxy?: string;
|
||||
payload?: string[];
|
||||
sizeLimit?: number;
|
||||
}
|
||||
|
||||
export interface ServerRoutingConfig {
|
||||
proxies: Array<Record<string, unknown> & { name: string; type: string; server?: string; port?: number | string }>;
|
||||
proxyGroups: GroupEntry[];
|
||||
rules: string[];
|
||||
warpDomains?: string[];
|
||||
ruleProviders?: RuleProvider[];
|
||||
}
|
||||
|
||||
export const fetchServerRouting = () =>
|
||||
@@ -32,3 +47,12 @@ export const saveServerRouting = (cfg: ServerRoutingConfig) =>
|
||||
withNetworkRetry(() =>
|
||||
client.put<ServerRoutingConfig>('/config/server-routing', cfg).then((r) => r.data),
|
||||
);
|
||||
|
||||
export const fetchRuleProviderStatus = () =>
|
||||
withNetworkRetry(() => client.get('/config/rule-providers/status').then((r) => r.data));
|
||||
|
||||
/** Hot-reload one rule-set via Mihomo PUT /providers/rules/{name}. */
|
||||
export const updateRuleProvider = (name: string) =>
|
||||
withNetworkRetry(() =>
|
||||
client.put(`/config/rule-providers/${encodeURIComponent(name)}/update`).then((r) => r.data),
|
||||
);
|
||||
@@ -829,6 +829,14 @@ export default {
|
||||
"tplWarpAi": "AI domains → WARP (needs outbound named WARP)",
|
||||
"warpAiHint": "Register WARP in Settings (name the proxy WARP), then Generate & apply",
|
||||
"applyPromptBodyServer": "Generate & apply writes server egress (rules + WARP domains + WARP outbound) into the panel Mihomo config and reloads the core.",
|
||||
"ruleProviders": "Rule providers (rule-set)",
|
||||
"rpHint": "Official Mihomo rule-providers. Save + Apply writes rule-providers into config. Hot update calls PUT /providers/rules/{name}. Use rules: RULE-SET,name,TARGET",
|
||||
"rpEmpty": "No rule providers yet",
|
||||
"rpAdd": "Add rule provider",
|
||||
"rpHotUpdate": "Hot update",
|
||||
"rpUpdated": "Rule provider updated",
|
||||
"rpDup": "Name already exists",
|
||||
"rpAddedHint": "Added — Save then Generate & apply so Mihomo loads rule-providers",
|
||||
"noServerTemplates": "Server egress has no templates — set WARP domains below or edit rules.",
|
||||
"warpDomains": "WARP domains",
|
||||
"warpDomainsHintShort": "Requires WARP account in Settings",
|
||||
|
||||
@@ -829,6 +829,14 @@ export default {
|
||||
"tplWarpAi": "AI 域名 → WARP(需名为 WARP 的出站)",
|
||||
"warpAiHint": "请先在设置注册 WARP,并将出站名称设为 WARP,再点生成并应用",
|
||||
"applyPromptBodyServer": "生成并应用会把服务端出站(规则 + WARP 域名 + WARP 出站)写入面板 Mihomo 配置并重载核心。",
|
||||
"ruleProviders": "规则集 (rule-providers)",
|
||||
"rpHint": "官方 Mihomo rule-providers。保存并应用后写入配置;热更新调用 PUT /providers/rules/{name}。规则写法:RULE-SET,名称,出站",
|
||||
"rpEmpty": "暂无规则集",
|
||||
"rpAdd": "添加规则集",
|
||||
"rpHotUpdate": "热更新",
|
||||
"rpUpdated": "规则集已更新",
|
||||
"rpDup": "名称已存在",
|
||||
"rpAddedHint": "已添加 — 请保存并「生成并应用」以便 Mihomo 加载 rule-providers",
|
||||
"noServerTemplates": "服务端出站没有模板 — 请在下方填写 WARP 域名或编辑规则。",
|
||||
"warpDomains": "WARP 域名",
|
||||
"warpDomainsHintShort": "需先在设置中注册 WARP 账户",
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
saveRules,
|
||||
fetchServerRouting,
|
||||
saveServerRouting,
|
||||
updateRuleProvider,
|
||||
type GroupEntry,
|
||||
type RuleProvider,
|
||||
} from '../api/routing';
|
||||
import { generateConfig, applyConfigYAML, fetchProxies } from '../api/config';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -67,6 +69,10 @@ const RoutingPage: React.FC = () => {
|
||||
const [scope, setScope] = useState<'client' | 'server'>('client');
|
||||
const [serverRules, setServerRules] = useState<RuleRow[]>([]);
|
||||
const [warpDomains, setWarpDomains] = useState<string>('');
|
||||
const [ruleProviders, setRuleProviders] = useState<import('../api/routing').RuleProvider[]>([]);
|
||||
const [rpOpen, setRpOpen] = useState(false);
|
||||
const [rpUpdating, setRpUpdating] = useState<string | null>(null);
|
||||
const [rpForm] = Form.useForm();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const targetOptions = useMemo(() => {
|
||||
@@ -89,6 +95,7 @@ const RoutingPage: React.FC = () => {
|
||||
setRules(parseRulesText((Array.isArray(r) ? r : []).join('\n')));
|
||||
setServerRules(parseRulesText((Array.isArray(sr?.rules) ? sr.rules : ['MATCH,DIRECT']).join('\n')));
|
||||
setWarpDomains(Array.isArray(sr?.warpDomains) ? sr.warpDomains.join('\n') : '');
|
||||
setRuleProviders(Array.isArray(sr?.ruleProviders) ? sr.ruleProviders : []);
|
||||
setProxyNames(
|
||||
(Array.isArray(px) ? px : [])
|
||||
.map((p: any) => String(p?.name || '').trim())
|
||||
@@ -167,6 +174,7 @@ const RoutingPage: React.FC = () => {
|
||||
proxyGroups: [],
|
||||
rules: serializeRules(current),
|
||||
warpDomains: domains,
|
||||
ruleProviders,
|
||||
});
|
||||
setServerRules(parseRulesText((Array.isArray(saved?.rules) ? saved.rules : serializeRules(current)).join('\n')));
|
||||
if (Array.isArray(saved?.warpDomains)) {
|
||||
@@ -397,6 +405,177 @@ const RoutingPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{scope === 'server' && (
|
||||
<Card
|
||||
title={t('routing.ruleProviders') || 'Rule providers (rule-set)'}
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Space wrap size="small">
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
rpForm.resetFields();
|
||||
rpForm.setFieldsValue({ type: 'http', behavior: 'domain', format: 'mrs', interval: 86400, proxy: 'DIRECT' });
|
||||
setRpOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('routing.rpAdd') || 'Add'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
|
||||
{t('routing.rpHint') ||
|
||||
'Official Mihomo rule-providers. After Save + Apply, use Update to hot-reload a set (PUT /providers/rules/{name}) without full restart. Rules: RULE-SET,name,TARGET'}
|
||||
</Typography.Paragraph>
|
||||
{ruleProviders.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('routing.rpEmpty') || 'No rule providers yet'}</Typography.Text>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{ruleProviders.map((rp) => (
|
||||
<div
|
||||
key={rp.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography.Text strong>{rp.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
{rp.type}/{rp.behavior}/{rp.format || 'yaml'}
|
||||
{rp.url ? ` · ${rp.url}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space size="small" wrap>
|
||||
<Button
|
||||
size="small"
|
||||
loading={rpUpdating === rp.name}
|
||||
onClick={async () => {
|
||||
setRpUpdating(rp.name);
|
||||
try {
|
||||
await updateRuleProvider(rp.name);
|
||||
message.success(t('routing.rpUpdated') || `Updated ${rp.name}`);
|
||||
} catch (e: any) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setRpUpdating(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('routing.rpHotUpdate') || 'Hot update'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
setRuleProviders((prev) => prev.filter((x) => x.name !== rp.name));
|
||||
}}
|
||||
>
|
||||
{t('common.delete') || 'Delete'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<Modal
|
||||
title={t('routing.rpAdd') || 'Add rule provider'}
|
||||
open={rpOpen}
|
||||
onCancel={() => setRpOpen(false)}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await rpForm.validateFields();
|
||||
const name = String(v.name || '').trim();
|
||||
if (!name) return;
|
||||
if (ruleProviders.some((x) => x.name === name)) {
|
||||
message.error(t('routing.rpDup') || 'Name already exists');
|
||||
return;
|
||||
}
|
||||
const entry: RuleProvider = {
|
||||
name,
|
||||
type: v.type || 'http',
|
||||
behavior: v.behavior || 'domain',
|
||||
format: v.format || 'yaml',
|
||||
url: v.url,
|
||||
path: v.path,
|
||||
interval: v.interval != null ? Number(v.interval) : 86400,
|
||||
proxy: v.proxy || 'DIRECT',
|
||||
payload:
|
||||
typeof v.payload === 'string'
|
||||
? String(v.payload)
|
||||
.split(/\n/)
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
setRuleProviders((prev) => [...prev, entry]);
|
||||
setRpOpen(false);
|
||||
message.info(t('routing.rpAddedHint') || 'Added — Save then Generate & apply so Mihomo loads rule-providers');
|
||||
} catch {
|
||||
/* validate */
|
||||
}
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={rpForm} layout="vertical" size="small">
|
||||
<Form.Item name="name" label="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="gfw" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="type" initialValue="http">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'http', label: 'http' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'inline', label: 'inline' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="behavior" label="behavior" initialValue="domain">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'domain', label: 'domain' },
|
||||
{ value: 'ipcidr', label: 'ipcidr' },
|
||||
{ value: 'classical', label: 'classical' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="format" label="format" initialValue="mrs">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'mrs', label: 'mrs' },
|
||||
{ value: 'yaml', label: 'yaml' },
|
||||
{ value: 'text', label: 'text' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="url" extra="Required for type=http">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label="path" extra="Optional; default ./rule-providers/{name}.{format}">
|
||||
<Input placeholder="./rule-providers/gfw.mrs" />
|
||||
</Form.Item>
|
||||
<Form.Item name="interval" label="interval (s)" initialValue={86400}>
|
||||
<InputNumber min={60} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxy" label="proxy (download)" initialValue="DIRECT">
|
||||
<Input placeholder="DIRECT" />
|
||||
</Form.Item>
|
||||
<Form.Item name="payload" label="payload (inline, one per line)">
|
||||
<Input.TextArea rows={3} placeholder="DOMAIN-SUFFIX,example.com" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{scope === 'client' && (
|
||||
<Card
|
||||
title={t('routing.groups')}
|
||||
|
||||
Reference in new issue
Block a user