新增请求页:实时显示内核日志流
流式接口不能走 api.ts 的 request()(它会把整个响应读完),页面自己 fetch 加 stream.ts 按行读。解析和追加上限放在 web/logs.ts 里,因为 「脏行跳过」「超上限丢最旧的」是点名要测的行为,埋在组件里测不到。 收到的行先进缓冲、每 200ms 批量刷进列表;暂停就是不刷但照收, 缓冲也设上限,恢复时补上。级别是建连时定的,切换即重连。
This commit is contained in:
+87
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user