import http from './http' /** * 「日志管理」前端胶水:对应 MiGu.Server `LogsController`(`/api/logs/*`)。 * * 数据是 Simple3 内核 `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' /** Simple3 projection 投影 API 统一信封(同 reflection.ts)。 */ interface SlEnvelope { success: boolean; code: number; data: T | null; message: string } /** 经 YARP 反代到 Simple3 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 LogDiskStatus { known: boolean drive: string freeGB: number alertEnabled: boolean alertGB: number belowThreshold: boolean } export interface LogCleanupConfig { enabled: boolean retentionDays: number checkIntervalHours: number runOnStartup: boolean diskAlertEnabled: boolean diskFreeAlertGB: number diskCheckIntervalMinutes: number } export interface LogCleanupResult { deletedFiles: number freedBytes: number freedMB: number } export interface LogOverview { exists: boolean root: string | null workingDirectory: string | null totalFiles?: number totalBytes?: number latestFileTime?: string | null days?: LogDayStat[] message?: string disk?: LogDiskStatus } 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 } /** 实时诊断单条:对应 Simple3 内核 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:\\...\\Simple3\\bin\\Debug\\log', workingDirectory: 'E:\\...\\Simple3\\bin\\Debug', totalFiles: 3, totalBytes: 5_233_649, latestFileTime: new Date().toISOString(), days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }], disk: { known: true, drive: 'E:\\', freeGB: 118.7, alertEnabled: true, alertGB: 5, belowThreshold: false } } } function mockCleanupConfig(): LogCleanupConfig { return { enabled: true, retentionDays: 30, checkIntervalHours: 24, runOnStartup: true, diskAlertEnabled: true, diskFreeAlertGB: 5, diskCheckIntervalMinutes: 30 } } 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: '[Simple3] 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), /** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 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)}`, cleanupConfig: (): Promise => MOCK ? Promise.resolve(mockCleanupConfig()) : http.get('/logs/cleanup-config').then((r) => r.data), saveCleanupConfig: (body: LogCleanupConfig): Promise => MOCK ? Promise.resolve({ ...body }) : http.put('/logs/cleanup-config', body).then((r) => r.data), cleanupNow: (): Promise => MOCK ? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 }) : http.post('/logs/cleanup-now').then((r) => r.data) }