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

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

88 lines
3.0 KiB
TypeScript
Raw Permalink 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.
/**
* 内核日志流。
*
* 接口是 `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
}
}