From 35eb46363318091195643b6980dca67681817a46 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 21:14:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E5=AE=A1=20P1=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=B8=80=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置:显式主题补 color-scheme,滚动条和 select 弹层这些 UA 部件不看 变量。连接:断开失败的提示与轮询取数分成两条通道,不再被下一秒的轮询 成功抹掉;排序加 id 决胜,内核快照的遍历序无序,空闲连接下载量普遍 相同,没有决胜键行会在断开按钮下面跳。仪表盘与请求:一条流断了连带 收掉同组连接;断流不等于内核没了(配置重载掐断流而 online 从未翻转), 5 秒自动重连。规则:正则类规则(DOMAIN-REGEX 等三种)按内核的 payloadCanContainComma 语义拆分与禁入结构化编辑,顶层逗号不再把正则 截一半当目标;findRuleIssues 注释里对内核校验的错误断言改正。 --- shared/connections.ts | 7 ++++++- shared/rules.test.ts | 13 ++++++++++++- shared/rules.ts | 29 +++++++++++++++++++++++------ web/styles/tokens.css | 5 +++++ web/views/ConnectionsPage.vue | 16 ++++++++++++---- web/views/DashboardPage.vue | 26 +++++++++++++++++++++++--- web/views/RequestsPage.vue | 23 +++++++++++++++++++++-- web/views/RuleForm.vue | 1 - 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/shared/connections.ts b/shared/connections.ts index accdc77..6aa0ffd 100644 --- a/shared/connections.ts +++ b/shared/connections.ts @@ -83,7 +83,12 @@ export function sortConnections( const started = Date.parse(c.start) 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) + }) } /** 按主机名筛选。大小写不敏感,空串不筛。 */ diff --git a/shared/rules.test.ts b/shared/rules.test.ts index 5a14d02..e1d3f2e 100644 --- a/shared/rules.test.ts +++ b/shared/rules.test.ts @@ -62,11 +62,22 @@ describe('规则字符串的拆分与拼装', () => { 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('MATCH,PROXY')).toBe(true) 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('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false) expect(isStructuredEditable('孤零零一段')).toBe(false) }) }) diff --git a/shared/rules.ts b/shared/rules.ts index a0b23c3..1ab4673 100644 --- a/shared/rules.ts +++ b/shared/rules.ts @@ -22,9 +22,15 @@ export interface ParsedRule { export function parseRule(rule: string): ParsedRule { const fields = splitRuleFields(rule) 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) } } + // 载荷可含顶层逗号的类型,内核把最后一段读成目标、其余全归载荷、不许带选项 + // (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) } } @@ -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 { const parsed = parseRule(rule) @@ -80,9 +96,10 @@ export interface RuleIssue { /** * 找出引用了不存在的出站或规则组的规则。 * - * 内核自带的校验命令不检查这类引用,只在有流量经过时才报错,所以保存前的自检 - * (vendor 的 validateDocument)和界面上的标注都要自己查。SUB-RULE 的目标段是 - * 子规则集合名而不是出站,跳过——那是路由页的段落,这里不认。 + * 内核在配置解析时就会拒绝这类引用(config/config.go:1102-1118),坏配置最终会被 + * 校验重载链拦下并还原;这里的标注是为了让人在保存前就看见、改得快,而 vendor 自检 + * 对规则目标只给 warning 不拦保存。SUB-RULE 的目标段是子规则集合名而不是出站, + * 跳过——那是路由页的段落,这里不认。 */ export function findRuleIssues( rules: string[], diff --git a/web/styles/tokens.css b/web/styles/tokens.css index 546d6bc..8b63d9a 100644 --- a/web/styles/tokens.css +++ b/web/styles/tokens.css @@ -78,6 +78,9 @@ * 只覆盖颜色变量,间距、字号与主题无关。 */ :root[data-theme='light'] { + /* 变量之外还要告诉浏览器本身:滚动条、select 弹层这些 UA 部件不看变量,只看 color-scheme */ + color-scheme: light; + --c-bg: #fbfbfc; --c-surface: #ffffff; --c-surface-sunken: #f4f5f7; @@ -105,6 +108,8 @@ } :root[data-theme='dark'] { + color-scheme: dark; + --c-bg: #16181c; --c-surface: #1d2025; --c-surface-sunken: #121417; diff --git a/web/views/ConnectionsPage.vue b/web/views/ConnectionsPage.vue index f51041b..ab956d0 100644 --- a/web/views/ConnectionsPage.vue +++ b/web/views/ConnectionsPage.vue @@ -25,7 +25,11 @@ import ConfirmDialog from '../components/ConfirmDialog.vue' import IconClose from '~icons/ri/close-line' const snapshot = ref(null) +/** 断开动作的失败。只被下一次动作覆盖,轮询不碰它 */ const failure = ref('') +/** 轮询取数的失败。轮询自己成功时清掉 */ +const pollFailure = ref('') +const shownFailure = computed(() => failure.value || pollFailure.value) /** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */ const now = ref(Date.now()) const query = ref('') @@ -38,6 +42,10 @@ const askAll = ref(false) let fetching = false let timer: number | undefined +/** + * 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示 + * 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。 + */ async function refresh() { // 内核不在线时不打这个接口:得不到数据,界面直接说原因 if (!kernel.value.online || fetching) return @@ -45,9 +53,9 @@ async function refresh() { try { snapshot.value = await api.connections() now.value = Date.now() - failure.value = '' + pollFailure.value = '' } catch (error) { - failure.value = error instanceof ApiError ? error.message : String(error) + pollFailure.value = error instanceof ApiError ? error.message : String(error) } finally { fetching = false } @@ -144,8 +152,8 @@ async function closeAllConfirmed() { -
- {{ failure }} +
+ {{ shownFailure }}

内核未连接,看不到当前有哪些连接。

diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue index 3395b28..2ab2167 100644 --- a/web/views/DashboardPage.vue +++ b/web/views/DashboardPage.vue @@ -51,7 +51,10 @@ function startStreams() { .catch(() => {}) // 正常结束也是断了:这两个流本身永不结束 .finally(() => { - if (!ctrl.signal.aborted) streamFailed.value = true + if (ctrl.signal.aborted) return + // 一条断了把另一条也收掉:界面已经显示离线,不留一条看不见的活连接 + ctrl.abort() + streamFailed.value = true }) run('/traffic', (p) => { @@ -84,7 +87,7 @@ onMounted(() => { } }) -// 内核从离线恢复(比如路由页保存配置触发的重载)时重新接上;掉线时收掉 +// 内核从离线恢复(外壳的心跳每 10 秒探测一次)时重新接上;掉线时收掉 watch( () => kernel.value.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) diff --git a/web/views/RequestsPage.vue b/web/views/RequestsPage.vue index 0a6efad..fec7038 100644 --- a/web/views/RequestsPage.vue +++ b/web/views/RequestsPage.vue @@ -77,9 +77,23 @@ function flush() { 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() { // 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒 aborter?.abort() + window.clearTimeout(retryTimer) const controller = new AbortController() aborter = controller failure.value = '' @@ -88,10 +102,14 @@ async function pump() { for await (const line of streamLogs(level.value, controller.signal)) { 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) { if (controller.signal.aborted) return failure.value = error instanceof Error ? error.message : String(error) + scheduleRetry() } } @@ -116,6 +134,7 @@ watch(level, () => { onBeforeUnmount(() => { aborter?.abort() window.clearInterval(timer) + window.clearTimeout(retryTimer) }) const visible = computed(() => { @@ -164,7 +183,7 @@ const visible = computed(() => {
-

日志流已中断。切换级别可重新连接。

+

日志流已中断,将自动重连。

diff --git a/web/views/RuleForm.vue b/web/views/RuleForm.vue index 9adcdb1..97c9beb 100644 --- a/web/views/RuleForm.vue +++ b/web/views/RuleForm.vue @@ -29,7 +29,6 @@ const COMMON_TYPES = [ 'DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', - 'DOMAIN-REGEX', 'GEOSITE', 'GEOIP', 'IP-CIDR',