- YARP: map-edit/ai-config 全方法、reflection 写方法挂 PlatformScope, reflection/selection 单独放行(运营端 3D 高亮),堵住运营账号直达地图编辑/反射调用 - goto-site 探测改用不存在的 car/-1(消除健康检查真实派车风险)并加 60s 缓存 - Config PUT 按 scope 收紧:RCSMonitor 仅可写 ops 节;wizard 写操作与 simplelite/restart-for-update 限 PlatformScope;/api/health 去除虚假端口表 - 修复 wms 模块菜单裁剪失效(admin-config-location → admin-config-facility) - vrHost 默认 location.hostname:8223(新增 utils/vrender.ts),远程访问 3D 视口可用 - /status 页改接真实 /api/health* 诊断;uploadAsset 移除矛盾 multipart 头; mapsApi.merge 对齐 save 的 409 冲突处理;JWT 验签参数改启动期 DI 一次性配置 - 清理死代码:ProjectionController、DataTablePro、useClipboard、CadToolbarView、 AppShell 未用导入;lint 脚本替换为 typecheck;日志窗口 List 改 Queue
837 lines
37 KiB
C#
837 lines
37 KiB
C#
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using MiGu.Server.Launcher;
|
||
|
||
namespace MiGu.Server.Controllers;
|
||
|
||
/// <summary>
|
||
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||
/// (<c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
|
||
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
|
||
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
|
||
///
|
||
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>,
|
||
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
||
///
|
||
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>,
|
||
/// 其中 tag 为 <c>/</c> 表示无标签(滚动记录)。不匹配该模式的行视为上一条的续行(多行内容)。
|
||
///
|
||
/// 鉴权:class 级 <c>[Authorize(Policy = "PlatformScope")]</c> —— 日志为内核落盘文件(可能含文件路径 /
|
||
/// 内部运行状态等敏感信息),仅平台后台用户(scope=Platform)可读 / 下载,排除 RCSMonitor 监控大屏 token。
|
||
/// </summary>
|
||
[ApiController]
|
||
[Authorize(Policy = "PlatformScope")]
|
||
[Route("api/logs")]
|
||
public sealed class LogsController : ControllerBase
|
||
{
|
||
/// <summary>文件列表默认上限(与 LogViewer.cs 的 MaxFilesShown=500 对齐)。</summary>
|
||
private const int DefaultFileLimit = 500;
|
||
|
||
/// <summary>单次解析的条目硬上限,防超大日志(实测单文件可达 46MB)撑爆内存。</summary>
|
||
private const int MaxEntries = 200_000;
|
||
|
||
/// <summary>单次扫描字节上限(超过则截断并标记 truncated)。</summary>
|
||
private const long MaxScanBytes = 96L * 1024 * 1024;
|
||
|
||
/// <summary>合订/某天聚合时最多遍历的文件数,避免一次扫描整月日志。</summary>
|
||
private const int MaxDigestFiles = 64;
|
||
|
||
/// <summary>跨文件聚合(analyze/digest 的 day 模式)的总条目上限,防某天多个大文件 entries 累加撑爆内存。</summary>
|
||
private const int MaxAggregateEntries = 300_000;
|
||
|
||
/// <summary>Diagnosis 落盘行:<c>[head] >tag: content</c>;head 内含可选 prefix + 时间戳。</summary>
|
||
private static readonly Regex LineRegex =
|
||
new(@"^\[(?<head>[^\]]*)\]\s*>(?<tag>.*?):\s?(?<content>.*)$", RegexOptions.Compiled);
|
||
|
||
/// <summary>从 head 里抠出时间戳(prefix = 时间戳之前的部分)。</summary>
|
||
private static readonly Regex TimeRegex =
|
||
new(@"\d{4}/\d{2}/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}", RegexOptions.Compiled);
|
||
|
||
private const string TimeFormat = "yyyy/MM/dd-HH:mm:ss.fff";
|
||
|
||
/// <summary>
|
||
/// 内容里的「数值字段」:<c>key=value</c> 或 <c>key: value</c>,value 为数字(可带小数/负号)。
|
||
/// key 必须以字母/下划线/中文开头,避免把 <c>12:30</c> 这类时间误判为字段。供「日志分析器」识别可绘图字段。
|
||
/// </summary>
|
||
private static readonly Regex NumericFieldRegex =
|
||
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
|
||
|
||
private readonly SimpleLiteLauncher _launcher;
|
||
private readonly IConfiguration _config;
|
||
private readonly ILogger<LogsController> _log;
|
||
|
||
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
|
||
{
|
||
_launcher = launcher;
|
||
_config = config;
|
||
_log = log;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────── 概览 ──
|
||
|
||
/// <summary>日志根概览:工作目录、根路径、是否存在、文件/字节总量、按天分组统计。</summary>
|
||
[HttpGet("overview")]
|
||
public IActionResult Overview()
|
||
{
|
||
var (root, workdir, error) = ResolveLogRoot();
|
||
if (root == null)
|
||
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
|
||
|
||
if (!Directory.Exists(root))
|
||
return Ok(new
|
||
{
|
||
exists = false, root, workingDirectory = workdir,
|
||
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
|
||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||
});
|
||
|
||
var files = EnumerateLogFiles(root);
|
||
var days = files
|
||
.GroupBy(f => f.Day)
|
||
.Select(g => new { day = g.Key, files = g.Count(), bytes = g.Sum(x => x.Bytes) })
|
||
.OrderByDescending(d => d.day, StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
return Ok(new
|
||
{
|
||
exists = true, root, workingDirectory = workdir,
|
||
totalFiles = files.Count,
|
||
totalBytes = files.Sum(f => f.Bytes),
|
||
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
|
||
days
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────── 文件列表 ──
|
||
|
||
/// <summary>列出落盘日志文件(递归 log/),可按天 / 文件名关键字过滤,按修改时间降序。</summary>
|
||
[HttpGet("files")]
|
||
public IActionResult Files([FromQuery] string? day, [FromQuery] string? keyword, [FromQuery] int limit = DefaultFileLimit)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
if (!Directory.Exists(root)) return Ok(new { root, total = 0, returned = 0, files = Array.Empty<object>() });
|
||
|
||
var clamp = Math.Clamp(limit, 1, 5000);
|
||
IEnumerable<FileMeta> q = EnumerateLogFiles(root);
|
||
if (!string.IsNullOrWhiteSpace(day))
|
||
q = q.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
q = q.Where(f => f.Rel.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||
|
||
var all = q.OrderByDescending(f => f.Mtime).ToList();
|
||
var page = all.Take(clamp).Select(f => new
|
||
{
|
||
rel = f.Rel, name = f.Name, day = f.Day, dir = f.Dir, bytes = f.Bytes, mtime = f.Mtime
|
||
}).ToList();
|
||
|
||
return Ok(new { root, total = all.Count, returned = page.Count, files = page });
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────── 目录浏览 ──
|
||
|
||
/// <summary>
|
||
/// 文件夹 / 文件浏览器:列出 <c>log/</c> 下指定相对目录的<b>直接</b>子项(子文件夹 + 文件),可逐层进入。
|
||
/// <c>path</c> 为空 = 日志根。对齐用户诉求「直接显示 log 下所有文件夹和文件,可进入文件夹、打开某个日志文件」。
|
||
/// 子文件夹附带其下一层的子目录/文件计数,文件附带大小、修改时间与是否 .log。
|
||
/// </summary>
|
||
[HttpGet("browse")]
|
||
public IActionResult Browse([FromQuery] string? path)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
|
||
var normRoot = Path.GetFullPath(root);
|
||
if (!Directory.Exists(normRoot))
|
||
return Ok(new
|
||
{
|
||
root = normRoot, exists = false, path = "", parent = (string?)null,
|
||
dirCount = 0, fileCount = 0,
|
||
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
|
||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||
});
|
||
|
||
var target = SafeResolveDir(normRoot, path);
|
||
if (target == null) return BadRequest(new { message = "非法的目录路径" });
|
||
if (!Directory.Exists(target)) return NotFound(new { message = "目录不存在" });
|
||
|
||
var rel = NormalizeRel(Path.GetRelativePath(normRoot, target));
|
||
var parent = string.IsNullOrEmpty(rel) ? (string?)null : NormalizeRel(Path.GetDirectoryName(rel) ?? "");
|
||
|
||
var dirs = new List<object>();
|
||
var files = new List<object>();
|
||
try
|
||
{
|
||
var di = new DirectoryInfo(target);
|
||
|
||
foreach (var sub in di.GetDirectories().OrderByDescending(x => x.LastWriteTime))
|
||
{
|
||
int childDirs = 0, childFiles = 0;
|
||
try { childDirs = sub.GetDirectories().Length; } catch { /* 无权限/并发删除:计数视为 0 */ }
|
||
try { childFiles = sub.GetFiles().Length; } catch { /* 同上 */ }
|
||
dirs.Add(new
|
||
{
|
||
name = sub.Name,
|
||
rel = NormalizeRel(Path.GetRelativePath(normRoot, sub.FullName)),
|
||
mtime = sub.LastWriteTime,
|
||
dirCount = childDirs,
|
||
fileCount = childFiles
|
||
});
|
||
}
|
||
|
||
foreach (var f in di.GetFiles().OrderByDescending(x => x.LastWriteTime))
|
||
{
|
||
files.Add(new
|
||
{
|
||
name = f.Name,
|
||
rel = NormalizeRel(Path.GetRelativePath(normRoot, f.FullName)),
|
||
bytes = f.Length,
|
||
mtime = f.LastWriteTime,
|
||
isLog = f.Extension.Equals(".log", StringComparison.OrdinalIgnoreCase)
|
||
});
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning(ex, "浏览日志目录失败 path={Path}", path);
|
||
return Problem($"读取目录失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
|
||
return Ok(new
|
||
{
|
||
root = normRoot, exists = true, path = rel, parent,
|
||
dirCount = dirs.Count, fileCount = files.Count, dirs, files
|
||
});
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────── 条目(分页)──
|
||
|
||
/// <summary>
|
||
/// 解析单个日志文件为结构化条目(时间 / 标签 / 内容),支持关键字 / 标签 / 仅带标签过滤、
|
||
/// 升降序与分页。超大文件按 <see cref="MaxScanBytes"/> / <see cref="MaxEntries"/> 截断并回 truncated。
|
||
/// </summary>
|
||
[HttpGet("entries")]
|
||
public IActionResult Entries(
|
||
[FromQuery] string file,
|
||
[FromQuery] string? keyword,
|
||
[FromQuery] string? tag,
|
||
[FromQuery] bool onlyTagged = false,
|
||
[FromQuery] string order = "desc",
|
||
[FromQuery] int limit = 300,
|
||
[FromQuery] int offset = 0)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
|
||
var full = SafeResolve(root, file);
|
||
if (full == null) return BadRequest(new { message = "非法的文件路径" });
|
||
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
|
||
|
||
var parsed = ParseFile(full);
|
||
IEnumerable<LogEntry> q = parsed.Entries;
|
||
if (onlyTagged) q = q.Where(e => !string.IsNullOrEmpty(e.Tag));
|
||
if (!string.IsNullOrWhiteSpace(tag))
|
||
q = q.Where(e => e.Tag.Contains(tag, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
q = q.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|
||
|| e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||
|
||
var filtered = q.ToList();
|
||
if (!string.Equals(order, "asc", StringComparison.OrdinalIgnoreCase))
|
||
filtered.Reverse();
|
||
|
||
var clampLimit = Math.Clamp(limit, 1, 2000);
|
||
var clampOffset = Math.Max(0, offset);
|
||
var page = filtered.Skip(clampOffset).Take(clampLimit).Select(Project).ToList();
|
||
|
||
return Ok(new
|
||
{
|
||
file, bytes = parsed.Bytes, scannedLines = parsed.ScannedLines,
|
||
truncated = parsed.Truncated, total = filtered.Count,
|
||
offset = clampOffset, limit = clampLimit, order = order.ToLowerInvariant(),
|
||
entries = page
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────── 合订本 ──
|
||
|
||
/// <summary>
|
||
/// 「合订本」:把带标签的 Post/Toast 按标签聚合成一册(条数 + 时间范围 + 最新内容 + 最近若干条),
|
||
/// 无标签的归入「滚动记录」。对齐用户诉求「post 和 toast 如果有标签需要是合订本的形式」。
|
||
/// 范围二选一:<c>file</c>(单文件)或 <c>day</c>(某天全部文件,最多 <see cref="MaxDigestFiles"/> 个)。
|
||
/// </summary>
|
||
[HttpGet("digest")]
|
||
public IActionResult Digest(
|
||
[FromQuery] string? file,
|
||
[FromQuery] string? day,
|
||
[FromQuery] string? keyword,
|
||
[FromQuery] int maxPerTag = 100)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
|
||
var targets = new List<string>();
|
||
string sourceLabel;
|
||
if (!string.IsNullOrWhiteSpace(file))
|
||
{
|
||
var full = SafeResolve(root, file);
|
||
if (full == null) return BadRequest(new { message = "非法的文件路径" });
|
||
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
|
||
targets.Add(full);
|
||
sourceLabel = file;
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(day))
|
||
{
|
||
if (!Directory.Exists(root)) return Ok(EmptyDigest("day", day));
|
||
targets = EnumerateLogFiles(root)
|
||
.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(f => f.Mtime)
|
||
.Take(MaxDigestFiles)
|
||
.Select(f => f.Full)
|
||
.ToList();
|
||
sourceLabel = day;
|
||
}
|
||
else
|
||
{
|
||
return BadRequest(new { message = "请提供 file 或 day 之一作为合订范围" });
|
||
}
|
||
|
||
var clampPerTag = Math.Clamp(maxPerTag, 1, 1000);
|
||
var books = new Dictionary<string, Book>(StringComparer.Ordinal);
|
||
var untagged = new Book { Tag = "" };
|
||
bool truncated = false;
|
||
long scanned = 0;
|
||
|
||
foreach (var f in targets)
|
||
{
|
||
var parsed = ParseFile(f);
|
||
truncated |= parsed.Truncated;
|
||
scanned += parsed.Bytes;
|
||
foreach (var e in parsed.Entries)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(keyword)
|
||
&& !e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|
||
&& !e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
if (string.IsNullOrEmpty(e.Tag))
|
||
{
|
||
Accumulate(untagged, e, clampPerTag);
|
||
}
|
||
else
|
||
{
|
||
if (!books.TryGetValue(e.Tag, out var b)) { b = new Book { Tag = e.Tag }; books[e.Tag] = b; }
|
||
Accumulate(b, e, clampPerTag);
|
||
}
|
||
}
|
||
// 跨文件总字节熔断:合订各 Book 已有 maxPerTag 上限,这里再防 64 个大文件把 CPU 拉满。
|
||
if (scanned >= MaxScanBytes) { truncated = true; break; }
|
||
}
|
||
|
||
var bookList = books.Values
|
||
.OrderByDescending(b => b.LastTime ?? DateTime.MinValue)
|
||
.Select(b => ToBookDto(b))
|
||
.ToList();
|
||
|
||
return Ok(new
|
||
{
|
||
source = string.IsNullOrWhiteSpace(file) ? "day" : "file",
|
||
target = sourceLabel,
|
||
files = targets.Count,
|
||
truncated,
|
||
tagCount = bookList.Count,
|
||
untaggedCount = untagged.Count,
|
||
books = bookList,
|
||
untagged = ToBookDto(untagged)
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────── 日志分析 ──
|
||
|
||
/// <summary>
|
||
/// 「日志分析器」:解析单文件(<c>file</c>)或某天(<c>day</c>)日志,产出图表所需的聚合数据:
|
||
/// <list type="number">
|
||
/// <item><b>标签分布</b> tags:各标签条数 + 占比(含「滚动记录」即无标签);</item>
|
||
/// <item><b>日志量直方图</b> volume:按 <c>granularity</c>(second/minute/hour) 分桶的总量 + Top6 标签拆分(稀疏桶,仅含有数据的时刻);</item>
|
||
/// <item><b>数值字段识别</b> fields:从内容里抽取 <c>key=value</c>/<c>key:value</c> 的数值字段,给出样本数/最小/最大/均值/最后值;</item>
|
||
/// <item><b>字段时序</b> series:当指定 <c>field</c> 时,返回该字段的 (时间, 值) 点序列(超量自动抽稀)。</item>
|
||
/// </list>
|
||
/// <c>keyword</c> 过滤全部统计;<c>tag</c> 仅聚焦「字段识别 + 字段时序」(不影响标签分布/直方图,便于先看全貌再下钻)。
|
||
/// </summary>
|
||
[HttpGet("analyze")]
|
||
public IActionResult Analyze(
|
||
[FromQuery] string? file,
|
||
[FromQuery] string? day,
|
||
[FromQuery] string granularity = "minute",
|
||
[FromQuery] string? tag = null,
|
||
[FromQuery] string? keyword = null,
|
||
[FromQuery] string? field = null)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
|
||
var gran = (granularity ?? "minute").ToLowerInvariant() switch
|
||
{
|
||
"second" or "sec" or "s" => Gran.Second,
|
||
"hour" or "h" => Gran.Hour,
|
||
_ => Gran.Minute
|
||
};
|
||
var granName = gran.ToString().ToLowerInvariant();
|
||
|
||
var targets = new List<string>();
|
||
string label;
|
||
string source;
|
||
if (!string.IsNullOrWhiteSpace(file))
|
||
{
|
||
var full = SafeResolve(root, file);
|
||
if (full == null) return BadRequest(new { message = "非法的文件路径" });
|
||
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
|
||
targets.Add(full);
|
||
label = file; source = "file";
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(day))
|
||
{
|
||
if (!Directory.Exists(root)) return Ok(EmptyAnalysis("day", day, granName));
|
||
targets = EnumerateLogFiles(root)
|
||
.Where(f => string.Equals(f.Day, day, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(f => f.Mtime).Take(MaxDigestFiles).Select(f => f.Full).ToList();
|
||
label = day; source = "day";
|
||
}
|
||
else
|
||
{
|
||
return BadRequest(new { message = "请提供 file 或 day 之一作为分析范围" });
|
||
}
|
||
|
||
var all = new List<LogEntry>();
|
||
bool truncated = false;
|
||
long aggBytes = 0;
|
||
foreach (var f in targets)
|
||
{
|
||
var parsed = ParseFile(f);
|
||
truncated |= parsed.Truncated;
|
||
aggBytes += parsed.Bytes;
|
||
// 跨文件聚合熔断:总条数 / 总字节达上限即停止纳入并标记 truncated(图表基于已采样数据),
|
||
// 避免某天多个大文件把全部 entries 堆进内存。
|
||
var room = MaxAggregateEntries - all.Count;
|
||
if (room <= 0) { truncated = true; break; }
|
||
if (parsed.Entries.Count > room)
|
||
{
|
||
all.AddRange(parsed.Entries.GetRange(0, room));
|
||
truncated = true;
|
||
break;
|
||
}
|
||
all.AddRange(parsed.Entries);
|
||
if (aggBytes >= MaxScanBytes) { truncated = true; break; }
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
all = all.Where(e => e.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)
|
||
|| e.Tag.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||
|
||
var total = all.Count;
|
||
|
||
// ── 1) 标签分布(全量)──
|
||
var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
foreach (var e in all)
|
||
{
|
||
var key = e.Tag ?? "";
|
||
tagCounts.TryGetValue(key, out var c);
|
||
tagCounts[key] = c + 1;
|
||
}
|
||
var tags = tagCounts.OrderByDescending(kv => kv.Value)
|
||
.Select(kv => new
|
||
{
|
||
tag = kv.Key,
|
||
count = kv.Value,
|
||
percent = total > 0 ? Math.Round(kv.Value * 100.0 / total, 2) : 0
|
||
}).ToList();
|
||
|
||
// ── 2) 时间范围 + 日志量直方图(稀疏桶 + Top6 标签拆分)──
|
||
var timed = all.Where(e => e.Time != null).Select(e => e.Time!.Value).ToList();
|
||
DateTime? start = timed.Count > 0 ? timed.Min() : (DateTime?)null;
|
||
DateTime? end = timed.Count > 0 ? timed.Max() : (DateTime?)null;
|
||
|
||
var topTagNames = tags.Where(t => t.tag != "").Take(6).Select(t => t.tag).ToList();
|
||
var topSet = new HashSet<string>(topTagNames, StringComparer.Ordinal);
|
||
var totalBuckets = new SortedDictionary<DateTime, int>();
|
||
var tagBuckets = topTagNames.ToDictionary(t => t, _ => new Dictionary<DateTime, int>(), StringComparer.Ordinal);
|
||
foreach (var e in all)
|
||
{
|
||
if (e.Time == null) continue;
|
||
var b = TruncateTime(e.Time.Value, gran);
|
||
totalBuckets.TryGetValue(b, out var c);
|
||
totalBuckets[b] = c + 1;
|
||
var tg = e.Tag ?? "";
|
||
if (topSet.Contains(tg))
|
||
{
|
||
var d = tagBuckets[tg];
|
||
d.TryGetValue(b, out var c2);
|
||
d[b] = c2 + 1;
|
||
}
|
||
}
|
||
var bucketTimes = totalBuckets.Keys.ToList();
|
||
var volume = new
|
||
{
|
||
granularity = granName,
|
||
buckets = bucketTimes,
|
||
total = bucketTimes.Select(t => totalBuckets[t]).ToList(),
|
||
topTags = topTagNames.Select(tg => new
|
||
{
|
||
tag = tg,
|
||
counts = bucketTimes.Select(t => tagBuckets[tg].TryGetValue(t, out var v) ? v : 0).ToList()
|
||
}).ToList()
|
||
};
|
||
|
||
// ── 3) 数值字段识别(受 tag 聚焦影响)──
|
||
IEnumerable<LogEntry> scope = all;
|
||
if (!string.IsNullOrWhiteSpace(tag))
|
||
scope = all.Where(e => string.Equals(e.Tag, tag, StringComparison.Ordinal)).ToList();
|
||
|
||
var fieldAgg = new Dictionary<string, FieldStat>(StringComparer.Ordinal);
|
||
foreach (var e in scope)
|
||
{
|
||
foreach (Match m in NumericFieldRegex.Matches(e.Content))
|
||
{
|
||
if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
|
||
var k = m.Groups["k"].Value;
|
||
if (!fieldAgg.TryGetValue(k, out var fs)) { fs = new FieldStat(); fieldAgg[k] = fs; }
|
||
fs.N++; fs.Sum += v; fs.Last = v;
|
||
if (v < fs.Min) fs.Min = v;
|
||
if (v > fs.Max) fs.Max = v;
|
||
}
|
||
}
|
||
var fields = fieldAgg.OrderByDescending(kv => kv.Value.N).Take(40).Select(kv => new
|
||
{
|
||
name = kv.Key,
|
||
samples = kv.Value.N,
|
||
min = kv.Value.Min,
|
||
max = kv.Value.Max,
|
||
avg = kv.Value.N > 0 ? Math.Round(kv.Value.Sum / kv.Value.N, 4) : 0,
|
||
last = kv.Value.Last
|
||
}).ToList();
|
||
|
||
// ── 4) 选定字段时序(超量抽稀,保留分布形态)──
|
||
object? series = null;
|
||
if (!string.IsNullOrWhiteSpace(field))
|
||
{
|
||
var pts = new List<(DateTime t, double v)>();
|
||
foreach (var e in scope)
|
||
{
|
||
if (e.Time == null) continue;
|
||
foreach (Match m in NumericFieldRegex.Matches(e.Content))
|
||
{
|
||
if (!string.Equals(m.Groups["k"].Value, field, StringComparison.Ordinal)) continue;
|
||
if (!double.TryParse(m.Groups["v"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
|
||
pts.Add((e.Time.Value, v));
|
||
}
|
||
}
|
||
const int cap = 8000;
|
||
if (pts.Count > cap)
|
||
{
|
||
var stride = (int)Math.Ceiling(pts.Count / (double)cap);
|
||
pts = pts.Where((_, i) => i % stride == 0).ToList();
|
||
}
|
||
series = new
|
||
{
|
||
field,
|
||
tag = tag ?? "",
|
||
count = pts.Count,
|
||
points = pts.Select(p => new { t = p.t, v = p.v }).ToList()
|
||
};
|
||
}
|
||
|
||
return Ok(new
|
||
{
|
||
source, target = label, files = targets.Count, truncated,
|
||
total,
|
||
timeRange = new { start, end },
|
||
granularity = granName,
|
||
tags, volume, fields, series
|
||
});
|
||
}
|
||
|
||
// ──────────────────────────────────────────────── 原文 / 下载 ──
|
||
|
||
/// <summary>返回日志文件尾部 N 行原文(默认 2000 行),用于「查看原始日志」视图。</summary>
|
||
[HttpGet("raw")]
|
||
public IActionResult Raw([FromQuery] string file, [FromQuery] int tail = 2000)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
var full = SafeResolve(root, file);
|
||
if (full == null) return BadRequest(new { message = "非法的文件路径" });
|
||
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
|
||
|
||
var clamp = Math.Clamp(tail, 1, 50000);
|
||
try
|
||
{
|
||
var lines = System.IO.File.ReadLines(full).TakeLast(clamp).ToList();
|
||
var text = string.Join("\n", lines);
|
||
return Content(text, "text/plain; charset=utf-8");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning(ex, "读取日志原文失败 file={File}", file);
|
||
return Problem($"读取失败:{ex.Message}", statusCode: 500);
|
||
}
|
||
}
|
||
|
||
/// <summary>下载原始日志文件。</summary>
|
||
[HttpGet("download")]
|
||
public IActionResult Download([FromQuery] string file)
|
||
{
|
||
var (root, _, error) = ResolveLogRoot();
|
||
if (root == null) return Problem(error ?? "无法定位日志目录", statusCode: 503);
|
||
var full = SafeResolve(root, file);
|
||
if (full == null) return BadRequest(new { message = "非法的文件路径" });
|
||
if (!System.IO.File.Exists(full)) return NotFound(new { message = "日志文件不存在" });
|
||
|
||
var downloadName = Path.GetFileName(full);
|
||
return PhysicalFile(full, "application/octet-stream", downloadName);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────── helpers ──
|
||
|
||
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
|
||
private (string? root, string? workdir, string? error) ResolveLogRoot()
|
||
{
|
||
var overrideRoot = _config["Logs:Root"];
|
||
if (!string.IsNullOrWhiteSpace(overrideRoot))
|
||
{
|
||
var r = Path.GetFullPath(overrideRoot);
|
||
return (r, Path.GetDirectoryName(r), null);
|
||
}
|
||
|
||
var wd = _launcher.ResolveWorkingDirectory();
|
||
if (string.IsNullOrWhiteSpace(wd))
|
||
return (null, null,
|
||
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory," +
|
||
"或显式设置 Logs:Root 指向日志根目录。");
|
||
|
||
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
||
}
|
||
|
||
/// <summary>把相对路径安全解析到日志根内(防 ../ 目录穿越),并要求落在 root 子级。</summary>
|
||
private static string? SafeResolve(string root, string? rel)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(rel)) return null;
|
||
var normRoot = Path.GetFullPath(root);
|
||
var full = Path.GetFullPath(Path.Combine(normRoot, rel));
|
||
if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return null;
|
||
var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
|
||
return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
|
||
}
|
||
|
||
/// <summary>把相对目录安全解析到日志根内(允许根自身;防 ../ 目录穿越)。用于目录浏览。</summary>
|
||
private static string? SafeResolveDir(string root, string? rel)
|
||
{
|
||
var normRoot = Path.GetFullPath(root);
|
||
if (string.IsNullOrWhiteSpace(rel) || rel == "/" || rel == ".") return normRoot;
|
||
var full = Path.GetFullPath(Path.Combine(normRoot, rel));
|
||
if (string.Equals(full, normRoot, StringComparison.OrdinalIgnoreCase)) return normRoot;
|
||
var prefix = normRoot.EndsWith(Path.DirectorySeparatorChar) ? normRoot : normRoot + Path.DirectorySeparatorChar;
|
||
return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full : null;
|
||
}
|
||
|
||
/// <summary>相对路径标准化:反斜杠转正斜杠、去尾斜杠、根目录归一为空串。</summary>
|
||
private static string NormalizeRel(string rel)
|
||
{
|
||
if (string.IsNullOrEmpty(rel) || rel == ".") return "";
|
||
return rel.Replace('\\', '/').TrimEnd('/');
|
||
}
|
||
|
||
private static List<FileMeta> EnumerateLogFiles(string root)
|
||
{
|
||
var list = new List<FileMeta>();
|
||
IEnumerable<string> files;
|
||
try { files = Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories); }
|
||
catch { return list; }
|
||
|
||
foreach (var f in files)
|
||
{
|
||
try
|
||
{
|
||
var fi = new FileInfo(f);
|
||
var rel = Path.GetRelativePath(root, f).Replace('\\', '/');
|
||
var slash = rel.IndexOf('/');
|
||
var day = slash > 0 ? rel[..slash] : "(根目录)";
|
||
var dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
|
||
list.Add(new FileMeta(rel, fi.Name, day, dir, fi.Length, fi.LastWriteTime, f));
|
||
}
|
||
catch { /* 个别文件读元数据失败跳过 */ }
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/// <summary>流式解析日志文件为条目;非标准行作为上一条的续行(多行内容)。</summary>
|
||
private LogParseResult ParseFile(string full)
|
||
{
|
||
var entries = new List<LogEntry>();
|
||
long bytes = 0;
|
||
int lineNo = 0;
|
||
long scanned = 0;
|
||
bool truncated = false;
|
||
|
||
try { bytes = new FileInfo(full).Length; } catch { /* ignore */ }
|
||
|
||
try
|
||
{
|
||
foreach (var raw in System.IO.File.ReadLines(full))
|
||
{
|
||
lineNo++;
|
||
scanned += raw.Length + 2; // 估算含换行
|
||
var line = raw.TrimEnd('\r');
|
||
var m = LineRegex.Match(line);
|
||
if (m.Success)
|
||
{
|
||
var head = m.Groups["head"].Value;
|
||
var tm = TimeRegex.Match(head);
|
||
if (tm.Success
|
||
&& DateTime.TryParseExact(tm.Value, TimeFormat, CultureInfo.InvariantCulture,
|
||
DateTimeStyles.None, out var dt))
|
||
{
|
||
var tag = m.Groups["tag"].Value;
|
||
if (tag == "/") tag = "";
|
||
entries.Add(new LogEntry
|
||
{
|
||
LineNo = lineNo,
|
||
Time = dt,
|
||
Prefix = head[..tm.Index],
|
||
Tag = tag,
|
||
Content = m.Groups["content"].Value
|
||
});
|
||
if (entries.Count >= MaxEntries) { truncated = true; break; }
|
||
}
|
||
else
|
||
{
|
||
AppendContinuation(entries, line);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
AppendContinuation(entries, line);
|
||
}
|
||
|
||
if (scanned >= MaxScanBytes) { truncated = true; break; }
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning(ex, "解析日志文件失败 file={File}", full);
|
||
}
|
||
|
||
return new LogParseResult(entries, bytes, lineNo, truncated);
|
||
}
|
||
|
||
private static void AppendContinuation(List<LogEntry> entries, string line)
|
||
{
|
||
if (entries.Count == 0)
|
||
{
|
||
// 文件开头就是无时间戳行:作为一条无标签、无时间的内容保留。
|
||
entries.Add(new LogEntry { LineNo = 1, Time = null, Tag = "", Content = line });
|
||
return;
|
||
}
|
||
var last = entries[^1];
|
||
last.Content = string.IsNullOrEmpty(last.Content) ? line : last.Content + "\n" + line;
|
||
}
|
||
|
||
private static void Accumulate(Book b, LogEntry e, int maxPerTag)
|
||
{
|
||
b.Count++;
|
||
if (e.Time != null)
|
||
{
|
||
if (b.FirstTime == null || e.Time < b.FirstTime) b.FirstTime = e.Time;
|
||
if (b.LastTime == null || e.Time > b.LastTime) b.LastTime = e.Time;
|
||
}
|
||
b.Latest = e.Content;
|
||
b.LatestTime = e.Time;
|
||
b.Recent.Enqueue(e);
|
||
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆(Queue 头部出队 O(1))。
|
||
if (b.Recent.Count > maxPerTag) b.Recent.Dequeue();
|
||
}
|
||
|
||
private static object ToBookDto(Book b) => new
|
||
{
|
||
tag = b.Tag,
|
||
count = b.Count,
|
||
firstTime = b.FirstTime,
|
||
lastTime = b.LastTime,
|
||
latest = b.Latest,
|
||
latestTime = b.LatestTime,
|
||
entries = b.Recent.Select(Project).ToList()
|
||
};
|
||
|
||
private static object EmptyDigest(string source, string target) => new
|
||
{
|
||
source, target, files = 0, truncated = false,
|
||
tagCount = 0, untaggedCount = 0,
|
||
books = Array.Empty<object>(),
|
||
untagged = new { tag = "", count = 0, entries = Array.Empty<object>() }
|
||
};
|
||
|
||
/// <summary>把时间戳截断到指定粒度的桶起点(用于直方图分桶)。</summary>
|
||
private static DateTime TruncateTime(DateTime t, Gran g) => g switch
|
||
{
|
||
Gran.Second => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, t.Kind),
|
||
Gran.Hour => new DateTime(t.Year, t.Month, t.Day, t.Hour, 0, 0, t.Kind),
|
||
_ => new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, 0, t.Kind)
|
||
};
|
||
|
||
private static object EmptyAnalysis(string source, string target, string granName) => new
|
||
{
|
||
source, target, files = 0, truncated = false, total = 0,
|
||
timeRange = new { start = (DateTime?)null, end = (DateTime?)null },
|
||
granularity = granName,
|
||
tags = Array.Empty<object>(),
|
||
volume = new { granularity = granName, buckets = Array.Empty<DateTime>(), total = Array.Empty<int>(), topTags = Array.Empty<object>() },
|
||
fields = Array.Empty<object>(),
|
||
series = (object?)null
|
||
};
|
||
|
||
private static object Project(LogEntry e) => new
|
||
{
|
||
lineNo = e.LineNo, time = e.Time, prefix = e.Prefix, tag = e.Tag, content = e.Content
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────── 内部类型 ──
|
||
|
||
private enum Gran { Second, Minute, Hour }
|
||
|
||
/// <summary>「日志分析器」数值字段的累计统计。</summary>
|
||
private sealed class FieldStat
|
||
{
|
||
public long N;
|
||
public double Sum;
|
||
public double Min = double.MaxValue;
|
||
public double Max = double.MinValue;
|
||
public double Last;
|
||
}
|
||
|
||
private readonly record struct FileMeta(
|
||
string Rel, string Name, string Day, string Dir, long Bytes, DateTime Mtime, string Full);
|
||
|
||
private sealed record LogParseResult(List<LogEntry> Entries, long Bytes, int ScannedLines, bool Truncated);
|
||
|
||
private sealed class LogEntry
|
||
{
|
||
public int LineNo { get; set; }
|
||
public DateTime? Time { get; set; }
|
||
public string Prefix { get; set; } = "";
|
||
public string Tag { get; set; } = "";
|
||
public string Content { get; set; } = "";
|
||
}
|
||
|
||
private sealed class Book
|
||
{
|
||
public string Tag { get; set; } = "";
|
||
public int Count { get; set; }
|
||
public DateTime? FirstTime { get; set; }
|
||
public DateTime? LastTime { get; set; }
|
||
public string Latest { get; set; } = "";
|
||
public DateTime? LatestTime { get; set; }
|
||
public Queue<LogEntry> Recent { get; } = new();
|
||
}
|
||
}
|