+
{{ current?.label }} 尚未实现
+
+
+
+
+
+
+
+
diff --git a/web/api.ts b/web/api.ts
index ab74332..9acb363 100644
--- a/web/api.ts
+++ b/web/api.ts
@@ -124,7 +124,7 @@ export interface ListenerFields {
export interface RuleProviderInfo {
/** domain / ipcidr / classical */
behavior: string
- /** yaml / text / mrs。inline 类型的没有这个字段 */
+ /** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */
format?: string
name: string
/** 当前装了多少条规则 */
diff --git a/web/logs.test.ts b/web/logs.test.ts
index 0e72122..3d00765 100644
--- a/web/logs.test.ts
+++ b/web/logs.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it, mock } from 'bun:test'
+import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test'
import { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts'
import { LogStreamError } from './logs.ts'
@@ -49,8 +49,10 @@ function streamOf(chunks: string[]): ReadableStream
{
const realFetch = globalThis.fetch
-// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌
-if (typeof globalThis.localStorage === 'undefined') {
+// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。
+// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件
+const hadStorage = typeof globalThis.localStorage !== 'undefined'
+if (!hadStorage) {
;(globalThis as Record).localStorage = {
getItem: () => '',
setItem: () => {},
@@ -58,6 +60,10 @@ if (typeof globalThis.localStorage === 'undefined') {
}
}
+afterAll(() => {
+ if (!hadStorage) delete (globalThis as Record).localStorage
+})
+
afterEach(() => {
globalThis.fetch = realFetch
})
diff --git a/web/telemetry.ts b/web/telemetry.ts
index ce2789c..55ac3e0 100644
--- a/web/telemetry.ts
+++ b/web/telemetry.ts
@@ -65,7 +65,14 @@ async function consume(
) {
const body = await api.openStream(path, ctrl.signal)
for await (const line of readLines(body)) {
- onLine(JSON.parse(line) as Record)
+ // 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环
+ let payload: Record
+ try {
+ payload = JSON.parse(line) as Record
+ } catch {
+ continue
+ }
+ onLine(payload)
}
}
diff --git a/web/views/ConnectionsPage.vue b/web/views/ConnectionsPage.vue
index 209af9b..14b1784 100644
--- a/web/views/ConnectionsPage.vue
+++ b/web/views/ConnectionsPage.vue
@@ -6,7 +6,7 @@
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
* 里回答,这里只管取数、状态和断开动作。
*/
-import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import {
chainLabel,
@@ -62,11 +62,17 @@ async function refresh() {
}
}
-onMounted(() => {
+// 组件包在 KeepAlive 里:首次挂载和每次切回都走 activated(挂载时它也会触发,
+// 不能再用 onMounted 起轮询,否则会叠两个定时器),切走时停掉轮询不白打后端
+onActivated(() => {
void refresh()
timer = window.setInterval(() => void refresh(), 1000)
})
+onDeactivated(() => {
+ window.clearInterval(timer)
+})
+
onUnmounted(() => {
window.clearInterval(timer)
})
diff --git a/web/views/RequestsPage.vue b/web/views/RequestsPage.vue
index 88d5a8c..12cf358 100644
--- a/web/views/RequestsPage.vue
+++ b/web/views/RequestsPage.vue
@@ -5,7 +5,7 @@
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
* 这个组件只管呈现:筛选、暂停、清空、跟随滚动。
*/
-import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue'
import { kernel } from '../runtime'
import { LEVELS } from '../logs'
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
@@ -34,7 +34,9 @@ let follow = true
function onScroll() {
const el = listEl.value
if (!el) return
- follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
+ // 阈值必须小于一行的高度(约 25px),否则向上滚不足一行仍算「在底部」,
+ // 新日志会把人拽回去。8px 只容浏览器的取整误差
+ follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
}
async function scrollToBottom(force = false) {
@@ -49,6 +51,9 @@ watch(revision, () => void scrollToBottom())
// 切回这一页时接上离开时的进度,直接落到最新一条
onMounted(() => void scrollToBottom(true))
+// 组件包在 KeepAlive 里,切走期间元素脱离文档、scrollHeight 不可靠,
+// 跟随模式的自动滚动可能把位置写歪;切回来重新贴底(手动上滚过的不动)
+onActivated(() => void scrollToBottom())
const visible = computed(() => {
const q = query.value.trim().toLowerCase()
@@ -94,7 +99,11 @@ const visible = computed(() => {
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
- 暂无日志。没有流量经过内核时不会产生请求记录。
+ {{
+ level === 'silent'
+ ? '级别为 silent 时内核不输出日志。'
+ : '暂无日志。没有流量经过内核时不会产生请求记录。'
+ }}
没有包含「{{ query.trim() }}」的日志。
diff --git a/web/views/RulesPage.vue b/web/views/RulesPage.vue
index b610711..84b21c0 100644
--- a/web/views/RulesPage.vue
+++ b/web/views/RulesPage.vue
@@ -257,11 +257,16 @@ async function submitGroup(entry: {
async function submitImport(plan: IniImportPlan) {
const next = clone()
setRuleProviders(next, { ...ruleProvidersOf(next), ...plan.providers })
- setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...plan.finals])
- const added = plan.rules.length + plan.finals.length
+ // 表里已有 MATCH 时丢弃配方的兜底:那是你自己安排的分流终点,配方不该
+ // 偷偷替换;追加在 MATCH 之后则永远是死行
+ const keepFinals = matchAt.value === null
+ const finals = keepFinals ? plan.finals : []
+ setRules(next, [...insertBeforeMatch(rules.value, plan.rules), ...finals])
+ const added = plan.rules.length + finals.length
+ const dropped = keepFinals ? '' : ';配方的兜底规则未导入,你已有自己的 MATCH'
const ok = await commit(
next,
- `已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则`,
+ `已导入 ${Object.keys(plan.providers).length} 个规则组、${String(added)} 条规则${dropped}`,
)
if (ok) {
importOpen.value = false
@@ -536,7 +541,7 @@ async function updateNow(name: string) {
{{ rule }}