评审 P1 修正一批
设置:显式主题补 color-scheme,滚动条和 select 弹层这些 UA 部件不看 变量。连接:断开失败的提示与轮询取数分成两条通道,不再被下一秒的轮询 成功抹掉;排序加 id 决胜,内核快照的遍历序无序,空闲连接下载量普遍 相同,没有决胜键行会在断开按钮下面跳。仪表盘与请求:一条流断了连带 收掉同组连接;断流不等于内核没了(配置重载掐断流而 online 从未翻转), 5 秒自动重连。规则:正则类规则(DOMAIN-REGEX 等三种)按内核的 payloadCanContainComma 语义拆分与禁入结构化编辑,顶层逗号不再把正则 截一半当目标;findRuleIssues 注释里对内核校验的错误断言改正。
This commit is contained in:
@@ -83,7 +83,12 @@ export function sortConnections(
|
|||||||
const started = Date.parse(c.start)
|
const started = Date.parse(c.start)
|
||||||
return Number.isNaN(started) ? -1 : now - started
|
return Number.isNaN(started) ? -1 : now - started
|
||||||
}
|
}
|
||||||
return [...list].sort((a, b) => (key === 'download' ? b.download - a.download : age(b) - age(a)))
|
// 平局按 id 决胜。输入顺序来自内核快照的 map 遍历,本身无序且每秒在变;
|
||||||
|
// 空闲连接的下载量普遍相同,没有决胜键的话行会在指针(和断开按钮)下面跳
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const main = key === 'download' ? b.download - a.download : age(b) - age(a)
|
||||||
|
return main !== 0 ? main : a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按主机名筛选。大小写不敏感,空串不筛。 */
|
/** 按主机名筛选。大小写不敏感,空串不筛。 */
|
||||||
|
|||||||
+12
-1
@@ -62,11 +62,22 @@ describe('规则字符串的拆分与拼装', () => {
|
|||||||
expect(parsed.target).toBe('PROXY')
|
expect(parsed.target).toBe('PROXY')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('结构化编辑只给常见形状,逻辑规则和子规则按原文编辑', () => {
|
it('正则类规则的顶层逗号不拆坏:最后一段是目标,其余全归载荷', () => {
|
||||||
|
// 内核对 DOMAIN-REGEX 这几类按「载荷可含逗号、不许带选项」读(rules/common/base.go:47-79)
|
||||||
|
expect(parseRule('DOMAIN-REGEX,^(a|b),c$,PROXY')).toEqual({
|
||||||
|
type: 'DOMAIN-REGEX',
|
||||||
|
payload: '^(a|b),c$',
|
||||||
|
target: 'PROXY',
|
||||||
|
extras: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('结构化编辑只给常见形状,逻辑规则、子规则和正则类按原文编辑', () => {
|
||||||
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
|
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
|
||||||
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
|
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
|
||||||
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
|
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
|
||||||
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
|
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
|
||||||
|
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
|
||||||
expect(isStructuredEditable('孤零零一段')).toBe(false)
|
expect(isStructuredEditable('孤零零一段')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-6
@@ -22,9 +22,15 @@ export interface ParsedRule {
|
|||||||
export function parseRule(rule: string): ParsedRule {
|
export function parseRule(rule: string): ParsedRule {
|
||||||
const fields = splitRuleFields(rule)
|
const fields = splitRuleFields(rule)
|
||||||
const type = fields[0] ?? ''
|
const type = fields[0] ?? ''
|
||||||
if (type.toUpperCase() === 'MATCH') {
|
const upper = type.toUpperCase()
|
||||||
|
if (upper === 'MATCH') {
|
||||||
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
|
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
|
||||||
}
|
}
|
||||||
|
// 载荷可含顶层逗号的类型,内核把最后一段读成目标、其余全归载荷、不许带选项
|
||||||
|
// (rules/common/base.go:47-79)。按普通形状拆会把正则截一半当目标。
|
||||||
|
if (RAW_ONLY_TYPES.has(upper) && fields.length >= 3) {
|
||||||
|
return { type, payload: fields.slice(1, -1).join(','), target: fields.at(-1) ?? null, extras: [] }
|
||||||
|
}
|
||||||
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
|
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +44,19 @@ export function buildRule(parsed: ParsedRule): string {
|
|||||||
/**
|
/**
|
||||||
* 界面能按「类型,载荷,目标」结构化编辑的规则。
|
* 界面能按「类型,载荷,目标」结构化编辑的规则。
|
||||||
*
|
*
|
||||||
* 逻辑规则和子规则的结构不是这个形状,让它们进表单只会把原文拆坏,按整行编辑。
|
* 这份清单必须和内核 `rules/common/base.go:56` 的 `payloadCanContainComma` 一致:
|
||||||
|
* 那些类型内核按「载荷可含逗号、最后一段是目标、不许带选项」读,按普通形状拆的话,
|
||||||
|
* 正则里的顶层逗号会被当成字段分隔,显示错的目标、编辑后悄悄改变语义。
|
||||||
*/
|
*/
|
||||||
const RAW_ONLY_TYPES = new Set(['AND', 'OR', 'NOT', 'SUB-RULE'])
|
const RAW_ONLY_TYPES = new Set([
|
||||||
|
'AND',
|
||||||
|
'OR',
|
||||||
|
'NOT',
|
||||||
|
'SUB-RULE',
|
||||||
|
'DOMAIN-REGEX',
|
||||||
|
'PROCESS-NAME-REGEX',
|
||||||
|
'PROCESS-PATH-REGEX',
|
||||||
|
])
|
||||||
|
|
||||||
export function isStructuredEditable(rule: string): boolean {
|
export function isStructuredEditable(rule: string): boolean {
|
||||||
const parsed = parseRule(rule)
|
const parsed = parseRule(rule)
|
||||||
@@ -80,9 +96,10 @@ export interface RuleIssue {
|
|||||||
/**
|
/**
|
||||||
* 找出引用了不存在的出站或规则组的规则。
|
* 找出引用了不存在的出站或规则组的规则。
|
||||||
*
|
*
|
||||||
* 内核自带的校验命令不检查这类引用,只在有流量经过时才报错,所以保存前的自检
|
* 内核在配置解析时就会拒绝这类引用(config/config.go:1102-1118),坏配置最终会被
|
||||||
* (vendor 的 validateDocument)和界面上的标注都要自己查。SUB-RULE 的目标段是
|
* 校验重载链拦下并还原;这里的标注是为了让人在保存前就看见、改得快,而 vendor 自检
|
||||||
* 子规则集合名而不是出站,跳过——那是路由页的段落,这里不认。
|
* 对规则目标只给 warning 不拦保存。SUB-RULE 的目标段是子规则集合名而不是出站,
|
||||||
|
* 跳过——那是路由页的段落,这里不认。
|
||||||
*/
|
*/
|
||||||
export function findRuleIssues(
|
export function findRuleIssues(
|
||||||
rules: string[],
|
rules: string[],
|
||||||
|
|||||||
@@ -78,6 +78,9 @@
|
|||||||
* 只覆盖颜色变量,间距、字号与主题无关。
|
* 只覆盖颜色变量,间距、字号与主题无关。
|
||||||
*/
|
*/
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
|
/* 变量之外还要告诉浏览器本身:滚动条、select 弹层这些 UA 部件不看变量,只看 color-scheme */
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
--c-bg: #fbfbfc;
|
--c-bg: #fbfbfc;
|
||||||
--c-surface: #ffffff;
|
--c-surface: #ffffff;
|
||||||
--c-surface-sunken: #f4f5f7;
|
--c-surface-sunken: #f4f5f7;
|
||||||
@@ -105,6 +108,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
--c-bg: #16181c;
|
--c-bg: #16181c;
|
||||||
--c-surface: #1d2025;
|
--c-surface: #1d2025;
|
||||||
--c-surface-sunken: #121417;
|
--c-surface-sunken: #121417;
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
|
|||||||
import IconClose from '~icons/ri/close-line'
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
const snapshot = ref<ConnectionSnapshot | null>(null)
|
const snapshot = ref<ConnectionSnapshot | null>(null)
|
||||||
|
/** 断开动作的失败。只被下一次动作覆盖,轮询不碰它 */
|
||||||
const failure = ref('')
|
const failure = ref('')
|
||||||
|
/** 轮询取数的失败。轮询自己成功时清掉 */
|
||||||
|
const pollFailure = ref('')
|
||||||
|
const shownFailure = computed(() => failure.value || pollFailure.value)
|
||||||
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
@@ -38,6 +42,10 @@ const askAll = ref(false)
|
|||||||
let fetching = false
|
let fetching = false
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示
|
||||||
|
* 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。
|
||||||
|
*/
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
|
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
|
||||||
if (!kernel.value.online || fetching) return
|
if (!kernel.value.online || fetching) return
|
||||||
@@ -45,9 +53,9 @@ async function refresh() {
|
|||||||
try {
|
try {
|
||||||
snapshot.value = await api.connections()
|
snapshot.value = await api.connections()
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
failure.value = ''
|
pollFailure.value = ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failure.value = error instanceof ApiError ? error.message : String(error)
|
pollFailure.value = error instanceof ApiError ? error.message : String(error)
|
||||||
} finally {
|
} finally {
|
||||||
fetching = false
|
fetching = false
|
||||||
}
|
}
|
||||||
@@ -144,8 +152,8 @@ async function closeAllConfirmed() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="failure" class="failure">
|
<div v-if="shownFailure" class="failure">
|
||||||
<strong>{{ failure }}</strong>
|
<strong>{{ shownFailure }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
|
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ function startStreams() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
// 正常结束也是断了:这两个流本身永不结束
|
// 正常结束也是断了:这两个流本身永不结束
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!ctrl.signal.aborted) streamFailed.value = true
|
if (ctrl.signal.aborted) return
|
||||||
|
// 一条断了把另一条也收掉:界面已经显示离线,不留一条看不见的活连接
|
||||||
|
ctrl.abort()
|
||||||
|
streamFailed.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
run('/traffic', (p) => {
|
run('/traffic', (p) => {
|
||||||
@@ -84,7 +87,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 内核从离线恢复(比如路由页保存配置触发的重载)时重新接上;掉线时收掉
|
// 内核从离线恢复(外壳的心跳每 10 秒探测一次)时重新接上;掉线时收掉
|
||||||
watch(
|
watch(
|
||||||
() => kernel.value.online,
|
() => kernel.value.online,
|
||||||
(online) => {
|
(online) => {
|
||||||
@@ -100,7 +103,24 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(stopStreams)
|
// 断流不等于内核没了:配置重载会掐断流,内核几秒内就回来、online 从未翻转。
|
||||||
|
// 稍后重试一次;再失败会重新走到这里,形成 5 秒一次的重试节奏,直到接上或离开页面
|
||||||
|
let retryTimer = 0
|
||||||
|
watch(streamFailed, (failed) => {
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
|
if (!failed) return
|
||||||
|
retryTimer = window.setTimeout(() => {
|
||||||
|
if (!kernel.value.online) return // 真离线交给上面那个 watch
|
||||||
|
streamFailed.value = false
|
||||||
|
startStreams()
|
||||||
|
void loadConfigs()
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopStreams()
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
|
})
|
||||||
|
|
||||||
const latest = computed(() => samples.value.at(-1) ?? null)
|
const latest = computed(() => samples.value.at(-1) ?? null)
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,23 @@ function flush() {
|
|||||||
void scrollToBottom()
|
void scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
|
||||||
|
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
|
||||||
|
* online 翻转的 watch。
|
||||||
|
*/
|
||||||
|
let retryTimer = 0
|
||||||
|
function scheduleRetry() {
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
|
retryTimer = window.setTimeout(() => {
|
||||||
|
if (kernel.value.online) void pump()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
async function pump() {
|
async function pump() {
|
||||||
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
|
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
|
||||||
aborter?.abort()
|
aborter?.abort()
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
aborter = controller
|
aborter = controller
|
||||||
failure.value = ''
|
failure.value = ''
|
||||||
@@ -88,10 +102,14 @@ async function pump() {
|
|||||||
for await (const line of streamLogs(level.value, controller.signal)) {
|
for await (const line of streamLogs(level.value, controller.signal)) {
|
||||||
pending.push({ ...line, id: nextId++, time: clock(new Date()) })
|
pending.push({ ...line, id: nextId++, time: clock(new Date()) })
|
||||||
}
|
}
|
||||||
if (!controller.signal.aborted) ended.value = true
|
if (!controller.signal.aborted) {
|
||||||
|
ended.value = true
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
failure.value = error instanceof Error ? error.message : String(error)
|
failure.value = error instanceof Error ? error.message : String(error)
|
||||||
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +134,7 @@ watch(level, () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
aborter?.abort()
|
aborter?.abort()
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const visible = computed(() => {
|
const visible = computed(() => {
|
||||||
@@ -164,7 +183,7 @@ const visible = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="ended && !failure" class="notice">日志流已中断。切换级别可重新连接。</p>
|
<p v-if="ended && !failure" class="notice">日志流已中断,将自动重连。</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const COMMON_TYPES = [
|
|||||||
'DOMAIN',
|
'DOMAIN',
|
||||||
'DOMAIN-SUFFIX',
|
'DOMAIN-SUFFIX',
|
||||||
'DOMAIN-KEYWORD',
|
'DOMAIN-KEYWORD',
|
||||||
'DOMAIN-REGEX',
|
|
||||||
'GEOSITE',
|
'GEOSITE',
|
||||||
'GEOIP',
|
'GEOIP',
|
||||||
'IP-CIDR',
|
'IP-CIDR',
|
||||||
|
|||||||
Reference in New Issue
Block a user