Files
Migu2.0/frontends/apps/simple-platform-vue/src/api/logs.ts
T
黄兆尉andCursor 79c9e36d89 在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:25 +08:00

454 lines
16 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/*`)。
*
* 数据是 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<T> { 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<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),
/** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 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)}`,
cleanupConfig: (): Promise<LogCleanupConfig> => MOCK
? Promise.resolve(mockCleanupConfig())
: http.get<LogCleanupConfig>('/logs/cleanup-config').then((r) => r.data),
saveCleanupConfig: (body: LogCleanupConfig): Promise<LogCleanupConfig> => MOCK
? Promise.resolve({ ...body })
: http.put<LogCleanupConfig>('/logs/cleanup-config', body).then((r) => r.data),
cleanupNow: (): Promise<LogCleanupResult> => MOCK
? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 })
: http.post<LogCleanupResult>('/logs/cleanup-now').then((r) => r.data)
}