设置:显式主题补 color-scheme,滚动条和 select 弹层这些 UA 部件不看 变量。连接:断开失败的提示与轮询取数分成两条通道,不再被下一秒的轮询 成功抹掉;排序加 id 决胜,内核快照的遍历序无序,空闲连接下载量普遍 相同,没有决胜键行会在断开按钮下面跳。仪表盘与请求:一条流断了连带 收掉同组连接;断流不等于内核没了(配置重载掐断流而 online 从未翻转), 5 秒自动重连。规则:正则类规则(DOMAIN-REGEX 等三种)按内核的 payloadCanContainComma 语义拆分与禁入结构化编辑,顶层逗号不再把正则 截一半当目标;findRuleIssues 注释里对内核校验的错误断言改正。
303 lines
7.8 KiB
Vue
303 lines
7.8 KiB
Vue
<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()
|
||
}
|
||
|
||
/**
|
||
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、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 = ''
|
||
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
|
||
scheduleRetry()
|
||
}
|
||
} catch (error) {
|
||
if (controller.signal.aborted) return
|
||
failure.value = error instanceof Error ? error.message : String(error)
|
||
scheduleRetry()
|
||
}
|
||
}
|
||
|
||
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)
|
||
window.clearTimeout(retryTimer)
|
||
})
|
||
|
||
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>
|