feat(routing): server-side Mihomo egress for panel process
Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs. feat(routing): server-side Mihomo egress for panel process Store server-routing (rules/proxies/groups) separately from client visual-config. Generator applies it to the serving core instead of a hard-coded MATCH,DIRECT. Routing UI gains Client / Server tabs.
This commit is contained in:
1 parent
addf8aee32
commit
4d51a5704f
8 files changed
+336
-23
No files matched your search
@@ -67,9 +67,13 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
|
||||
if CoreLowMemory() {
|
||||
merged["geodata-loader"] = geodataLoaderMemConservative
|
||||
}
|
||||
// Inbound panel: always DIRECT exit (no server-side split).
|
||||
merged["rules"] = []interface{}{"MATCH,DIRECT"}
|
||||
merged["proxy-groups"] = []interface{}{}
|
||||
// Server-side egress routing (3x-ui-style): proxies / groups / rules for the
|
||||
// serving Mihomo process. visual-config remains client-subscription only.
|
||||
sr, srErr := GetServerRouting(ce.db)
|
||||
if srErr != nil {
|
||||
return "", fmt.Errorf("load server-routing: %w", srErr)
|
||||
}
|
||||
applyServerRouting(merged, sr)
|
||||
var listeners []models.Listener
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
|
||||
return "", fmt.Errorf("load enabled listeners: %w", err)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// serverRoutingName is the panel_settings / config fragment key for *serving*
|
||||
// Mihomo rules (post-inbound egress). Distinct from visual-config, which only
|
||||
// shapes client subscription YAML.
|
||||
const serverRoutingName = "server-routing"
|
||||
|
||||
// ServerRoutingConfig controls how traffic leaves the VPS after hitting a
|
||||
// listener — the Mihomo equivalent of 3x-ui's Xray outbounds + routing rules.
|
||||
type ServerRoutingConfig struct {
|
||||
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
|
||||
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
|
||||
Rules []string `json:"rules" yaml:"rules"`
|
||||
}
|
||||
|
||||
// DefaultServerRouting is pure direct egress (historical 3m-ui behaviour).
|
||||
func DefaultServerRouting() ServerRoutingConfig {
|
||||
return ServerRoutingConfig{
|
||||
Proxies: nil,
|
||||
Groups: nil,
|
||||
Rules: []string{"MATCH,DIRECT"},
|
||||
}
|
||||
}
|
||||
|
||||
func GetServerRouting(db *gorm.DB) (ServerRoutingConfig, error) {
|
||||
cfg := DefaultServerRouting()
|
||||
if db == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
var row models.PanelSetting
|
||||
err := db.Where("key = ?", serverRoutingName).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Legacy: some installs may only have rules in a Config fragment.
|
||||
var frag models.Config
|
||||
if e2 := db.Where("name = ?", serverRoutingName).First(&frag).Error; e2 == nil && strings.TrimSpace(frag.Content) != "" {
|
||||
if e3 := yaml.Unmarshal([]byte(frag.Content), &cfg); e3 == nil {
|
||||
normalizeServerRouting(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if strings.TrimSpace(row.Value) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse server-routing: %w", err)
|
||||
}
|
||||
normalizeServerRouting(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveServerRouting(db *gorm.DB, cfg ServerRoutingConfig) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is not initialized")
|
||||
}
|
||||
normalizeServerRouting(&cfg)
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var row models.PanelSetting
|
||||
err = db.Where("key = ?", serverRoutingName).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return db.Create(&models.PanelSetting{Key: serverRoutingName, Value: string(raw)}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Value = string(raw)
|
||||
return db.Save(&row).Error
|
||||
}
|
||||
|
||||
func normalizeServerRouting(cfg *ServerRoutingConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if len(cfg.Rules) == 0 {
|
||||
cfg.Rules = []string{"MATCH,DIRECT"}
|
||||
return
|
||||
}
|
||||
hasMatch := false
|
||||
for _, r := range cfg.Rules {
|
||||
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(r)), "MATCH,") {
|
||||
hasMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMatch {
|
||||
cfg.Rules = append(cfg.Rules, "MATCH,DIRECT")
|
||||
}
|
||||
}
|
||||
|
||||
// applyServerRouting writes proxies / groups / rules into the serving config map.
|
||||
func applyServerRouting(merged map[string]interface{}, sr ServerRoutingConfig) {
|
||||
normalizeServerRouting(&sr)
|
||||
if len(sr.Proxies) > 0 {
|
||||
merged["proxies"] = proxyEntriesToYAML(sr.Proxies)
|
||||
}
|
||||
if len(sr.Groups) > 0 {
|
||||
merged["proxy-groups"] = groupEntriesToYAML(sr.Groups)
|
||||
} else {
|
||||
merged["proxy-groups"] = []interface{}{}
|
||||
}
|
||||
rules := make([]interface{}, 0, len(sr.Rules))
|
||||
for _, r := range sr.Rules {
|
||||
r = strings.TrimSpace(r)
|
||||
if r != "" {
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
rules = []interface{}{"MATCH,DIRECT"}
|
||||
}
|
||||
merged["rules"] = rules
|
||||
}
|
||||
|
||||
func proxyEntriesToYAML(entries []ProxyEntry) []interface{} {
|
||||
out := make([]interface{}, 0, len(entries))
|
||||
for _, p := range entries {
|
||||
m := map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"type": p.Type,
|
||||
"server": p.Server,
|
||||
"port": p.Port,
|
||||
}
|
||||
for k, v := range p.Options {
|
||||
m[k] = v
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupEntriesToYAML(entries []GroupEntry) []interface{} {
|
||||
out := make([]interface{}, 0, len(entries))
|
||||
for _, g := range entries {
|
||||
m := map[string]interface{}{
|
||||
"name": g.Name,
|
||||
"type": g.Type,
|
||||
}
|
||||
if len(g.Proxies) > 0 {
|
||||
m["proxies"] = g.Proxies
|
||||
}
|
||||
if g.URL != "" {
|
||||
m["url"] = g.URL
|
||||
}
|
||||
if g.Interval > 0 {
|
||||
m["interval"] = g.Interval
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyServerRoutingDefault(t *testing.T) {
|
||||
merged := map[string]interface{}{}
|
||||
applyServerRouting(merged, DefaultServerRouting())
|
||||
rules, _ := merged["rules"].([]interface{})
|
||||
if len(rules) != 1 || rules[0] != "MATCH,DIRECT" {
|
||||
t.Fatalf("rules: %#v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyServerRoutingCustom(t *testing.T) {
|
||||
merged := map[string]interface{}{}
|
||||
sr := ServerRoutingConfig{
|
||||
Rules: []string{"GEOIP,private,DIRECT", "DOMAIN-SUFFIX,google.com,WARP"},
|
||||
Proxies: []ProxyEntry{
|
||||
{Name: "WARP", Type: "wireguard", Server: "engage.cloudflareclient.com", Port: 2408},
|
||||
},
|
||||
}
|
||||
applyServerRouting(merged, sr)
|
||||
rules, _ := merged["rules"].([]interface{})
|
||||
if len(rules) != 3 { // + MATCH,DIRECT auto
|
||||
t.Fatalf("expected MATCH appended, got %#v", rules)
|
||||
}
|
||||
last := rules[len(rules)-1].(string)
|
||||
if last != "MATCH,DIRECT" {
|
||||
t.Fatalf("last rule: %s", last)
|
||||
}
|
||||
proxies, _ := merged["proxies"].([]interface{})
|
||||
if len(proxies) != 1 {
|
||||
t.Fatalf("proxies: %#v", proxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServerRoutingEmpty(t *testing.T) {
|
||||
sr := ServerRoutingConfig{}
|
||||
normalizeServerRouting(&sr)
|
||||
if len(sr.Rules) != 1 || sr.Rules[0] != "MATCH,DIRECT" {
|
||||
t.Fatalf("%#v", sr.Rules)
|
||||
}
|
||||
}
|
||||
@@ -62,4 +62,31 @@ func registerRoutingRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
}
|
||||
c.JSON(http.StatusOK, visual.Rules)
|
||||
})
|
||||
|
||||
// Server-side egress (serving Mihomo), distinct from client subscription visual-config.
|
||||
group.GET("/server-routing", func(c *gin.Context) {
|
||||
sr, err := mihomoConfig.GetServerRouting(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sr)
|
||||
})
|
||||
group.PUT("/server-routing", func(c *gin.Context) {
|
||||
var sr mihomoConfig.ServerRoutingConfig
|
||||
if err := c.ShouldBindJSON(&sr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := mihomoConfig.SaveServerRouting(db, sr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
saved, err := mihomoConfig.GetServerRouting(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, saved)
|
||||
})
|
||||
}
|
||||
@@ -16,3 +16,18 @@ export const fetchRules = () =>
|
||||
withNetworkRetry(() => client.get<string[]>('/config/rules').then((r) => r.data));
|
||||
export const saveRules = (rules: string[]) =>
|
||||
withNetworkRetry(() => client.put<string[]>('/config/rules', rules).then((r) => r.data));
|
||||
|
||||
export interface ServerRoutingConfig {
|
||||
proxies: Array<Record<string, unknown> & { name: string; type: string; server?: string; port?: number | string }>;
|
||||
proxyGroups: GroupEntry[];
|
||||
rules: string[];
|
||||
}
|
||||
|
||||
export const fetchServerRouting = () =>
|
||||
withNetworkRetry(() =>
|
||||
client.get<ServerRoutingConfig>('/config/server-routing').then((r) => r.data),
|
||||
);
|
||||
export const saveServerRouting = (cfg: ServerRoutingConfig) =>
|
||||
withNetworkRetry(() =>
|
||||
client.put<ServerRoutingConfig>('/config/server-routing', cfg).then((r) => r.data),
|
||||
);
|
||||
@@ -814,13 +814,17 @@ export default {
|
||||
"ruleInvalid": "Invalid rules",
|
||||
"applyDone": "Configuration applied",
|
||||
"applyNow": "Generate & apply",
|
||||
"applyPromptBody": "Saved for client subscriptions. Generate & apply refreshes the panel core (server stays MATCH,DIRECT). Update the subscription in your client for new groups.",
|
||||
"applyPromptBody": "Saved. Generate & apply refreshes panel Mihomo using Server egress rules; update the client subscription separately.",
|
||||
"applyPromptTitle": "Apply configuration?",
|
||||
"groupAdded": "Group added",
|
||||
"addRule": "Add rule",
|
||||
"pageHint": "Proxy-groups and rules are for client Mihomo/Clash subscriptions only. Save, then update the subscription in the client. Panel server always uses MATCH,DIRECT.",
|
||||
"pageHint": "Client subscription: proxy-groups and rules go into Mihomo/Clash subscription YAML only. Save, then refresh the subscription in the client.",
|
||||
"rulesSaved": "Rules saved",
|
||||
"groupSaved": "Group saved",
|
||||
"tabClient": "Client subscription",
|
||||
"tabServer": "Server egress",
|
||||
"serverHint": "Server egress: rules apply to panel Mihomo (like 3x-ui Xray routing) after traffic hits listeners. Save then Apply.",
|
||||
"serverRulesSaved": "Server routing saved",
|
||||
"groupDeleted": "Group deleted"
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -814,13 +814,17 @@ export default {
|
||||
"ruleInvalid": "规则无效",
|
||||
"applyDone": "配置已应用",
|
||||
"applyNow": "生成并应用",
|
||||
"applyPromptBody": "已保存供客户端订阅使用。生成并应用会刷新面板核心(服务端仍为 MATCH,DIRECT)。请在客户端更新订阅以加载新策略组。",
|
||||
"applyPromptBody": "已保存。生成并应用会按「服务端出站」规则刷新面板 Mihomo;客户端订阅需在客户端更新。",
|
||||
"applyPromptTitle": "应用配置?",
|
||||
"groupAdded": "策略组已添加",
|
||||
"addRule": "添加规则",
|
||||
"pageHint": "策略组与规则仅用于客户端 Mihomo/Clash 订阅。保存后请在客户端更新订阅。面板服务端固定 MATCH,DIRECT。",
|
||||
"pageHint": "客户端订阅:策略组与规则只进入 Mihomo/Clash 订阅 YAML。保存后请在客户端更新订阅。",
|
||||
"rulesSaved": "规则已保存",
|
||||
"groupSaved": "策略组已保存",
|
||||
"tabClient": "客户端订阅",
|
||||
"tabServer": "服务端出站",
|
||||
"serverHint": "服务端出站:规则写入面板 Mihomo 进程(类似 3x-ui 的 Xray 路由),影响用户流量到达节点之后的出口。保存后请点应用。",
|
||||
"serverRulesSaved": "服务端路由已保存",
|
||||
"groupDeleted": "策略组已删除"
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Tabs,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
IconAddRule,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
saveGroups,
|
||||
fetchRules,
|
||||
saveRules,
|
||||
fetchServerRouting,
|
||||
saveServerRouting,
|
||||
type GroupEntry,
|
||||
} from '../api/routing';
|
||||
import { generateConfig, applyConfigYAML, fetchProxies } from '../api/config';
|
||||
@@ -60,6 +64,8 @@ const RoutingPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const [scope, setScope] = useState<'client' | 'server'>('client');
|
||||
const [serverRules, setServerRules] = useState<RuleRow[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const targetOptions = useMemo(() => {
|
||||
@@ -72,13 +78,15 @@ const RoutingPage: React.FC = () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [g, r, px] = await Promise.all([
|
||||
const [g, r, px, sr] = await Promise.all([
|
||||
fetchGroups(),
|
||||
fetchRules(),
|
||||
fetchProxies().catch(() => []),
|
||||
fetchServerRouting().catch(() => ({ rules: ['MATCH,DIRECT'], proxyGroups: [], proxies: [] })),
|
||||
]);
|
||||
setGroups(Array.isArray(g) ? g : []);
|
||||
setRules(parseRulesText((Array.isArray(r) ? r : []).join('\n')));
|
||||
setServerRules(parseRulesText((Array.isArray(sr?.rules) ? sr.rules : ['MATCH,DIRECT']).join('\n')));
|
||||
setProxyNames(
|
||||
(Array.isArray(px) ? px : [])
|
||||
.map((p: any) => String(p?.name || '').trim())
|
||||
@@ -121,8 +129,12 @@ const RoutingPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const activeRules = scope === 'server' ? serverRules : rules;
|
||||
const setActiveRules = scope === 'server' ? setServerRules : setRules;
|
||||
|
||||
const onSaveRules = async () => {
|
||||
const issues = validateRules(rules);
|
||||
const current = scope === 'server' ? serverRules : rules;
|
||||
const issues = validateRules(current);
|
||||
const hard = issues.filter((i) => !i.message.includes('recommended'));
|
||||
if (hard.length) {
|
||||
message.error(
|
||||
@@ -139,10 +151,21 @@ const RoutingPage: React.FC = () => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveRules(serializeRules(rules));
|
||||
setRules(parseRulesText((Array.isArray(saved) ? saved : serializeRules(rules)).join('\n')));
|
||||
message.success(t('routing.rulesSaved') || 'Rules saved');
|
||||
offerApply();
|
||||
if (scope === 'server') {
|
||||
const saved = await saveServerRouting({
|
||||
proxies: [],
|
||||
proxyGroups: [],
|
||||
rules: serializeRules(current),
|
||||
});
|
||||
setServerRules(parseRulesText((Array.isArray(saved?.rules) ? saved.rules : serializeRules(current)).join('\n')));
|
||||
message.success(t('routing.serverRulesSaved') || 'Server routing saved');
|
||||
offerApply();
|
||||
} else {
|
||||
const saved = await saveRules(serializeRules(current));
|
||||
setRules(parseRulesText((Array.isArray(saved) ? saved : serializeRules(current)).join('\n')));
|
||||
message.success(t('routing.rulesSaved') || 'Rules saved');
|
||||
offerApply();
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
@@ -190,11 +213,13 @@ const RoutingPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const updateRule = (index: number, patch: Partial<RuleRow>) => {
|
||||
setRules((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch, raw: undefined } : r)));
|
||||
const setter = scope === 'server' ? setServerRules : setRules;
|
||||
setter((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch, raw: undefined } : r)));
|
||||
};
|
||||
|
||||
const moveRule = (index: number, dir: -1 | 1) => {
|
||||
setRules((prev) => {
|
||||
const setter = scope === 'server' ? setServerRules : setRules;
|
||||
setter((prev) => {
|
||||
const j = index + dir;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
@@ -206,11 +231,13 @@ const RoutingPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
setRules((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||
const setter = scope === 'server' ? setServerRules : setRules;
|
||||
setter((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
setRules((prev) => [...prev, emptyRule({ type: 'DOMAIN-SUFFIX', target: 'DIRECT' })]);
|
||||
const setter = scope === 'server' ? setServerRules : setRules;
|
||||
setter((prev) => [...prev, emptyRule({ type: 'DOMAIN-SUFFIX', target: 'DIRECT' })]);
|
||||
};
|
||||
|
||||
const onTemplate = async (id: string) => {
|
||||
@@ -298,11 +325,29 @@ const RoutingPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={t('routing.title')} subtitle={t('routing.subtitle')} />
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -4, marginBottom: isMobile ? 8 : 12, fontSize: isMobile ? 12 : undefined }}>
|
||||
{t('routing.pageHint') ||
|
||||
'Proxy-groups and rules are for **client** Mihomo/Clash subscriptions only (not server-side split). Save, then update the subscription in the client. Panel core always uses MATCH,DIRECT.'}
|
||||
</Typography.Paragraph>
|
||||
<Tabs
|
||||
activeKey={scope}
|
||||
onChange={(k) => setScope(k as 'client' | 'server')}
|
||||
style={{ marginBottom: 8 }}
|
||||
items={[
|
||||
{ key: 'client', label: t('routing.tabClient') || 'Client subscription' },
|
||||
{ key: 'server', label: t('routing.tabServer') || 'Server egress' },
|
||||
]}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: isMobile ? 8 : 12 }}
|
||||
message={
|
||||
scope === 'server'
|
||||
? (t('routing.serverHint') ||
|
||||
'Server egress: rules applied to the panel Mihomo process (like 3x-ui Xray routing). Affects traffic after it hits your listeners. Save then Apply.')
|
||||
: (t('routing.pageHint') ||
|
||||
'Client subscription: proxy-groups and rules go into Mihomo/Clash subscription YAML only. Save, then refresh the subscription in the client.')
|
||||
}
|
||||
/>
|
||||
|
||||
{scope === 'client' && (
|
||||
<Card
|
||||
title={t('routing.groups')}
|
||||
extra={
|
||||
@@ -341,11 +386,12 @@ const RoutingPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={t('routing.rules')} extra={isMobile ? undefined : ruleCardExtra}>
|
||||
{isMobile ? <div style={{ marginBottom: 12 }}>{ruleCardExtra}</div> : null}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{rules.map((row, index) => (
|
||||
{activeRules.map((row, index) => (
|
||||
<div
|
||||
key={row.key || newRuleKey()}
|
||||
style={{
|
||||
@@ -427,7 +473,7 @@ const RoutingPage: React.FC = () => {
|
||||
onClick={() => moveRule(index, 1)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button size="small" danger icon={<IconDelete />} disabled={rules.length <= 1} onClick={() => removeRule(index)} />
|
||||
<Button size="small" danger icon={<IconDelete />} disabled={activeRules.length <= 1} onClick={() => removeRule(index)} />
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user