From 991502b4b1ae0fb16e04ecf53af8a8017010af5f Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Mon, 8 Jun 2026 16:11:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(web/logs):=20=E6=97=A5=E5=BF=97=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E4=B8=8E=20JSON=20=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增日志管理页(实时诊断/文件浏览/日志分析三区,echarts 按需引入绘制标签分布、日志量、字段时序图, 图表实例与定时器在卸载时释放);新增 api/logs 封装后端日志接口;新增通用 JsonFold 折叠查看组件 (折叠子树惰性渲染 v-if,避免大 JSON 一次性铺满 DOM)。 Co-authored-by: Cursor --- .../apps/simple-platform-vue/src/api/logs.ts | 402 +++++++++ .../src/components/common/JsonFoldNode.vue | 174 ++++ .../src/components/common/JsonFoldViewer.vue | 46 + .../src/views/admin/LogManagementView.vue | 811 ++++++++++++++++++ 4 files changed, 1433 insertions(+) create mode 100644 frontends/apps/simple-platform-vue/src/api/logs.ts create mode 100644 frontends/apps/simple-platform-vue/src/components/common/JsonFoldNode.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/common/JsonFoldViewer.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/LogManagementView.vue diff --git a/frontends/apps/simple-platform-vue/src/api/logs.ts b/frontends/apps/simple-platform-vue/src/api/logs.ts new file mode 100644 index 0000000..e5ec873 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/logs.ts @@ -0,0 +1,402 @@ +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 { 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 => MOCK + ? Promise.resolve(mockOverview()) + : http.get('/logs/overview').then((r) => r.data), + + files: (params?: { day?: string; keyword?: string; limit?: number }): Promise => MOCK + ? Promise.resolve(mockFiles()) + : http.get('/logs/files', { params }).then((r) => r.data), + + entries: (q: EntriesQuery): Promise => MOCK + ? Promise.resolve(mockEntries(q)) + : http.get('/logs/entries', { params: q }).then((r) => r.data), + + digest: (q: DigestQuery): Promise => MOCK + ? Promise.resolve(mockDigest()) + : http.get('/logs/digest', { params: q }).then((r) => r.data), + + /** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */ + liveDiagnosis: (): Promise => MOCK + ? Promise.resolve(mockLiveDiagnosis()) + : http.get>(`${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 => MOCK + ? Promise.resolve(mockBrowse(path)) + : http.get('/logs/browse', { params: path ? { path } : undefined }).then((r) => r.data), + + /** 日志分析:标签分布 / 时间直方图 / 数值字段识别 / 选定字段时序。file 或 day 二选一。 */ + analyze: (q: AnalyzeQuery): Promise => MOCK + ? Promise.resolve(mockAnalyze(q)) + : http.get('/logs/analyze', { params: q }).then((r) => r.data), + + /** 取文件尾部 N 行原文(text/plain)。 */ + raw: (file: string, tail = 2000): Promise => 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)}` +} diff --git a/frontends/apps/simple-platform-vue/src/components/common/JsonFoldNode.vue b/frontends/apps/simple-platform-vue/src/components/common/JsonFoldNode.vue new file mode 100644 index 0000000..dbc568f --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/common/JsonFoldNode.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/common/JsonFoldViewer.vue b/frontends/apps/simple-platform-vue/src/components/common/JsonFoldViewer.vue new file mode 100644 index 0000000..d471983 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/common/JsonFoldViewer.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/admin/LogManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/LogManagementView.vue new file mode 100644 index 0000000..69bdfe4 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/LogManagementView.vue @@ -0,0 +1,811 @@ + + + + +