Files
mihomo-patchbay/web/views/RequestsPage.vue
T
im0o 0eb8d05ed8 新增请求页:实时显示内核日志流
流式接口不能走 api.ts 的 request()(它会把整个响应读完),页面自己
fetch 加 stream.ts 按行读。解析和追加上限放在 web/logs.ts 里,因为
「脏行跳过」「超上限丢最旧的」是点名要测的行为,埋在组件里测不到。

收到的行先进缓冲、每 200ms 批量刷进列表;暂停就是不刷但照收,
缓冲也设上限,恢复时补上。级别是建连时定的,切换即重连。
2026-08-11 18:49:59 +08:00

284 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 请求页:内核的日志流,一条一条「这次请求匹配了哪条规则、走了哪个出站」。
*
* 编排只有三件事:
* 1. 流的生命周期——进页面开始读,离开页面中止;切换级别断开重连,
* 因为内核的级别是建立连接时定的,中途改不了
* 2. 渲染节奏——收到的行先进缓冲,定时一批批刷进列表;暂停就是不刷,
* 缓冲照收(也设上限),恢复时补上
* 3. 滚动——默认跟着最新条目,用户往上滚就停,滚回底部恢复
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { kernel } from '../runtime'
import { LEVELS, LOG_CAP, pushCapped, streamLogs } from '../logs'
import type { LogLevel, LogLine } from '../logs'
import IconPause from '~icons/ri/pause-line'
import IconPlay from '~icons/ri/play-line'
interface Row extends LogLine {
id: number
/** 收到那一刻的时间。日志行本身不带时间戳 */
time: string
}
const level = ref<LogLevel>('info')
const query = ref('')
const paused = ref(false)
const rows = ref<Row[]>([])
const failure = ref('')
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
const ended = ref(false)
/** 级别标签的着色。只染级别那一小块,整行染色在低密度的设计里很吵 */
const LEVEL_CLASS: Record<string, string> = {
debug: 'lv-debug',
info: 'lv-info',
warning: 'lv-warn',
error: 'lv-error',
}
let pending: Row[] = []
let nextId = 0
let aborter: AbortController | null = null
let timer = 0
const clock = (d: Date): string =>
[d.getHours(), d.getMinutes(), d.getSeconds()]
.map((n) => String(n).padStart(2, '0'))
.join(':')
const listEl = ref<HTMLElement | null>(null)
/** 是否跟随最新条目。用户手动上滚时关掉,滚回底部再开 */
let follow = true
function onScroll() {
const el = listEl.value
if (!el) return
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
}
async function scrollToBottom() {
if (!follow) return
await nextTick()
const el = listEl.value
if (el) el.scrollTop = el.scrollHeight
}
function flush() {
if (paused.value) {
// 暂停时继续接收但不渲染。缓冲也设上限,暂停再久内存也不涨
if (pending.length > LOG_CAP) pending.splice(0, pending.length - LOG_CAP)
return
}
if (pending.length === 0) return
pushCapped(rows.value, pending, LOG_CAP)
pending = []
void scrollToBottom()
}
async function pump() {
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
aborter?.abort()
const controller = new AbortController()
aborter = controller
failure.value = ''
ended.value = false
try {
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
} catch (error) {
if (controller.signal.aborted) return
failure.value = error instanceof Error ? error.message : String(error)
}
}
onMounted(() => {
timer = window.setInterval(flush, 200)
if (kernel.value.online) void pump()
})
// 挂载时内核不在线就不连;等它上线再开始。反方向(流到一半内核掉了)
// 由流自己报错或结束来呈现
watch(
() => kernel.value.online,
(online) => {
if (online) void pump()
},
)
watch(level, () => {
if (kernel.value.online) void pump()
})
onBeforeUnmount(() => {
aborter?.abort()
window.clearInterval(timer)
})
const visible = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter((row) => row.payload.toLowerCase().includes(q))
})
</script>
<template>
<div class="page">
<header class="head">
<select v-model="level" class="ctl" aria-label="日志级别">
<option v-for="l in LEVELS" :key="l" :value="l">{{ l }}</option>
</select>
<input v-model="query" class="ctl search" type="search" placeholder="筛选" />
<button class="ctl pause" type="button" @click="paused = !paused">
<IconPlay v-if="paused" />
<IconPause v-else />
<span>{{ paused ? '继续' : '暂停' }}</span>
</button>
</header>
<div v-if="failure" class="failure">
<strong>{{ failure }}</strong>
</div>
<div ref="listEl" class="log" @scroll="onScroll">
<p v-if="!kernel.online" class="state">内核未连接,无法读取日志。</p>
<template v-else>
<div v-for="row in visible" :key="row.id" class="row">
<span class="time mono">{{ row.time }}</span>
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
<span class="msg mono">{{ row.payload }}</span>
</div>
<p v-if="rows.length === 0 && !failure && !ended" class="state">
暂无日志。没有流量经过内核时不会产生请求记录。
</p>
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
没有包含「{{ query.trim() }}」的日志。
</p>
</template>
</div>
<p v-if="ended && !failure" class="notice">日志流已中断。切换级别可重新连接。</p>
</div>
</template>
<style scoped>
/* 日志区自己滚动、撑满视口剩余高度:自动跟随需要它有自己的滚动容器 */
.page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--s-6) - var(--s-7));
}
.head {
display: flex;
align-items: center;
gap: var(--s-3);
margin-bottom: var(--s-4);
}
.ctl {
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
}
.search {
flex: 1;
max-width: 320px;
}
.pause {
display: flex;
align-items: center;
gap: var(--s-2);
margin-left: auto;
cursor: pointer;
}
.pause:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.failure {
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.log {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-3) var(--s-4);
}
.row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--s-3);
align-items: baseline;
padding: 2px 0;
font-size: var(--t-sm);
line-height: 1.6;
}
.time {
color: var(--c-text-faint);
}
.lv {
padding: 0 var(--s-2);
border-radius: var(--r-sm);
font-size: var(--t-xs);
text-align: center;
}
.lv-debug {
background: var(--c-surface-sunken);
color: var(--c-text-faint);
}
.lv-info {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.lv-warn {
background: var(--c-warn-soft);
color: var(--c-warn);
}
.lv-error {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.msg {
word-break: break-all;
white-space: pre-wrap;
}
.state {
color: var(--c-text-faint);
font-size: var(--t-sm);
padding: var(--s-3) 0;
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-top: var(--s-3);
}
</style>