x-ui/web/html/xui/routing_rules.html
Alireza Ahmadi 669aa8bf03
Some checks failed
Release X-UI / build (386) (push) Has been cancelled
Release X-UI / build (amd64) (push) Has been cancelled
Release X-UI / build (arm64) (push) Has been cancelled
Release X-UI / build (armv5) (push) Has been cancelled
Release X-UI / build (armv6) (push) Has been cancelled
Release X-UI / build (armv7) (push) Has been cancelled
Release X-UI / build (s390x) (push) Has been cancelled
Release X-UI / Build for Windows (push) Has been cancelled
fix and improve clipboard
2026-06-25 00:04:31 +02:00

618 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
{{template "head" .}}
<style>
@media (min-width: 769px) {
.ant-layout-content { margin: 24px 16px; }
}
@media (max-width: 768px) {
.ant-card-body { padding: .5rem; }
}
.ant-list-item {
display: block;
}
.collapse-title {
color: inherit;
font-weight: bold;
font-size: 18px;
padding: 10px 20px;
border-bottom: 2px solid;
}
.collapse-title > i {
color: inherit;
font-size: 24px;
}
.rule-drag-handle {
cursor: grab;
font-size: 24px;
user-select: none;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.rule-drag-handle:active {
cursor: grabbing;
}
.routing-rules-table tr.routing-rule-drag-over td {
border-top: 2px solid #1890ff;
}
.routing-rules-table tr.routing-rule-dragging td {
opacity: 0.45;
}
</style>
<body>
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
{{ template "commonSider" . }}
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="spinning" :delay="500" tip='{{ i18n "loading"}}'>
<a-card hoverable style="margin-bottom: 12px;">
<a-row>
<a-col :xs="24" :sm="12" style="padding: 4px;">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveBtnDisable" @click="saveAll">{{ i18n "pages.xray.save" }}</a-button>
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="import">
<a-icon type="import"></a-icon>
{{ i18n "import" }}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "export" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</a-space>
</a-col>
<a-col :xs="24" :sm="12">
<a-alert type="warning" style="float: right; width: fit-content;"
message='{{ i18n "pages.xray.rules.infoDesc" }}' show-icon></a-alert>
</a-col>
</a-row>
<a-divider></a-divider>
<a-collapse>
<a-collapse-panel header='{{ i18n "pages.xray.basicRouting"}}'>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" style="text-align: center;">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled" style="color: #FFA031"></a-icon>
{{ i18n "pages.xray.blockConnectionsConfigsDesc" }}
</template>
</a-alert>
</a-row>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.blockips" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="blockedIPs"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.IPsOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.blockdomains" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="blockedDomains"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.DomainsOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4">
<a-list-item-meta title='{{ i18n "pages.xray.Torrent"}}' description='{{ i18n "pages.xray.TorrentDesc"}}'/>
</a-col>
<a-col :lg="24" :xl="20">
<a-switch :checked="torrentSettings" @change="v => torrentSettings = v"></a-switch>
</a-col>
</a-row>
</a-list-item>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" style="text-align: center;">
<template slot="message">{{ i18n "pages.xray.directConnectionsConfigsDesc" }}</template>
</a-alert>
</a-row>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.directips" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="directIPs"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.IPsOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.directdomains" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="directDomains"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.DomainsOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" style="text-align: center;">
<template slot="message">{{ i18n "pages.xray.ipv4RoutingDesc" }}</template>
</a-alert>
</a-row>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.ipv4Routing" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="ipv4Domains"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.ServicesOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<template v-if="WarpExist">
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" style="text-align: center;">
<template slot="message">{{ i18n "pages.xray.warpRoutingDesc" }}</template>
</a-alert>
</a-row>
<a-list-item>
<a-row style="padding: 0 20px">
<a-col :lg="24" :xl="4"><a-list-item-meta title='{{ i18n "pages.xray.warpRouting" }}'/></a-col>
<a-col :lg="24" :xl="20">
<a-select mode="tags" v-model="warpDomains"
style="width: 100%" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="p in settingsData.ServicesOptions" :value="p.value" :label="p.label">[[ p.label ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
</template>
<a-button v-else type="primary" icon="cloud" style="margin: 15px 20px;" @click="showWarp()">WARP</a-button>
</a-collapse-panel>
</a-collapse>
</a-card>
<a-card hoverable>
<a-alert type="warning" show-icon style="margin-bottom: 12px; text-align: center;"
:message='`{{ i18n "pages.xray.RoutingsDesc" }}`'></a-alert>
<a-table class="routing-rules-table" :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered
:row-key="r => r.id"
:data-source="tableRules"
:custom-row="ruleCustomRow"
:scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
style="margin-top: 10px;">
<template slot="actions" slot-scope="text, row">
<a-tooltip>
<template slot="title">{{ i18n "pages.routingRules.dragToReorder" }}</template>
<span class="rule-drag-handle" draggable="true"
@dragstart.stop="onRuleDragStart($event, row)"
@dragend="onRuleDragEnd">
<a-icon type="vertical-align-middle" style="font-size: 24px; cursor: move;" class="normal-icon"></a-icon>
</span>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "edit" }}</template>
<a-icon style="font-size: 24px; cursor: pointer;" class="normal-icon" type="edit" @click="editRule(row)"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title"><span style="color: #FF4D4F">{{ i18n "delete" }}</span></template>
<a-popconfirm @confirm="deleteRule(row.id)"
title='{{ i18n "pages.routingRules.deleteConfirm" }}'
:overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" style="color: #e04141"></a-icon>
<a-icon style="font-size: 24px; cursor: pointer;" class="delete-icon" type="delete"></a-icon>
</a-popconfirm>
</a-tooltip>
</template>
<template slot="ruleTag" slot-scope="text, row">[[ row.tag ]]</template>
<template slot="inbound" slot-scope="text, row">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="row.inboundTag">Inbound Tag: [[ row.inboundTag ]]</p>
<p v-if="row.user">User: [[ row.user ]]</p>
</template>
[[ [row.inboundTag, row.user].filter(Boolean).join('\n') ]]
</a-popover>
</template>
<template slot="outbound" slot-scope="text, row">[[ row.outboundTag ]]</template>
<template slot="balancer" slot-scope="text, row">[[ row.balancerTag ]]</template>
<template slot="info" slot-scope="text, row">
<a-popover placement="bottomRight" v-if="(row.source||row.sourcePort||row.network||row.protocol||row.attrs||row.ip||row.domain||row.port)"
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2" style="max-width: 300px;">
<tr v-if="row.source"><td>Source</td><td>[[ row.source ]]</td></tr>
<tr v-if="row.ip"><td>IP</td><td>[[ row.ip ]]</td></tr>
<tr v-if="row.domain"><td>Domain</td><td>[[ row.domain ]]</td></tr>
<tr v-if="row.protocol"><td>Protocol</td><td>[[ row.protocol ]]</td></tr>
</table>
</template>
<a-button shape="round" size="small"><a-icon type="info"></a-icon></a-button>
</a-popover>
</template>
</a-table>
</a-card>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "js" .}}
{{template "component/themeSwitcher" .}}
<script src="{{ .base_path }}assets/js/model/dbroutingrule.js?{{ .cur_ver }}"></script>
<script>
const rulesColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', align: 'center', width: 20, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.routingRules.ruleTag" }}', align: 'center', width: 20, scopedSlots: { customRender: 'ruleTag' } },
{ title: '{{ i18n "pages.xray.rules.source"}}', children: [
{ title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true },
{ title: 'Port', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true }]},
{ title: '{{ i18n "pages.xray.rules.dest"}}', children: [
{ title: 'IP', dataIndex: 'ip', align: 'center', width: 20, ellipsis: true },
{ title: 'Domain', dataIndex: 'domain', align: 'center', width: 20, ellipsis: true },
{ title: 'Port', dataIndex: 'port', align: 'center', width: 10, ellipsis: true }]},
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'center', width: 40, scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'center', width: 25, scopedSlots: { customRender: 'outbound' } },
{ title: '{{ i18n "pages.xray.rules.balancer"}}', align: 'center', width: 20, scopedSlots: { customRender: 'balancer' } },
{ title: '{{ i18n "pages.xray.rules.info"}}', align: 'center', width: 15, scopedSlots: { customRender: 'info' } },
];
const rulesMobileColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', align: 'center', width: 110, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.routingRules.ruleTag" }}', align: 'center', scopedSlots: { customRender: 'ruleTag' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'center', scopedSlots: { customRender: 'outbound' } },
{ title: '{{ i18n "pages.xray.rules.info"}}', align: 'center', scopedSlots: { customRender: 'info' } },
];
const routingRulesApp = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
siderDrawer,
themeSwitcher,
spinning: false,
isMobile: window.innerWidth <= 768,
dbRules: [],
inboundTags: [],
clientReverseTags: [],
outboundTags: [],
outboundReverseTags: [],
routingMeta: {},
dragRuleId: null,
dragOverId: null,
nextTempId: -1,
saveBtnDisable: true,
oldRulesSnapshot: '',
settingsData: {
protocols: {
bittorrent: ["bittorrent"],
},
IPsOptions: [
{ label: 'Private IP', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
],
DomainsOptions: [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
],
ServicesOptions: [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
],
},
},
computed: {
tableRules() {
return this.dbRules.map(r => r.toTableRow());
},
WarpExist() {
return this.outboundTags.includes('warp');
},
balancerTags() {
const balancers = this.routingMeta.balancers || [];
return balancers.filter(b => b.tag).map(b => b.tag);
},
blockedProtocolsList() {
return this.getBasicLocal('blocked', 'protocol');
},
torrentSettings: {
get() {
return doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocolsList);
},
set(newValue) {
let data = [...this.blockedProtocolsList];
if (newValue) {
data = [...new Set([...data, ...this.settingsData.protocols.bittorrent])];
} else {
data = data.filter(d => !this.settingsData.protocols.bittorrent.includes(d));
}
this.setBasicLocal('blocked', 'protocol', data);
},
},
blockedIPs: {
get() { return this.getBasicLocal('blocked', 'ip'); },
set(v) { this.setBasicLocal('blocked', 'ip', v); },
},
blockedDomains: {
get() { return this.getBasicLocal('blocked', 'domain'); },
set(v) { this.setBasicLocal('blocked', 'domain', v); },
},
directIPs: {
get() { return this.getBasicLocal('direct', 'ip'); },
set(v) { this.setBasicLocal('direct', 'ip', v); },
},
directDomains: {
get() { return this.getBasicLocal('direct', 'domain'); },
set(v) { this.setBasicLocal('direct', 'domain', v); },
},
ipv4Domains: {
get() { return this.getBasicLocal('IPv4', 'domain'); },
set(v) { this.setBasicLocal('IPv4', 'domain', v); },
},
warpDomains: {
get() { return this.getBasicLocal('warp', 'domain'); },
set(v) { this.setBasicLocal('warp', 'domain', v); },
},
},
methods: {
loading(spinning = true) { this.spinning = spinning; },
currentSnapshot() {
return JSON.stringify(this.dbRules.map(r => ({ tag: r.tag || '', rawJson: r.rawJson || '' })));
},
async loadAll() {
this.loading(true);
const [listMsg, refsMsg] = await Promise.all([
HttpUtil.post('/xui/routing/list'),
HttpUtil.post('/xui/routing/refs'),
]);
this.loading(false);
if (listMsg.success) {
this.dbRules = (listMsg.obj || []).map(r => new DBRoutingRule(r));
}
if (refsMsg.success) {
this.inboundTags = refsMsg.obj.inboundTags || [];
this.clientReverseTags = refsMsg.obj.clientReverseTags || [];
this.outboundTags = refsMsg.obj.outboundTags || [];
this.outboundReverseTags = refsMsg.obj.outboundReverseTags || [];
this.routingMeta = refsMsg.obj.routingMeta || {};
}
this.oldRulesSnapshot = this.currentSnapshot();
this.saveBtnDisable = true;
},
getBasicLocal(outboundTag, property) {
for (const r of this.dbRules) {
let raw;
try { raw = r.rawJson ? JSON.parse(r.rawJson) : {}; } catch (e) { continue; }
if (raw.outboundTag !== outboundTag || raw[property] === undefined) continue;
const v = raw[property];
return Array.isArray(v) ? v : (v != null ? [v] : []);
}
return [];
},
setBasicLocal(outboundTag, property, data) {
data = data || [];
const idx = this.dbRules.findIndex(r => {
let raw;
try { raw = r.rawJson ? JSON.parse(r.rawJson) : {}; } catch (e) { return false; }
return raw.outboundTag === outboundTag && raw[property] !== undefined;
});
if (idx === -1) {
if (data.length === 0) return;
const raw = { type: 'field', outboundTag };
raw[property] = data;
this.dbRules.push(new DBRoutingRule({ id: this.nextTempId--, tag: '', sort: this.dbRules.length, rawJson: JSON.stringify(raw) }));
return;
}
if (data.length === 0) {
this.dbRules.splice(idx, 1);
return;
}
const raw = JSON.parse(this.dbRules[idx].rawJson);
raw[property] = data;
this.dbRules[idx].rawJson = JSON.stringify(raw);
},
addRule() {
ruleModal.show({
title: '{{ i18n "pages.xray.rules.add"}}',
okText: '{{ i18n "pages.xray.rules.add" }}',
confirm: (rule, tag) => {
const data = DBRoutingRule.payloadFromRule(rule, { id: 0, tag: tag || '' });
this.dbRules.push(new DBRoutingRule({ id: this.nextTempId--, tag: data.tag, sort: this.dbRules.length, rawJson: data.rawJson }));
ruleModal.close();
},
isEdit: false,
});
},
editRule(row) {
const db = this.dbRules.find(r => r.id === row.id);
ruleModal.show({
title: '{{ i18n "pages.xray.rules.edit"}} ' + db.tag,
rule: db.toRule(),
tag: db.tag,
confirm: (rule, tag) => {
const data = DBRoutingRule.payloadFromRule(rule, { id: db.id, tag: tag || db.tag });
db.tag = data.tag;
db.rawJson = data.rawJson;
ruleModal.close();
},
isEdit: true,
});
},
deleteRule(id) {
const idx = this.dbRules.findIndex(r => r.id === id);
if (idx !== -1) this.dbRules.splice(idx, 1);
},
async saveAll() {
const payload = this.dbRules.map(r => ({ tag: r.tag || '', rawJson: r.rawJson || '' }));
this.loading(true);
const msg = await HttpUtil.post('/xui/routing/save', { rules: JSON.stringify(payload) });
this.loading(false);
if (msg.success) {
await this.loadAll();
}
},
generalActions({ key }) {
switch (key) {
case 'import':
this.importRules();
break;
case 'export':
this.exportRules();
break;
}
},
exportRules() {
const arr = this.dbRules.map(r => {
const rule = r.toRule();
if (r.tag) rule.ruleTag = r.tag;
return rule;
});
txtModal.show('{{ i18n "export" }}', JSON.stringify(arr, null, 2), 'routing_rules.json');
},
importRules() {
promptModal.open({
title: '{{ i18n "import" }}',
type: 'textarea',
value: '',
okText: '{{ i18n "import" }}',
confirm: (text) => {
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
this.$message.error('{{ i18n "importInvalid" }}');
return;
}
let list = null;
if (Array.isArray(parsed)) {
list = parsed;
} else if (parsed && parsed.routing && Array.isArray(parsed.routing.rules)) {
list = parsed.routing.rules;
} else if (parsed && Array.isArray(parsed.rules)) {
list = parsed.rules;
}
if (!list || list.length === 0) {
this.$message.error('{{ i18n "importInvalid" }}');
return;
}
list.forEach(ruleObj => {
const data = DBRoutingRule.payloadFromRule(ruleObj, { id: this.nextTempId, tag: ruleObj.ruleTag || '' });
this.dbRules.push(new DBRoutingRule({ id: this.nextTempId--, tag: data.tag, sort: this.dbRules.length, rawJson: data.rawJson }));
});
promptModal.close();
this.$message.success(list.length + '');
},
});
},
ruleCustomRow(record) {
const self = this;
let rowClass = '';
if (self.dragRuleId === record.id) {
rowClass = 'routing-rule-dragging';
} else if (self.dragOverId === record.id && self.dragRuleId) {
rowClass = 'routing-rule-drag-over';
}
return {
class: rowClass,
on: {
dragover(e) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
},
dragenter(e) {
e.preventDefault();
if (self.dragRuleId && self.dragRuleId !== record.id) {
self.dragOverId = record.id;
}
},
dragleave(e) {
if (self.dragOverId === record.id) {
self.dragOverId = null;
}
},
drop(e) {
e.preventDefault();
if (self.dragRuleId && self.dragRuleId !== record.id) {
self.moveRule(self.dragRuleId, record.id);
}
self.onRuleDragEnd();
},
},
};
},
onRuleDragStart(e, row) {
this.dragRuleId = row.id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(row.id));
}
},
onRuleDragEnd() {
this.dragRuleId = null;
this.dragOverId = null;
},
moveRule(dragId, targetId) {
const rules = this.dbRules.slice();
const fromIdx = rules.findIndex(r => r.id === dragId);
const toIdx = rules.findIndex(r => r.id === targetId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
return;
}
const [moved] = rules.splice(fromIdx, 1);
rules.splice(toIdx, 0, moved);
this.dbRules = rules;
},
showWarp() {
warpModal.show();
},
onResize() {
this.isMobile = window.innerWidth <= 768;
},
async startDirtyWatch() {
while (true) {
await PromiseUtil.sleep(1000);
this.saveBtnDisable = this.oldRulesSnapshot === this.currentSnapshot();
}
},
},
async mounted() {
window.addEventListener('resize', this.onResize);
this.onResize();
await this.loadAll();
this.startDirtyWatch();
},
});
const app = routingRulesApp;
</script>
{{template "ruleModal"}}
{{template "warpModal"}}
{{template "promptModal"}}
{{template "textModal"}}
</body>
</html>