Files
Migu2.0/frontends/apps/simple-platform-vue/src/api/logs.ts
T

454 lines
16 KiB
TypeScript
Raw Normal View History

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)
}