Files
Migu2.0/frontends/apps/simple-platform-vue/src/api/logs.ts
T
zhaowei.huangandCursor 991502b4b1 feat(web/logs): 日志管理页与 JSON 折叠组件
新增日志管理页(实时诊断/文件浏览/日志分析三区,echarts 按需引入绘制标签分布、日志量、字段时序图,
图表实例与定时器在卸载时释放);新增 api/logs 封装后端日志接口;新增通用 JsonFold 折叠查看组件
(折叠子树惰性渲染 v-if,避免大 JSON 一次性铺满 DOM)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:11:20 +08:00

403 lines
14 KiB
TypeScript
Raw 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.
import http from './http'
/**
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController``/api/logs/*`)。
*
* 数据是 SimpleLite 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
* 「按标签合订」/ 原文 / 下载。
*
* 与 config.ts 一致直接返回数据对象(非 reflection 的 success/data 信封);错误由 http.ts
* 拦截器翻译成中文。VITE_USE_MOCK=true 时返回内置样例,便于无后端调试界面。
*/
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
const SL_DIAG = '/sl/projection/diagnosis'
export interface LogEntry {
lineNo: number
/** ISO 时间;无法解析时间戳的续行/异常行为 null。 */
time: string | null
prefix: string
/** 空串表示无标签(滚动记录)。 */
tag: string
content: string
}
export interface LogFile {
/** 相对日志根的路径,如 `2026-06-01/20260601-12Q(30).log`,作为其它接口的 file 参数。 */
rel: string
name: string
/** 一级目录(通常是日期),无子目录时为「(根目录)」。 */
day: string
dir: string
bytes: number
mtime: string
}
export interface LogDayStat {
day: string
files: number
bytes: number
}
export interface LogOverview {
exists: boolean
root: string | null
workingDirectory: string | null
totalFiles?: number
totalBytes?: number
latestFileTime?: string | null
days?: LogDayStat[]
message?: string
}
export interface LogFilesResult {
root: string
total: number
returned: number
files: LogFile[]
}
export interface LogEntriesResult {
file: string
bytes: number
scannedLines: number
truncated: boolean
total: number
offset: number
limit: number
order: string
entries: LogEntry[]
}
/** 合订本「一册」:同一标签聚合后的视图。 */
export interface LogBook {
tag: string
count: number
firstTime?: string | null
lastTime?: string | null
latest?: string
latestTime?: string | null
entries: LogEntry[]
}
export interface LogDigest {
source: 'file' | 'day' | string
target: string
files: number
truncated: boolean
tagCount: number
untaggedCount: number
books: LogBook[]
untagged: LogBook
}
export interface EntriesQuery {
file: string
keyword?: string
tag?: string
onlyTagged?: boolean
order?: 'asc' | 'desc'
limit?: number
offset?: number
}
export interface DigestQuery {
file?: string
day?: string
keyword?: string
maxPerTag?: number
}
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
export interface LiveDiagItem {
index: number
time: string
/** 空串=无标签(滚动记录);非空=按标签合订(同标签仅留最新一条)。 */
tag: string
tagged: boolean
content: string
}
export interface LiveDiagnosis {
serverTime: string
total: number
taggedCount: number
untaggedCount: number
items: LiveDiagItem[]
}
/** 目录浏览:子文件夹。 */
export interface BrowseDir {
name: string
rel: string
mtime: string
dirCount: number
fileCount: number
}
/** 目录浏览:文件。 */
export interface BrowseFile {
name: string
rel: string
bytes: number
mtime: string
isLog: boolean
}
export interface BrowseResult {
root: string
exists: boolean
/** 当前相对路径(""=日志根)。 */
path: string
/** 上一级相对路径;位于根时为 null。 */
parent: string | null
dirCount: number
fileCount: number
dirs: BrowseDir[]
files: BrowseFile[]
message?: string
}
// ───────────────────────────────────────────────── 日志分析器 ──
/** 标签分布一项。 */
export interface AnalyzeTag { tag: string; count: number; percent: number }
/** 日志量直方图:稀疏桶(仅含有数据的时刻)+ Top 标签拆分(与 buckets 等长对齐)。 */
export interface AnalyzeVolume {
granularity: string
buckets: string[]
total: number[]
topTags: Array<{ tag: string; counts: number[] }>
}
/** 识别出的数值字段统计。 */
export interface AnalyzeField {
name: string
samples: number
min: number
max: number
avg: number
last: number
}
/** 选定字段的时序点序列。 */
export interface AnalyzeSeries {
field: string
tag: string
count: number
points: Array<{ t: string; v: number }>
}
export interface AnalyzeResult {
source: string
target: string
files: number
truncated: boolean
total: number
timeRange: { start: string | null; end: string | null }
granularity: string
tags: AnalyzeTag[]
volume: AnalyzeVolume
fields: AnalyzeField[]
series: AnalyzeSeries | null
}
export interface AnalyzeQuery {
file?: string
day?: string
granularity?: 'second' | 'minute' | 'hour'
tag?: string
keyword?: string
field?: string
}
// ───────────────────────────────────────────────────────────── mock ──
function mockOverview(): LogOverview {
return {
exists: true,
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
totalFiles: 3,
totalBytes: 5_233_649,
latestFileTime: new Date().toISOString(),
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
}
}
function mockFiles(): LogFilesResult {
const files: LogFile[] = [
{ rel: '2026-06-01/20260601-12Q(30).log', name: '20260601-12Q(30).log', day: '2026-06-01', dir: '2026-06-01', bytes: 5_223_512, mtime: '2026-06-01T12:44:59' },
{ rel: '2026-06-01/20260601-12Q(15).log', name: '20260601-12Q(15).log', day: '2026-06-01', dir: '2026-06-01', bytes: 1188, mtime: '2026-06-01T12:21:44' },
{ rel: '2026-06-01/20260601-08Q(45).log', name: '20260601-08Q(45).log', day: '2026-06-01', dir: '2026-06-01', bytes: 949, mtime: '2026-06-01T08:47:20' }
]
return { root: 'mock', total: files.length, returned: files.length, files }
}
function mockEntries(q: EntriesQuery): LogEntriesResult {
const base: LogEntry[] = [
{ lineNo: 1, time: '2026-06-01T12:21:40.120', prefix: '', tag: 'Persistence', content: 'worker started, schema=1.2' },
{ lineNo: 2, time: '2026-06-01T12:21:41.330', prefix: '', tag: '', content: 'loaded 12 sites, 18 tracks' },
{ lineNo: 3, time: '2026-06-01T12:21:42.880', prefix: '', tag: 'Dispatch', content: 'dispatch loop 50Hz online' },
{ lineNo: 4, time: '2026-06-01T12:21:44.010', prefix: '', tag: 'Persistence', content: 'flush 4 entities ok' },
{ lineNo: 5, time: '2026-06-01T12:21:45.220', prefix: '', tag: 'UI-Error', content: 'panel repaint skipped (terminal closing)' }
]
let list = base
if (q.onlyTagged) list = list.filter((e) => e.tag)
if (q.tag) list = list.filter((e) => e.tag.includes(q.tag!))
if (q.keyword) list = list.filter((e) => e.content.includes(q.keyword!) || e.tag.includes(q.keyword!))
if (q.order !== 'asc') list = [...list].reverse()
return { file: q.file, bytes: 5_223_512, scannedLines: base.length, truncated: false, total: list.length, offset: 0, limit: 300, order: q.order ?? 'desc', entries: list }
}
function mockDigest(): LogDigest {
const mk = (tag: string, n: number, latest: string): LogBook => ({
tag, count: n, firstTime: '2026-06-01T12:00:00', lastTime: '2026-06-01T12:44:00',
latest, latestTime: '2026-06-01T12:44:00',
entries: Array.from({ length: Math.min(n, 3) }, (_, i) => ({
lineNo: i + 1, time: '2026-06-01T12:4' + i + ':00.000', prefix: '', tag, content: `${latest} #${i + 1}`
}))
})
return {
source: 'file', target: 'mock', files: 1, truncated: false, tagCount: 3, untaggedCount: 42,
books: [mk('Persistence', 128, 'flush ok'), mk('Dispatch', 64, 'loop tick'), mk('UI-Error', 3, 'repaint skipped')],
untagged: { tag: '', count: 42, latest: 'misc rolling line', entries: [
{ lineNo: 2, time: '2026-06-01T12:21:41.330', prefix: '', tag: '', content: 'loaded 12 sites, 18 tracks' }
] }
}
}
// ─────────────────────────────────────────────────────────── client ──
function mockLiveDiagnosis(): LiveDiagnosis {
const now = new Date()
const iso = (sec: number) => new Date(now.getTime() - sec * 1000).toISOString().slice(0, 23)
const items: LiveDiagItem[] = [
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
]
const taggedCount = items.filter((i) => i.tagged).length
return { serverTime: now.toISOString().slice(0, 23), total: items.length, taggedCount, untaggedCount: items.length - taggedCount, items }
}
function mockBrowse(path?: string): BrowseResult {
if (!path) {
return {
root: 'mock-log', exists: true, path: '', parent: null, dirCount: 1, fileCount: 0,
dirs: [{ name: '2026-06-01', rel: '2026-06-01', mtime: '2026-06-01T12:44:59', dirCount: 0, fileCount: 3 }],
files: []
}
}
return {
root: 'mock-log', exists: true, path, parent: '', dirCount: 0, fileCount: 3,
dirs: [],
files: [
{ name: '20260601-12Q(30).log', rel: `${path}/20260601-12Q(30).log`, bytes: 5_223_512, mtime: '2026-06-01T12:44:59', isLog: true },
{ name: '20260601-12Q(15).log', rel: `${path}/20260601-12Q(15).log`, bytes: 1188, mtime: '2026-06-01T12:21:44', isLog: true },
{ name: '20260601-08Q(45).log', rel: `${path}/20260601-08Q(45).log`, bytes: 949, mtime: '2026-06-01T08:47:20', isLog: true }
]
}
}
function mockAnalyze(q: AnalyzeQuery): AnalyzeResult {
const now = Date.now()
const gran = q.granularity ?? 'minute'
const step = gran === 'second' ? 1000 : gran === 'hour' ? 3_600_000 : 60_000
const n = 30
const buckets = Array.from({ length: n }, (_, i) => new Date(now - (n - 1 - i) * step).toISOString())
const wave = (amp: number, base: number, ph: number) =>
buckets.map((_, i) => Math.max(0, Math.round(base + amp * Math.sin(i / 3 + ph))))
const fields: AnalyzeField[] = [
{ name: 'cost', samples: 420, min: 2, max: 88, avg: 18.4, last: 21 },
{ name: 'queue', samples: 380, min: 0, max: 32, avg: 6.1, last: 4 },
{ name: 'speed', samples: 300, min: 0, max: 1.5, avg: 0.7, last: 0.9 }
]
const series: AnalyzeSeries | null = q.field
? {
field: q.field, tag: q.tag ?? '', count: n,
points: buckets.map((t, i) => ({ t, v: Math.round((18 + 10 * Math.sin(i / 2)) * 10) / 10 }))
}
: null
return {
source: q.file ? 'file' : 'day', target: q.file ?? q.day ?? 'mock', files: 1, truncated: false,
total: 1280,
timeRange: { start: buckets[0], end: buckets[n - 1] },
granularity: gran,
tags: [
{ tag: 'Dispatch', count: 520, percent: 40.6 },
{ tag: 'Persistence', count: 360, percent: 28.1 },
{ tag: '交管#3', count: 210, percent: 16.4 },
{ tag: 'UI-Error', count: 90, percent: 7.0 },
{ tag: '', count: 100, percent: 7.8 }
],
volume: {
granularity: gran, buckets,
total: wave(20, 40, 0),
topTags: [
{ tag: 'Dispatch', counts: wave(10, 18, 0) },
{ tag: 'Persistence', counts: wave(8, 12, 1) },
{ tag: '交管#3', counts: wave(6, 7, 2) }
]
},
fields,
series
}
}
export const logsApi = {
overview: (): Promise<LogOverview> => MOCK
? Promise.resolve(mockOverview())
: http.get<LogOverview>('/logs/overview').then((r) => r.data),
files: (params?: { day?: string; keyword?: string; limit?: number }): Promise<LogFilesResult> => MOCK
? Promise.resolve(mockFiles())
: http.get<LogFilesResult>('/logs/files', { params }).then((r) => r.data),
entries: (q: EntriesQuery): Promise<LogEntriesResult> => MOCK
? Promise.resolve(mockEntries(q))
: http.get<LogEntriesResult>('/logs/entries', { params: q }).then((r) => r.data),
digest: (q: DigestQuery): Promise<LogDigest> => MOCK
? Promise.resolve(mockDigest())
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
? Promise.resolve(mockLiveDiagnosis())
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
if (!r.data?.success || !r.data.data) throw new Error(r.data?.message ?? '获取实时诊断失败')
return r.data.data
}),
/** 目录浏览:列出 log/ 下某相对目录的子文件夹 + 文件(path 为空=根)。 */
browse: (path?: string): Promise<BrowseResult> => MOCK
? Promise.resolve(mockBrowse(path))
: http.get<BrowseResult>('/logs/browse', { params: path ? { path } : undefined }).then((r) => r.data),
/** 日志分析:标签分布 / 时间直方图 / 数值字段识别 / 选定字段时序。file 或 day 二选一。 */
analyze: (q: AnalyzeQuery): Promise<AnalyzeResult> => MOCK
? Promise.resolve(mockAnalyze(q))
: http.get<AnalyzeResult>('/logs/analyze', { params: q }).then((r) => r.data),
/** 取文件尾部 N 行原文(text/plain)。 */
raw: (file: string, tail = 2000): Promise<string> => MOCK
? Promise.resolve('[2026/06/01-12:21:40.120] >Persistence: worker started\n[2026/06/01-12:21:41.330] >/: loaded 12 sites')
: http.get('/logs/raw', { params: { file, tail }, responseType: 'text' }).then((r) => r.data as string),
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
downloadUrl: (file: string): string =>
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
}