流式接口不能走 api.ts 的 request()(它会把整个响应读完),页面自己 fetch 加 stream.ts 按行读。解析和追加上限放在 web/logs.ts 里,因为 「脏行跳过」「超上限丢最旧的」是点名要测的行为,埋在组件里测不到。 收到的行先进缓冲、每 200ms 批量刷进列表;暂停就是不刷但照收, 缓冲也设上限,恢复时补上。级别是建连时定的,切换即重连。
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
/**
|
||
* 内核日志流。
|
||
*
|
||
* 接口是 `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
|
||
}
|
||
}
|