新增请求页:实时显示内核日志流

流式接口不能走 api.ts 的 request()(它会把整个响应读完),页面自己
fetch 加 stream.ts 按行读。解析和追加上限放在 web/logs.ts 里,因为
「脏行跳过」「超上限丢最旧的」是点名要测的行为,埋在组件里测不到。

收到的行先进缓冲、每 200ms 批量刷进列表;暂停就是不刷但照收,
缓冲也设上限,恢复时补上。级别是建连时定的,切换即重连。
This commit is contained in:
2026-08-11 18:49:59 +08:00
parent 6c8a1c4e0e
commit 0eb8d05ed8
3 changed files with 476 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* 内核日志流。
*
* 接口是 `GET /api/kernel/logs?level=...`,一条永不结束的流,一行一个 JSON,
* 形如 `{ "type": "info", "payload": "..." }`。所以不能走 api.ts 的 request()——
* 那个会把整个响应读完,对流等于挂死。这里自己 fetch,按行读用 stream.ts。
*
* 解析和成批追加放在组件外面,是因为「脏行跳过」「超上限丢最旧的」这两条
* 是验收里点名要测的行为,埋在 .vue 里测不到。
*/
import { getToken } from './api'
import { readLines } from './stream'
/** 内核接受的日志级别。连接建立时定死,中途改不了,所以切换等于重连。 */
export const LEVELS = ['debug', 'info', 'warning', 'error', 'silent'] as const
export type LogLevel = (typeof LEVELS)[number]
/**
* 页面上保留的最近条数。日志是无限流,不设上限内存会一直涨,
* 而目标机器只有 1-2G。超出就丢最旧的。
*/
export const LOG_CAP = 1000
export interface LogLine {
type: string
payload: string
}
/** 解析一行日志。脏行(不是 JSON、形状不对)返回 null,由调用方跳过,不能抛。 */
export function parseLogLine(line: string): LogLine | null {
let value: unknown
try {
value = JSON.parse(line)
} catch {
return null
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
const { type, payload } = value as Record<string, unknown>
if (typeof type !== 'string' || typeof payload !== 'string') return null
return { type, payload }
}
/** 把一批新条目追加进列表,超出上限从头部丢最旧的。 */
export function pushCapped<T>(list: T[], items: T[], cap: number): void {
list.push(...items)
if (list.length > cap) list.splice(0, list.length - cap)
}
export class LogStreamError extends Error {
constructor(message: string) {
super(message)
this.name = 'LogStreamError'
}
}
/**
* 连上内核的日志流,逐条产出。停止的方式是中止 signal。
*
* 流正常结束(for await 走完)意味着内核那边断了连接,比如内核重启——
* 调用方要把它和「主动中止」区分开,前者该告诉用户。
*/
export async function* streamLogs(
level: LogLevel,
signal: AbortSignal,
): AsyncGenerator<LogLine> {
const res = await fetch(`/api/kernel/logs?level=${level}`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal,
})
if (!res.ok) {
// 失败时后端回的是一次性的 { message, hint? },不是流
let message = `日志接口请求失败(HTTP ${String(res.status)})`
try {
const payload = JSON.parse(await res.text()) as Record<string, unknown>
if (typeof payload.message === 'string') message = payload.message
} catch {
// 保留默认消息
}
throw new LogStreamError(message)
}
if (!res.body) throw new LogStreamError('日志接口没有返回数据流')
for await (const line of readLines(res.body)) {
const entry = parseLogLine(line)
if (entry) yield entry
}
}