diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..d5ced96 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,60 @@ +## (Q1.1) + +### 1 + +在LogParser\Parser\LogFileParser.cs中 + +``` +using var csv = new CsvReader(logFile, config); + csv.Context.RegisterClassMap(); + + foreach (var logRecord in csv.GetRecords()) + +``` + +csv.GetRecords按照CSV规则解析各行。而每一列对应什么含义,也是在 LogFileParser 中通过 Index 指定的: + +``` + Map(m => m.LineNo).Index(0); + Map(m => m.Timestamp).Index(1); + Map(m => m.PodName).Index(2); + Map(m => m.Message).Index(3); + +``` + +### 2 + +``` + +using (var doc = JsonDocument.Parse(logRecord.Message)) + { + var root = doc.RootElement; + if (root.TryGetProperty("event", out var eventElement)) + +``` + +根据 eventElement 来判断类型 + +### 3 + +使用JsonSerializer.Deserialize。 + +同时设置 + +private static JsonSerializerOptions options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, + }; + +调用时将options传入,完成命名法转换 + +## (Q1.2) + ++ Dictionary KeyValueVisitor.Dump(LogEntry entry) ++ Dictionary CallLogEntry.Accept>(visitor) ++ Dictionary KeyValueVisitor.Visit(CallLogEntry entry) + +## (Q1.3) + +没有使用AI,时间花了大概三小时。比程设作业难。我认为我完成作业只是模仿示例完成了代码,还没有完全看懂整个架构。 + diff --git a/docs/02-multithreading/assets/localcli-normal.png b/docs/02-multithreading/assets/localcli-normal.png new file mode 100644 index 0000000..a5582bb Binary files /dev/null and b/docs/02-multithreading/assets/localcli-normal.png differ diff --git a/docs/02-multithreading/assets/localcli-robustness.png b/docs/02-multithreading/assets/localcli-robustness.png new file mode 100644 index 0000000..d7a5047 Binary files /dev/null and b/docs/02-multithreading/assets/localcli-robustness.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..52b351d --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,55 @@ +# T2.3 LocalCli 实现报告 + +## 实现功能 + +- 输入日志目录并创建分析器,也可以在运行期间切换目录。 +- 查看当前目录中的全部日志文件。 +- 设置并行度,分析指定的一个或多个日志文件。 +- 设置并行度,分析当前目录中的全部日志文件。 +- 查询日志文件的分析状态,并区分未分析、分析成功、分析失败和文件不存在四种情况。 +- 使用 `KeyValueVisitor.Dump` 输出成功解析的完整日志内容,并显示解析失败时的错误信息。 +- 校验目录、并行度和文件名等输入,捕获分析过程中产生的异常,避免程序因非法输入退出。 + +![LocalCli 完整功能截图](./assets/localcli-normal.png) + +## 鲁棒性测试截图 + +![LocalCli 鲁棒性测试截图](./assets/localcli-robustness.png) + +## Q2.1 + +1. 共享变量是队列 `_items` 和完成标志 `_isCompleted`;统一用 `lock (_items)` 保护,配合 `Wait/Pulse/PulseAll` 协调生产和消费。 + +2. `LogFileAnalyzer` 的目录、分析状态及两个字典由 `_syncRoot` 加锁保护;工作线程在锁外解析文件,完成后再加锁写入 `_analysisResults`。 + +3. 使用 `if` 遇到虚假唤醒会在空队列取值,导致异常或消费者提前退出;使用 `while` 可在每次唤醒后重新检查条件,确保队列非空或生产已结束。 + +## Q2.2 + +扫描代码是 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly)`;递归时改用 `AllDirectories`,并以相对路径或完整路径作键,避免子目录同名文件冲突。 + +## Q2.3 + +使用AI,给AI的提示词之一为: + +``` + +public bool TryDequeue([NotNullWhen(true)] out T? item){ + lock (_items){ + while (_items.Count == 0 && !_isCompleted){ + Monitor.Wait(_items); + } + if (_items.Count > 0){ + item = _items.Dequeue(); + return true; + } + } + + item = default; + return false; +} +我现在这样写可能有什么问题? + +``` + +我询问AI一些接口的用法,帮忙排查错误。目前未发现AI的解答有错误。我认为本节难度偏高 diff --git a/docs/03-async-grpc/assets/remotecli-normal.png b/docs/03-async-grpc/assets/remotecli-normal.png new file mode 100644 index 0000000..5d496bf Binary files /dev/null and b/docs/03-async-grpc/assets/remotecli-normal.png differ diff --git a/docs/03-async-grpc/assets/remotecli-robustness.png b/docs/03-async-grpc/assets/remotecli-robustness.png new file mode 100644 index 0000000..3b29480 Binary files /dev/null and b/docs/03-async-grpc/assets/remotecli-robustness.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..55b1422 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,23 @@ +# T3.2 RemoteCli 实现报告 + +## 实现功能 + +- 连接并检测 LogAnalyzer Agent,支持通过命令行参数或环境变量指定服务地址。 +- 异步切换日志目录、查看日志文件、分析指定文件和分析全部文件。 +- 异步读取 `GetAnalysisResult` 响应流,区分文件头与日志条目,并将日志转换后完整输出。 +- 检查每次 RPC 返回的操作状态,分别提示非法参数、目录或文件不存在等业务错误。 +- 处理非法菜单、空输入、非法并行度、空文件列表和 gRPC 连接异常,避免程序意外退出。 + +![RemoteCli 完整功能截图](./assets/remotecli-normal.png) + +## 鲁棒性测试截图 + +![RemoteCli 鲁棒性测试截图](./assets/remotecli-robustness.png) + +## Q3.1 + +与本地程序相比,网络应用多了客户端与服务端之间的通信边界。一次调用可能因断网、服务不可用或业务状态失败,不能只考虑本地异常,还需要处理异步调用、序列化转换、状态码和流式响应。调试时也要同时启动两端,并区分传输错误与业务错误,因此状态同步、错误处理和联调过程更加复杂。 + +## Q3.2 + +我使用了AI.提示词为“请帮我解决目前T3.2”中的bug。 diff --git a/docs/04-avalonia/assets/actual-failed.png b/docs/04-avalonia/assets/actual-failed.png new file mode 100644 index 0000000..7566d5e Binary files /dev/null and b/docs/04-avalonia/assets/actual-failed.png differ diff --git a/docs/04-avalonia/assets/actual-not-analyzed.png b/docs/04-avalonia/assets/actual-not-analyzed.png new file mode 100644 index 0000000..66d01e9 Binary files /dev/null and b/docs/04-avalonia/assets/actual-not-analyzed.png differ diff --git a/docs/04-avalonia/assets/actual-success.png b/docs/04-avalonia/assets/actual-success.png new file mode 100644 index 0000000..64ff5c1 Binary files /dev/null and b/docs/04-avalonia/assets/actual-success.png differ diff --git a/docs/04-avalonia/assets/robust-invalid-directory.png b/docs/04-avalonia/assets/robust-invalid-directory.png new file mode 100644 index 0000000..fb8e047 Binary files /dev/null and b/docs/04-avalonia/assets/robust-invalid-directory.png differ diff --git a/docs/04-avalonia/assets/robust-invalid-dop.png b/docs/04-avalonia/assets/robust-invalid-dop.png new file mode 100644 index 0000000..ff30ae7 Binary files /dev/null and b/docs/04-avalonia/assets/robust-invalid-dop.png differ diff --git a/docs/04-avalonia/assets/robust-no-selection.png b/docs/04-avalonia/assets/robust-no-selection.png new file mode 100644 index 0000000..889ea20 Binary files /dev/null and b/docs/04-avalonia/assets/robust-no-selection.png differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..e2b036f --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,41 @@ +# Avalonia 作业报告 + +## T4.1 + +实现了目录刷新、多选和全部分析以及右键单文件分析和结果查看;同时校验连接,文件选择,并行度及服务端错误。 + +### 功能测试 + +分析成功: + +![分析成功](./assets/actual-success.png) + +分析失败: + +![分析失败](./assets/actual-failed.png) + +尚未分析: + +![尚未分析](./assets/actual-not-analyzed.png) + +### 鲁棒性测试 + +非法并行度: + +![非法并行度](./assets/robust-invalid-dop.png) + +未选择文件: + +![未选择文件](./assets/robust-no-selection.png) + +无效目录: + +![无效目录](./assets/robust-invalid-directory.png) + +## Q4.1 + +GUI需处理状态、交互和UI线程;异步可避免界面卡顿,但增加了异常与状态管理复杂度。 + +## Q4.2 + +使用了AI,提示词为:“请根据项目中已经实现的RemoteCli,完成T4.1的图形界面客户端”。 \ No newline at end of file diff --git a/docs/05-advanced/report.md b/docs/05-advanced/report.md new file mode 100644 index 0000000..1c8e0f8 --- /dev/null +++ b/docs/05-advanced/report.md @@ -0,0 +1,106 @@ +## 实现内容 + +本章完成了 T5.1.a.c、T5.1.b.a,并在 T5.2 中对查询功能做了扩展。 + +| 任务 | 实现 | +| --- | --- | +| T5.1.a.c | 按日志类型、时间范围、产生日志的服务、严重级别和 Request ID 组合查询;按行号、时间、服务、严重级别、类型和 Request ID 排序 | +| T5.1.b.a | 以固定列表格显示公共字段和各日志类型的 Message 字段;Info、Warning、Error 分别使用蓝、橙、红色标签 | +| T5.2 | 增加跨字段关键词搜索、服务端分页、匹配数与严重级别统计;增加系统文件夹选择器;点击日志文件后自动显示分析结果;修复左侧分析按钮文字裁切;将服务筛选改为候选列表,将时间筛选改为日期和时间选择器;切换日志文件时自动重置筛选条件 | + +## 编译与运行 + +### 环境 + +- .NET 10 SDK +- Windows、Linux 或 macOS +- 用于分析的 `.log` 文件目录 + +以下命令均在仓库的 `src` 目录执行。 + +```shell +dotnet restore dotnet-workshop.slnx +dotnet build dotnet-workshop.slnx +``` + +先启动 Agent: + +```shell +dotnet run --project LogAnalyzerAgent/LogAnalyzerAgent.csproj +``` + +默认监听地址为 `http://localhost:5000`。保持 Agent 运行,在另一个终端启动桌面客户端: + +```shell +dotnet run --project LogAnalyzerClient/LogAnalyzerClient.Desktop/LogAnalyzerClient.Desktop.csproj +``` + +## 使用方法 + +### 连接与分析 + +1. 在菜单中选择 `File > Connect...`,输入 `http://localhost:5000`。 +2. 客户端与 Agent 在同一台机器上时,点击 `Browse...` 在文件夹选择器中选择日志目录,选中后会自动切换目录。连接远程 Agent 时,需要在 `Log directory` 中输入 Agent 所在机器上的绝对路径,再点击 `Change`。 +3. 在左侧选择文件。可以点击 `Analyze selected` 分析选中文件,或点击 `Analyze all` 分析当前目录的全部日志。 +4. 点击单个文件后,右侧会自动加载它的分析结果。未分析或分析失败时,结果区会显示对应原因。原有的右键 `View results` 操作仍然保留。 + +`Parallelism` 接受非负整数。设为 `0` 时由 Agent 根据处理器数量决定并行度。 + +### 条件查询 + +结果区的筛选条件可以单独使用,也可以组合使用: + +- `Event type`:Call、Request 或 Internal。 +- `Severity`:Info、Warning 或 Error。 +- `Service`:从当前文件中已发现的服务下拉列表中选择。Agent 会将 `gateway-0`、`gateway-1` 归并为 `gateway`。 +- `Request ID`:不区分大小写的精确匹配。Internal 日志没有 Request ID,因此不会匹配此条件。 +- `From` 和 `To`:分别使用日期选择器和 24 小时制时间选择器。只选起始日期时从当日 00:00 开始;只选结束日期时包含该日全天。边界时间包含在查询范围内。 +- `Search all fields`:不区分大小写的包含搜索,覆盖 Pod、类型、严重级别、Request ID、路径、目标服务、异常名和异常消息等字段。 + +设置条件后点击 `Apply`。`Reset` 会清空所有查询条件并重新加载当前文件。 + +从左侧切换到另一个日志文件时,日志类型、严重级别、服务、Request ID、关键词和起止时间会自动重置,新文件首次显示的是未筛选结果。排序方式和每页行数保留不变。 + +### 排序与分页 + +点击表头中的 `Line`、`Timestamp`、`Service / pod`、`Severity`、`Type` 或 `Request ID` 可以排序。第一次点击按升序排列,再次点击同一列切换为降序。当前排序方式显示在表格底部。 + +每页可显示 25、50、100 或 200 条记录。翻页时 Agent 只返回当前页,但结果摘要中的匹配总数和 Info、Warning、Error 数量始终针对全部匹配记录。 + +## 实现要点 + +新增的 `QueryAnalysisResult` gRPC 使用服务端流返回一个结果头和当前页的日志。结果头包含分析状态、匹配总数、页码、页大小和三种严重级别的数量。原有 `GetAnalysisResult` RPC 保留不变,因此原有 CLI 不需要跟随修改。 + +Agent 的处理顺序为:组合过滤、统计、排序、分页。这个顺序保证了统计数据不会被当前页截断。页大小上限为 200,避免单次查询传输过多记录。 + +客户端使用统一的表格行模型承载 Call、Request 和 Internal 日志。不属于当前日志类型的列保持为空,使同一列的语义保持稳定。表格水平滚动,不会为了塞入窄窗口而截掉 Message 字段。 + +## 验证 + +执行了以下命令: + +```shell +dotnet build LogAnalyzerAgent/LogAnalyzerAgent.csproj --no-restore +dotnet build LogAnalyzerClient/LogAnalyzerClient.Desktop/LogAnalyzerClient.Desktop.csproj --no-restore +dotnet test test-03-async-grpc/test-03-async-grpc.csproj --no-restore +``` + +Agent 和桌面客户端均以 0 错误通过编译。`test-03-async-grpc` 共运行 7 项测试,全部通过。新增的 4 项测试覆盖了: + +- 类型、时间、服务、严重级别和 Request ID 的组合查询。 +- 子类专属字段的关键词搜索、降序排序和分页。 +- 非法页码、页大小和颠倒时间范围的拒绝处理。 +- 分析文件中服务名称的提取、去重与 Pod 实例后缀归并。 + +## 已知限制 + +- 查询以单个已分析文件为单位,尚不支持跨文件聚合。 +- 分析是显式触发的;日志文件在分析后发生变化时,需要重新分析。 +- 文件夹选择器只能浏览客户端所在机器。当 Agent 在另一台机器上时,客户端无法通过本地系统选择器浏览 Agent 的文件系统,需要手动输入远程路径。 +- 表格同时展示三种日志的全部字段,在较窄窗口中需要水平滚动。 + +## 开发记录 + +这次修改中最需要先确定的是查询边界。如果只在客户端过滤,客户端仍然需要接收整个日志,分页也只能减少渲染量,不能减少传输量。因此筛选、排序、统计和分页都放在 Agent,客户端只管理查询状态和展示。 + +另一个具体问题是日志中记录的是 Pod 名,但用户按服务查询时更常输入 `gateway` 而不是 `gateway-1`。当前规则同时接受完整 Pod 名和“服务名 + 连字符 + 实例后缀”,解决了数据字段与用户查询习惯不一致的问题。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..0418637 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -27,6 +27,12 @@ public static void Main(string[] args) { return null; } + directory = directory.Trim(); + if (directory.Length == 0) + { + Console.WriteLine("Directory cannot be empty, please try again:"); + continue; + } try { if (!analyzer.ChangeDirectory(directory)) @@ -41,6 +47,11 @@ public static void Main(string[] args) Console.WriteLine("Directory illegal, please try again:"); continue; } + catch (Exception ex) + { + Console.WriteLine($"Failed to open directory: {ex.Message}"); + continue; + } } return analyzer; } @@ -112,22 +123,138 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var files = analyzer.GetLogFiles(); + Console.WriteLine($"[{string.Join(", ", files)}]"); } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + var degreeOfParallelism = ReadDegreeOfParallelism(); + var fileNames = ReadFileNames(); + + analyzer.AnalyzeFiles(degreeOfParallelism, fileNames); + Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]"); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + var degreeOfParallelism = ReadDegreeOfParallelism(); + var fileNames = analyzer.GetLogFiles(); + + analyzer.AnalyzeAll(degreeOfParallelism); + Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]"); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input log file name:"); + var input = Console.ReadLine(); + if (input is null) + { + return; + } + + var fileName = input.Trim(); + if (fileName.Length == 0) + { + Console.WriteLine("File name cannot be empty."); + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + Console.WriteLine($"File {fileName} does not exist."); + return; + } + + switch (result.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File {fileName} has not been analyzed yet."); + break; + + case AnalysisState.Failed: + Console.WriteLine( + $"Analysis failed for {fileName}: {result.ErrorMessage ?? "Unknown error"}"); + break; + + case AnalysisState.Succeeded: + Console.WriteLine($"Analysis result for {fileName}:"); + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var values = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", + values.Select(pair => $"{pair.Key}: {pair.Value}"))); + } + break; + + default: + Console.WriteLine($"Unknown analysis state for {fileName}: {result.State}"); + break; + } + } + + private static int ReadDegreeOfParallelism() + { + while (true) + { + Console.WriteLine("Please input degree of parallelism:"); + var input = Console.ReadLine(); + if (input is null) + { + throw new EndOfStreamException("Input ended."); + } + + if (int.TryParse(input.Trim(), out var degreeOfParallelism) + && degreeOfParallelism >= 0) + { + return degreeOfParallelism; + } + + Console.WriteLine("Invalid degree of parallelism, please try again:"); + } + } + + private static List ReadFileNames() + { + while (true) + { + Console.WriteLine("Please input log file names (comma separated):"); + var input = Console.ReadLine(); + if (input is null) + { + throw new EndOfStreamException("Input ended."); + } + + var fileNames = input + .Split(',') + .Select(fileName => fileName.Trim()) + .Where(fileName => fileName.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (fileNames.Count > 0) + { + return fileNames; + } + + Console.WriteLine("No log file names provided, please try again:"); + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..c411933 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -137,11 +137,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } } fileList = fileNameList.Select(fileName => _logFiles[fileName]).ToList(); - - /* - * Set _isAnalyzing - */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -150,11 +146,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } finally { - /* - * Unset _isAnalyzing - * Remember to lock _syncRoot to prevent data race - */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -165,11 +160,16 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { foreach (var file in fileList) { - /* - * Filter unparsed files. - * If there is an unknown file, throw System.InvalidOperationException. - */ - throw new NotImplementedException("TODO: T2.2"); + if (!_analysisResults.TryGetValue(file.Name, out var result)) + { + throw new InvalidOperationException( + $"File '{file.Name}' is not registered in the analyzer." + ); + } + if (result.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -180,10 +180,12 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis var queue = new WorkQueue(); - /* - * Enqueue log files - */ - // TODO: T2.2 + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + + queue.CompleteAdding(); degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); var workers = new Thread[degreeOfParallelism]; @@ -191,16 +193,20 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { int workerId = i; string threadName = $"log-analyzer-worker-{workerId}"; - /* - * Create and start threads to run `WorkerMain` - */ - // TODO: T2.2 + + workers[i] = new Thread(() => WorkerMain(workerId, queue)) + { + Name = threadName, + IsBackground = true + }; + + workers[i].Start(); } - /* - * Wait for (join) all threads to end - */ - // TODO: T2.2 + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -212,20 +218,36 @@ private void WorkerMain(int workerId, WorkQueue queue) AnalysisResult result; try { - // Parse file - throw new NotImplementedException("TODO: T2.2"); + using var reader = new StreamReader(file.FullName); + + var entries = parser.Parse(reader).ToList(); + + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Succeeded, + Entries: entries, + ErrorMessage: null, + WorkerId: workerId + ); + } catch (Exception ex) { - // Save exception message to result - throw new NotImplementedException("TODO: T2.2"); + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Failed, + Entries: Array.Empty(), + ErrorMessage: ex.Message, + WorkerId: workerId + ); } - /* - * Save parse result. - * [!Important] Remember to lock _syncRoot to prevent data race. - */ - throw new NotImplementedException("TODO: T2.2"); + lock (_syncRoot) + { + _analysisResults[file.Name] = result; + } } } } diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..eba4f4f 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,50 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException( + "Cannot enqueue after adding has completed."); + } + + _items.Enqueue(item); + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + while (_items.Count == 0 && !_isCompleted) + { + Monitor.Wait(_items); + } + if (_items.Count > 0) + { + item = _items.Dequeue()!; + return true; + } + } + + item = default; + return false; } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + Monitor.PulseAll(_items); + } } } } diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..3c70be3 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -18,16 +18,27 @@ public AgentSession(LogFileAnalyzer analyzer, ILoggerFactory loggerFactory) _logger = loggerFactory.CreateLogger(); } - private static OperationStatusMessage CreateInternalErrorOperationStatus(Exception ex) + private static OperationStatusMessage CreateErrorOperationStatus( + AgentErrorCode code, + string message) { return new OperationStatusMessage() { Success = false, - Code = AgentErrorCode.InternalError, - Message = $"An error occurred while retrieving agent status: {ex.Message}", + Code = code, + Message = message, }; } + private static OperationStatusMessage CreateInternalErrorOperationStatus( + Exception ex, + string operation) + { + return CreateErrorOperationStatus( + AgentErrorCode.InternalError, + $"An error occurred while {operation}: {ex.Message}"); + } + private static OperationStatusMessage CreateNoErrorOperationStatus() { return new OperationStatusMessage() @@ -55,7 +66,9 @@ public Task GetAgentStatus(Empty empty, CancellationToke } catch (Exception ex) { - response.Status = CreateInternalErrorOperationStatus(ex); + response.Status = CreateInternalErrorOperationStatus( + ex, + "retrieving agent status"); _logger.LogError(ex, "An error occurred while retrieving agent status."); } return Task.FromResult(response); @@ -71,7 +84,9 @@ public Task GetLogFiles(Empty empty, CancellationToken canc } catch (Exception ex) { - response.Status = CreateInternalErrorOperationStatus(ex); + response.Status = CreateInternalErrorOperationStatus( + ex, + "retrieving log files"); _logger.LogError(ex, "An error occurred while retrieving log files."); } return Task.FromResult(response); @@ -79,22 +94,296 @@ public Task GetLogFiles(Empty empty, CancellationToken canc public Task ChangeDirectory(ChangeDirectoryRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new ChangeDirectoryResponse(); + try + { + var success = _analyzer.ChangeDirectory(request.DirectoryPath); + if (!success) + { + response.Status = _analyzer.IsAnalyzing + ? CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "Cannot change directory while log analysis is in progress.") + : CreateErrorOperationStatus( + AgentErrorCode.DirectoryNotFound, + $"Directory not found: {request.DirectoryPath}"); + return Task.FromResult(response); + } + response.CurrentDirectory = request.DirectoryPath; + response.FileNames.AddRange(_analyzer.GetLogFiles()); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus( + ex, + "changing directory"); + _logger.LogError(ex, "An error occurred while changing directory."); + } + return Task.FromResult(response); } public Task AnalyzeAll(AnalyzeAllRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeAllResponse(); + try + { + if (request.DegreeOfParallelism < 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be non-negative."); + return Task.FromResult(response); + } + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No log directory has been configured."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus( + ex, + "analyzing all log files"); + _logger.LogError(ex, "An error occurred while analyzing all log files."); + } + return Task.FromResult(response); } public Task AnalyzeFiles(AnalyzeFilesRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeFilesResponse(); + try + { + if (request.DegreeOfParallelism < 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be non-negative."); + return Task.FromResult(response); + } + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No log directory has been configured."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentOutOfRangeException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + ex.Message); + } + catch (ArgumentException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus( + ex, + "analyzing specified log files"); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + return Task.FromResult(response); } public IReadOnlyList GetAnalysisResult(GetAnalysisResultRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var responses = new List(); + try + { + var success = _analyzer.TryGetAnalysisResult(request.FileName, out var result); + if (!success || result is null) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = new OperationStatusMessage() + { + Success = false, + Code = AgentErrorCode.FileNotFound, + Message = $"File not found: {request.FileName}", + } + }); + return responses; + } + + responses.Add(new GetAnalysisResultResponse() + { + Header = new AnalysisResultHeaderMessage() + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + ErrorMessage = result.ErrorMessage ?? "", + WorkerId = result.WorkerId, + }, + Status = CreateNoErrorOperationStatus() + }); + + foreach (var entry in result.Entries) + { + var entryMessage = GrpcTypeConverter.ConvertToGrpc(entry); + responses.Add(new GetAnalysisResultResponse() + { + LogEntry = entryMessage, + Status = CreateNoErrorOperationStatus() + }); + } + } + catch (Exception ex) + { + responses.Add(new GetAnalysisResultResponse() + { + Header = new AnalysisResultHeaderMessage() + { + FileName = request.FileName, + FullName = "", + State = AnalysisStateEnum.NotAnalyzed, + ErrorMessage = $"An error occurred while retrieving analysis result: {ex.Message}", + WorkerId = -1, + }, + Status = CreateInternalErrorOperationStatus( + ex, + "retrieving an analysis result") + }); + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + } + return responses; + } + + public IReadOnlyList QueryAnalysisResult( + QueryAnalysisResultRequest request, + CancellationToken cancellationToken) + { + var responses = new List(); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result) || result is null) + { + responses.Add(new QueryAnalysisResultResponse + { + Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + $"File not found: {request.FileName}") + }); + return responses; + } + + var queryResult = result.State == AnalysisState.Succeeded + ? LogEntryQuery.Execute(result.Entries, request) + : new LogEntryQueryResult([], 0, request.PageNumber, request.PageSize, 0, 0, 0); + + var queryHeader = new QueryAnalysisResultHeaderMessage + { + Analysis = new AnalysisResultHeaderMessage + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + ErrorMessage = result.ErrorMessage ?? "", + WorkerId = result.WorkerId, + }, + TotalCount = queryResult.TotalCount, + PageNumber = queryResult.PageNumber, + PageSize = queryResult.PageSize, + InfoCount = queryResult.InfoCount, + WarningCount = queryResult.WarningCount, + ErrorCount = queryResult.ErrorCount, + }; + queryHeader.ServiceNames.AddRange(result.Entries + .Select(entry => LogEntryQuery.GetServiceName(entry.PodName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(serviceName => serviceName, StringComparer.OrdinalIgnoreCase)); + + responses.Add(new QueryAnalysisResultResponse + { + Header = queryHeader, + Status = CreateNoErrorOperationStatus() + }); + + foreach (var entry in queryResult.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + responses.Add(new QueryAnalysisResultResponse + { + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + Status = CreateNoErrorOperationStatus() + }); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ArgumentException ex) + { + responses.Add(new QueryAnalysisResultResponse + { + Status = CreateErrorOperationStatus(AgentErrorCode.InvalidArgument, ex.Message) + }); + } + catch (OverflowException ex) + { + responses.Add(new QueryAnalysisResultResponse + { + Status = CreateErrorOperationStatus(AgentErrorCode.InvalidArgument, ex.Message) + }); + } + catch (Exception ex) + { + responses.Add(new QueryAnalysisResultResponse + { + Status = CreateInternalErrorOperationStatus(ex, "querying an analysis result") + }); + _logger.LogError(ex, "An error occurred while querying analysis result."); + } + + return responses; } } } diff --git a/src/LogAnalyzerAgent/Applications/LogEntryQuery.cs b/src/LogAnalyzerAgent/Applications/LogEntryQuery.cs new file mode 100644 index 0000000..65fc0b4 --- /dev/null +++ b/src/LogAnalyzerAgent/Applications/LogEntryQuery.cs @@ -0,0 +1,190 @@ +using LogAnalyzerRpc; +using LogAnalyzerRpc.Protos; +using LogParser.Models; + +namespace LogAnalyzerAgent.Applications; + +public sealed record LogEntryQueryResult( + IReadOnlyList Entries, + int TotalCount, + int PageNumber, + int PageSize, + int InfoCount, + int WarningCount, + int ErrorCount); + +public static class LogEntryQuery +{ + public const int DefaultPageSize = 50; + public const int MaxPageSize = 200; + + public static string GetServiceName(string podName) + { + var separatorIndex = podName.LastIndexOf('-'); + if (separatorIndex > 0 + && int.TryParse(podName[(separatorIndex + 1)..], out _)) + { + return podName[..separatorIndex]; + } + + return podName; + } + + public static LogEntryQueryResult Execute( + IEnumerable source, + QueryAnalysisResultRequest request) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(request); + + if (request.PageNumber < 1) + { + throw new ArgumentOutOfRangeException( + nameof(request.PageNumber), + "Page number must be greater than zero."); + } + + var pageSize = request.PageSize == 0 ? DefaultPageSize : request.PageSize; + if (pageSize is < 1 or > MaxPageSize) + { + throw new ArgumentOutOfRangeException( + nameof(request.PageSize), + $"Page size must be between 1 and {MaxPageSize}."); + } + + if (request.StartTime is not null + && request.EndTime is not null + && request.StartTime.ToDateTimeOffset() > request.EndTime.ToDateTimeOffset()) + { + throw new ArgumentException("Start time must not be later than end time."); + } + + var query = source.Where(entry => Matches(entry, request)); + var matched = query.ToList(); + var sorted = Sort(matched, request.SortField, request.SortDescending); + var pageCount = Math.Max(1, (int)Math.Ceiling(matched.Count / (double)pageSize)); + var pageNumber = Math.Min(request.PageNumber, pageCount); + var offset = checked((pageNumber - 1) * pageSize); + + return new LogEntryQueryResult( + sorted.Skip(offset).Take(pageSize).ToList(), + matched.Count, + pageNumber, + pageSize, + matched.Count(entry => entry.Severity == LogSeverity.Info), + matched.Count(entry => entry.Severity == LogSeverity.Warning), + matched.Count(entry => entry.Severity == LogSeverity.Error)); + } + + private static bool Matches(LogEntry entry, QueryAnalysisResultRequest request) + { + if (request.HasEventType + && entry.EventType != GrpcTypeConverter.ConvertFromGrpc(request.EventType)) + { + return false; + } + + if (request.StartTime is not null + && entry.Timestamp < request.StartTime.ToDateTimeOffset()) + { + return false; + } + + if (request.EndTime is not null + && entry.Timestamp > request.EndTime.ToDateTimeOffset()) + { + return false; + } + + if (request.HasSeverity + && entry.Severity != GrpcTypeConverter.ConvertFromGrpc(request.Severity)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(request.ServiceName) + && !MatchesService(entry.PodName, request.ServiceName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(request.RequestId) + && !string.Equals( + GetRequestId(entry), + request.RequestId.Trim(), + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return string.IsNullOrWhiteSpace(request.SearchText) + || GetSearchableValues(entry).Any(value => value.Contains( + request.SearchText.Trim(), + StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesService(string podName, string requestedService) + { + var serviceName = requestedService.Trim(); + return string.Equals(podName, serviceName, StringComparison.OrdinalIgnoreCase) + || string.Equals( + GetServiceName(podName), + serviceName, + StringComparison.OrdinalIgnoreCase); + } + + private static string GetRequestId(LogEntry entry) => entry switch + { + CallLogEntry call => call.RequestId, + RequestLogEntry request => request.RequestId, + _ => string.Empty, + }; + + private static IEnumerable GetSearchableValues(LogEntry entry) + { + yield return entry.PodName; + yield return entry.Severity.ToString(); + yield return entry.EventType.ToString(); + yield return entry.Timestamp.ToString("O"); + + switch (entry) + { + case CallLogEntry call: + yield return call.RequestId; + yield return call.TargetService; + yield return call.DurationMs.ToString(); + break; + case RequestLogEntry request: + yield return request.RequestId; + yield return request.Method; + yield return request.Path; + yield return request.StatusCode.ToString(); + break; + case InternalLogEntry internalEntry: + yield return internalEntry.ExceptionName; + yield return internalEntry.ExceptionMessage; + break; + } + } + + private static IOrderedEnumerable Sort( + IEnumerable entries, + LogSortFieldEnum sortField, + bool descending) + { + Func selector = sortField switch + { + LogSortFieldEnum.LineNo => entry => entry.LineNo, + LogSortFieldEnum.Timestamp => entry => entry.Timestamp, + LogSortFieldEnum.PodName => entry => entry.PodName, + LogSortFieldEnum.Severity => entry => entry.Severity, + LogSortFieldEnum.EventType => entry => entry.EventType, + LogSortFieldEnum.RequestId => entry => GetRequestId(entry), + _ => throw new ArgumentOutOfRangeException(nameof(sortField), sortField, null), + }; + + return descending + ? entries.OrderByDescending(selector).ThenByDescending(entry => entry.LineNo) + : entries.OrderBy(selector).ThenBy(entry => entry.LineNo); + } +} diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..b520a22 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,40 @@ public override Task GetAgentStatus(Empty empty, ServerC public override Task ChangeDirectory(ChangeDirectoryRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.ChangeDirectory(request, context.CancellationToken); } public override Task GetLogFiles(Empty empty, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.GetLogFiles(empty, context.CancellationToken); } public override Task AnalyzeAll(AnalyzeAllRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.AnalyzeAll(request, context.CancellationToken); } public override Task AnalyzeFiles(AnalyzeFilesRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.AnalyzeFiles(request, context.CancellationToken); } public override async Task GetAnalysisResult(GetAnalysisResultRequest request, IServerStreamWriter responseStream, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + var responses = _session.GetAnalysisResult(request, context.CancellationToken); + foreach (var response in responses) + { + await responseStream.WriteAsync(response); + } + } + + public override async Task QueryAnalysisResult(QueryAnalysisResultRequest request, IServerStreamWriter responseStream, ServerCallContext context) + { + var responses = _session.QueryAnalysisResult(request, context.CancellationToken); + foreach (var response in responses) + { + await responseStream.WriteAsync(response, context.CancellationToken); + } } } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..b1fc067 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -1,18 +1,78 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; +using LogAnalyzerRpc.Protos; +using System; -namespace LogAnalyzerClient.Models +namespace LogAnalyzerClient.Models; + +public sealed record LogFileItem(string FileName) { - public sealed record LogFileItem(string FileName) + public override string ToString() => FileName; +} + +public sealed record FilterOption(string Label, string? Value); + +public sealed record LogTableRow( + int LineNo, + string Timestamp, + string PodName, + LogSeverityEnum Severity, + string EventType, + string RequestId, + string TargetService, + string DurationMs, + string Method, + string Path, + string StatusCode, + string ExceptionName, + string ExceptionMessage) +{ + public string SeverityText => Severity.ToString(); + public bool IsInfo => Severity == LogSeverityEnum.Info; + public bool IsWarning => Severity == LogSeverityEnum.Warning; + public bool IsError => Severity == LogSeverityEnum.Error; + + public static LogTableRow FromGrpc(LogEntryMessage message) { - public override string ToString() => FileName; + return message.EntryCase switch + { + LogEntryMessage.EntryOneofCase.CallLogEntry => new LogTableRow( + message.CallLogEntry.LineNo, + FormatTimestamp(message.CallLogEntry.Timestamp), + message.CallLogEntry.PodName, + message.CallLogEntry.Severity, + "Call", + message.CallLogEntry.RequestId, + message.CallLogEntry.TargetService, + message.CallLogEntry.DurationMs.ToString(), + "", "", "", "", ""), + LogEntryMessage.EntryOneofCase.RequestLogEntry => new LogTableRow( + message.RequestLogEntry.LineNo, + FormatTimestamp(message.RequestLogEntry.Timestamp), + message.RequestLogEntry.PodName, + message.RequestLogEntry.Severity, + "Request", + message.RequestLogEntry.RequestId, + "", "", + message.RequestLogEntry.Method, + message.RequestLogEntry.Path, + message.RequestLogEntry.StatusCode.ToString(), + "", ""), + LogEntryMessage.EntryOneofCase.InternalLogEntry => new LogTableRow( + message.InternalLogEntry.LineNo, + FormatTimestamp(message.InternalLogEntry.Timestamp), + message.InternalLogEntry.PodName, + message.InternalLogEntry.Severity, + "Internal", + "", "", "", "", "", "", + message.InternalLogEntry.ExceptionName, + message.InternalLogEntry.ExceptionMessage), + _ => throw new ArgumentException( + $"Unknown log entry type: {message.EntryCase}.", + nameof(message)), + }; } - public sealed record LogFields(int Index, IReadOnlyList Fields, string? ErrorMessage) + private static string FormatTimestamp(Google.Protobuf.WellKnownTypes.Timestamp value) { - public string Summary => "TODO: T4.1"; + return value.ToDateTimeOffset().ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'"); } - - public sealed record LogFieldItem(string Key, string Value); } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml index 5f30195..fccc381 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml @@ -12,7 +12,6 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs index 91c05a8..cd09b6f 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -1,173 +1,628 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using LogAnalyzerClient.Helpers; using LogAnalyzerClient.Models; using LogAnalyzerClient.Services; -using LogAnalyzerRpc; using LogAnalyzerRpc.Protos; -using LogParser.Visitors; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; -namespace LogAnalyzerClient.ViewModels +namespace LogAnalyzerClient.ViewModels; + +using LogAnalyzerAgentServiceClient = LogAnalyzerAgentService.LogAnalyzerAgentServiceClient; + +public partial class MainViewModel : ViewModelBase { - using LogAnalyzerAgentServiceClient = LogAnalyzerAgentService.LogAnalyzerAgentServiceClient; + internal IDialogHelper DialogHelper { get; set; } = new NullDialogHelper(); + + private LogAnalyzerAgentServiceClient? _client; + private string? _resultFileName; + + public IReadOnlyList SelectedFiles { get; set; } = []; + + public IReadOnlyList EventTypeFilters { get; } = + [ + new("All event types", null), + new("Call", nameof(LogEventTypeEnum.Call)), + new("Request", nameof(LogEventTypeEnum.Request)), + new("Internal", nameof(LogEventTypeEnum.Internal)), + ]; + + public IReadOnlyList SeverityFilters { get; } = + [ + new("All severities", null), + new("Info", nameof(LogSeverityEnum.Info)), + new("Warning", nameof(LogSeverityEnum.Warning)), + new("Error", nameof(LogSeverityEnum.Error)), + ]; - public partial class MainViewModel : ViewModelBase + public IReadOnlyList PageSizeOptions { get; } = [25, 50, 100, 200]; + public ObservableCollection ServiceFilters { get; } = + [ + new("All services", null), + ]; + + [ObservableProperty] private string _directoryPath = ""; + [ObservableProperty] private string _currentDirectory = ""; + [ObservableProperty] private string _degreeOfParallelismText = "1"; + [ObservableProperty] private string _currentAddress = ""; + + private static class ConnectStatusString { - internal IDialogHelper DialogHelper { get; set; } = new NullDialogHelper(); + public const string NotConnected = "Not connected"; + public const string Connecting = "Connecting"; + public const string Connected = "Connected"; + public const string ConnectFailed = "Connection failed"; + } - private LogAnalyzerAgentServiceClient? _client = null; + [ObservableProperty] private string _connectStatus = ConnectStatusString.NotConnected; + [ObservableProperty] private ObservableCollection _logFiles = []; + [ObservableProperty] private LogFileItem? _selectedLogFile; + [ObservableProperty] private ObservableCollection _resultEntries = []; + [ObservableProperty] private FilterOption _selectedEventTypeFilter; + [ObservableProperty] private FilterOption _selectedSeverityFilter; + [ObservableProperty] private FilterOption _selectedServiceFilter; + [ObservableProperty] private DateTimeOffset? _startDate; + [ObservableProperty] private TimeSpan? _startTime; + [ObservableProperty] private DateTimeOffset? _endDate; + [ObservableProperty] private TimeSpan? _endTime; + [ObservableProperty] private string _requestIdFilter = ""; + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private int _selectedPageSize = 50; + [ObservableProperty] private int _currentPage = 1; + [ObservableProperty] private int _totalPages = 1; + [ObservableProperty] private bool _canGoToPreviousPage; + [ObservableProperty] private bool _canGoToNextPage; + [ObservableProperty] private string _resultTitle = "Analysis Result"; + [ObservableProperty] private string _resultSummary = "Select an analyzed file to view its entries."; + [ObservableProperty] private string _resultMessage = "No result loaded."; + [ObservableProperty] private bool _isResultMessageVisible = true; + [ObservableProperty] private string _sortDescription = "Line, ascending"; - public IReadOnlyList SelectedFiles { get; set; } = new List(); + private LogSortFieldEnum _sortField = LogSortFieldEnum.LineNo; + private bool _sortDescending; - [ObservableProperty] - private string _greeting = "Welcome to Avalonia!"; + public MainViewModel() + { + _selectedEventTypeFilter = EventTypeFilters[0]; + _selectedSeverityFilter = SeverityFilters[0]; + _selectedServiceFilter = ServiceFilters[0]; + } - [ObservableProperty] - private string _directoryPath = ""; + [RelayCommand] + private async Task ConnectAsync() + { + var address = await DialogHelper.ShowConnectDialogAsync(CurrentAddress); + if (address is null) + { + return; + } - [ObservableProperty] - private string _degreeOfParallelismText = "1"; + if (string.IsNullOrWhiteSpace(address)) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Address cannot be empty."); + return; + } - [ObservableProperty] - private string _currentAddress = ""; - private static class ConnectStatusString + try + { + address = address.Trim(); + ConnectStatus = ConnectStatusString.Connecting; + _client = AppService.ClientFactory.CreateClient(address); + await _client.PingAsync(new Empty()); + CurrentAddress = address; + ConnectStatus = ConnectStatusString.Connected; + DirectoryPath = ""; + CurrentDirectory = ""; + LogFiles.Clear(); + ClearResult("No result loaded."); + } + catch (Exception ex) { - public const string NOT_CONNECTED = "Not connected."; - public const string CONNECTING = "Connecting..."; - public const string CONNECTED = "Connected."; - public const string CONNECT_FAILED = "Connect failed."; + _client = null; + CurrentAddress = ""; + DirectoryPath = ""; + CurrentDirectory = ""; + LogFiles.Clear(); + ClearResult("No result loaded."); + ConnectStatus = ConnectStatusString.ConnectFailed; + await DialogHelper.ShowMessageDialogAsync( + "Error", + $"Failed to connect to agent: {ex.Message}"); + ConnectStatus = ConnectStatusString.NotConnected; } - [ObservableProperty] - private string _connectStatus = ConnectStatusString.NOT_CONNECTED; + } - [ObservableProperty] - private ObservableCollection _logFiles = new(); + private async Task WithClientNotNull(Func action) + { + if (_client is null) + { + await DialogHelper.ShowMessageDialogAsync( + "Error", + "Agent is not connected. Please connect to an agent first."); + return; + } - [ObservableProperty] - private LogFileItem? _selectedLogFile = null; + try + { + await action(); + } + catch (Exception ex) + { + await DialogHelper.ShowMessageDialogAsync("Error", $"Error occurred: {ex.Message}"); + } + } + + [RelayCommand] + private async Task ChangeDirectoryAsync() + { + await WithClientNotNull(async () => + { + var response = await _client!.ChangeDirectoryAsync(new ChangeDirectoryRequest + { + DirectoryPath = DirectoryPath, + }); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync( + "Error", + $"{response.Status.Code}: {response.Status.Message}"); + DirectoryPath = CurrentDirectory; + return; + } - [ObservableProperty] - private ObservableCollection _resultEntries = new(); + DirectoryPath = response.CurrentDirectory; + CurrentDirectory = response.CurrentDirectory; + ClearResult("No result loaded."); + await RefreshAsync(); + }); + } - [RelayCommand] - private async Task ConnectAsync() + [RelayCommand] + private async Task RefreshAsync() + { + await WithClientNotNull(async () => { - var address = await DialogHelper.ShowConnectDialogAsync(CurrentAddress); - if (address is null) + var response = await _client!.GetLogFilesAsync(new Empty()); + if (!await EnsureSuccessAsync(response.Status)) { - // Do nothing if the user cancels the dialog + return; } - else if (string.IsNullOrEmpty(address.Trim())) + + LogFiles = new ObservableCollection( + response.FileNames.Select(fileName => new LogFileItem(fileName))); + SelectedLogFile = null; + SelectedFiles = []; + }); + } + + [RelayCommand] + private async Task AnalyzeSelectedFilesAsync() + { + if (SelectedFiles.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Please select at least one file."); + return; + } + if (!await TryGetDegreeOfParallelismAsync()) + { + return; + } + + await WithClientNotNull(async () => + { + var response = await _client!.AnalyzeFilesAsync(new AnalyzeFilesRequest + { + DegreeOfParallelism = int.Parse(DegreeOfParallelismText), + FileNames = { SelectedFiles } + }); + if (await EnsureSuccessAsync(response.Status) && SelectedLogFile is not null) { - await DialogHelper.ShowMessageDialogAsync("Error", "Address cannot be empty."); + await GetAnalysisResultAsync(); } - else + }); + } + + [RelayCommand] + private async Task AnalyzeAllAsync() + { + if (!await TryGetDegreeOfParallelismAsync()) + { + return; + } + + await WithClientNotNull(async () => + { + var response = await _client!.AnalyzeAllAsync(new AnalyzeAllRequest { - try - { - ConnectStatus = ConnectStatusString.CONNECTING; - _client = AppService.ClientFactory.CreateClient(address); - await _client.PingAsync(new Empty()); - CurrentAddress = address; - ConnectStatus = ConnectStatusString.CONNECTED; - LogFiles.Clear(); - } - catch (Exception ex) - { - ConnectStatus = ConnectStatusString.CONNECT_FAILED; - await DialogHelper.ShowMessageDialogAsync("Error", $"Failed to connect to agent: {ex.Message}"); - ConnectStatus = ConnectStatusString.NOT_CONNECTED; - } + DegreeOfParallelism = int.Parse(DegreeOfParallelismText) + }); + if (await EnsureSuccessAsync(response.Status) && SelectedLogFile is not null) + { + await GetAnalysisResultAsync(); } + }); + } + + [RelayCommand] + private async Task AnalyzeRightClickedFileAsync() + { + if (!await TryGetSelectedLogFileAsync() || !await TryGetDegreeOfParallelismAsync()) + { + return; } - private async Task WithClientNotNull(Func action) + await WithClientNotNull(async () => { - if (_client is null) + var response = await _client!.AnalyzeFilesAsync(new AnalyzeFilesRequest { - await DialogHelper.ShowMessageDialogAsync("Error", - "Agent is not connected. Please connect to an agent first."); + DegreeOfParallelism = int.Parse(DegreeOfParallelismText), + FileNames = { SelectedLogFile!.FileName } + }); + if (await EnsureSuccessAsync(response.Status)) + { + await GetAnalysisResultAsync(); } - else + }); + } + + [RelayCommand] + private async Task GetAnalysisResultAsync() + { + if (!await TryGetSelectedLogFileAsync()) + { + return; + } + + await SelectLogFileAsync(SelectedLogFile!); + } + + public async Task SelectLogFileAsync(LogFileItem file) + { + if (!string.Equals(_resultFileName, file.FileName, StringComparison.Ordinal)) + { + ResetFilterValues(clearServiceOptions: true); + } + + SelectedLogFile = file; + _resultFileName = file.FileName; + CurrentPage = 1; + await QueryAnalysisResultAsync(); + } + + [RelayCommand] + private async Task ResetFiltersAsync() + { + ResetFilterValues(clearServiceOptions: false); + CurrentPage = 1; + + if (_resultFileName is not null) + { + await QueryAnalysisResultAsync(); + } + } + + private void ResetFilterValues(bool clearServiceOptions) + { + SelectedEventTypeFilter = EventTypeFilters[0]; + SelectedSeverityFilter = SeverityFilters[0]; + if (clearServiceOptions) + { + UpdateServiceFilters([]); + } + else + { + SelectedServiceFilter = ServiceFilters[0]; + } + StartDate = null; + StartTime = null; + EndDate = null; + EndTime = null; + RequestIdFilter = ""; + SearchText = ""; + } + + [RelayCommand] + private async Task ChangeSortAsync(string? fieldName) + { + if (!System.Enum.TryParse(fieldName, out var requestedSort)) + { + return; + } + + if (_sortField == requestedSort) + { + _sortDescending = !_sortDescending; + } + else + { + _sortField = requestedSort; + _sortDescending = false; + } + + SortDescription = $"{GetSortLabel(_sortField)}, {(_sortDescending ? "descending" : "ascending")}"; + CurrentPage = 1; + if (_resultFileName is not null) + { + await QueryAnalysisResultAsync(); + } + } + + [RelayCommand] + private async Task PreviousPageAsync() + { + if (!CanGoToPreviousPage || _resultFileName is null) + { + return; + } + CurrentPage--; + await QueryAnalysisResultAsync(); + } + + [RelayCommand] + private async Task NextPageAsync() + { + if (!CanGoToNextPage || _resultFileName is null) + { + return; + } + CurrentPage++; + await QueryAnalysisResultAsync(); + } + + private async Task QueryAnalysisResultAsync() + { + if (_resultFileName is null) + { + return; + } + + var request = await BuildQueryRequestAsync(); + if (request is null) + { + return; + } + + await WithClientNotNull(async () => + { + QueryAnalysisResultHeaderMessage? header = null; + var entries = new List(); + using var call = _client!.QueryAnalysisResult(request); + await foreach (var response in call.ResponseStream.ReadAllAsync()) { - try + if (!await EnsureSuccessAsync(response.Status)) { - await action(); + return; } - catch (Exception ex) + + switch (response.PayloadCase) { - await DialogHelper.ShowMessageDialogAsync("Error", $"Error occurred: {ex.Message}"); + case QueryAnalysisResultResponse.PayloadOneofCase.Header: + header = response.Header; + break; + case QueryAnalysisResultResponse.PayloadOneofCase.LogEntry: + entries.Add(response.LogEntry); + break; + default: + throw new ClientInternalException("Agent returned an empty query payload."); } } + + if (string.Equals(request.FileName, _resultFileName, StringComparison.Ordinal)) + { + ShowQueryResult(header, entries); + } + }); + } + + private async Task BuildQueryRequestAsync() + { + var request = new QueryAnalysisResultRequest + { + FileName = _resultFileName, + ServiceName = SelectedServiceFilter.Value ?? "", + RequestId = RequestIdFilter.Trim(), + SearchText = SearchText.Trim(), + SortField = _sortField, + SortDescending = _sortDescending, + PageNumber = CurrentPage, + PageSize = SelectedPageSize, + }; + + if (SelectedEventTypeFilter.Value is not null) + { + request.EventType = System.Enum.Parse(SelectedEventTypeFilter.Value); + } + if (SelectedSeverityFilter.Value is not null) + { + request.Severity = System.Enum.Parse(SelectedSeverityFilter.Value); } - [RelayCommand] - private async Task ChangeDirectoryAsync() + if (StartDate is null && StartTime is not null) { - await WithClientNotNull(async() => - { - var request = new ChangeDirectoryRequest() - { - DirectoryPath = DirectoryPath, - }; - var response = await _client!.ChangeDirectoryAsync(request); - if (!response.Status.Success) - { - await DialogHelper.ShowMessageDialogAsync("Error", - $"{response.Status.Code}: {response.Status.Message}"); - } - await RefreshAsync(); - }); + await DialogHelper.ShowMessageDialogAsync( + "Invalid start time", + "Select a start date before selecting a start time."); + return null; + } + if (EndDate is null && EndTime is not null) + { + await DialogHelper.ShowMessageDialogAsync( + "Invalid end time", + "Select an end date before selecting an end time."); + return null; } - [RelayCommand] - private async Task RefreshAsync() + DateTimeOffset? startTime = StartDate is null + ? null + : CombineDateAndTime(StartDate.Value, StartTime, endOfDay: false); + DateTimeOffset? endTime = EndDate is null + ? null + : CombineDateAndTime(EndDate.Value, EndTime, endOfDay: true); + if (startTime is not null && endTime is not null && startTime > endTime) { - await WithClientNotNull(async () => - { - throw new NotImplementedException("TODO: T4.1"); - }); + await DialogHelper.ShowMessageDialogAsync( + "Invalid time range", + "Start time must not be later than end time."); + return null; } - [RelayCommand] - private async Task AnalyzeSelectedFilesAsync() + if (startTime is not null) { - throw new NotImplementedException("TODO: T4.1"); + request.StartTime = Timestamp.FromDateTimeOffset(startTime.Value); } + if (endTime is not null) + { + request.EndTime = Timestamp.FromDateTimeOffset(endTime.Value); + } + return request; + } - /* - * TODO: T4.1 - * Add AnalyzeAllAsync ReplayCommand - */ + private static DateTimeOffset CombineDateAndTime( + DateTimeOffset date, + TimeSpan? time, + bool endOfDay) + { + var defaultTime = endOfDay + ? TimeSpan.FromDays(1) - TimeSpan.FromTicks(1) + : TimeSpan.Zero; + var dateTime = DateTime.SpecifyKind( + date.Date + (time ?? defaultTime), + DateTimeKind.Unspecified); + return new DateTimeOffset(dateTime, date.Offset); + } - [RelayCommand] - private async Task AnalyzeRightClickedFileAsync() + private void ShowQueryResult( + QueryAnalysisResultHeaderMessage? header, + IReadOnlyList entries) + { + if (header?.Analysis is null) { - throw new NotImplementedException("TODO: T4.1"); + throw new ClientInternalException("Agent did not return a query result header."); } - [RelayCommand] - private async Task GetAnalysisResultAsync() + ResultTitle = $"Analysis Result - {header.Analysis.FileName}"; + UpdateServiceFilters(header.ServiceNames); + switch (header.Analysis.State) { - throw new NotImplementedException("TODO: T4.1"); + case AnalysisStateEnum.NotAnalyzed: + ClearResult($"{header.Analysis.FileName} has not been analyzed yet.", keepTitle: true); + break; + case AnalysisStateEnum.Failed: + ClearResult( + header.Analysis.HasErrorMessage + ? header.Analysis.ErrorMessage + : $"Analysis failed for {header.Analysis.FileName}.", + keepTitle: true); + break; + case AnalysisStateEnum.Succeeded: + ResultEntries = new ObservableCollection( + entries.Select(LogTableRow.FromGrpc)); + CurrentPage = header.PageNumber; + TotalPages = Math.Max(1, (int)Math.Ceiling(header.TotalCount / (double)header.PageSize)); + CanGoToPreviousPage = CurrentPage > 1; + CanGoToNextPage = CurrentPage < TotalPages; + ResultSummary = $"{header.TotalCount} matched | {header.InfoCount} info | " + + $"{header.WarningCount} warning | {header.ErrorCount} error | " + + $"sorted by {SortDescription}"; + ResultMessage = header.TotalCount == 0 + ? "No log entries match the current filters." + : ""; + IsResultMessageVisible = header.TotalCount == 0; + break; + default: + throw new ClientInternalException( + $"Unknown analysis state: {header.Analysis.State}."); } + } - [RelayCommand] - private async Task AboutAsync() + private void ClearResult(string message, bool keepTitle = false) + { + _resultFileName = keepTitle ? _resultFileName : null; + ResultEntries.Clear(); + if (!keepTitle) { - await DialogHelper.ShowMessageDialogAsync("About", - """ - LogAnalyzerClient - EESAST Software Center - https://github.com/eesast/dotnet-workshop - """); + ResultTitle = "Analysis Result"; + UpdateServiceFilters([]); } + ResultSummary = ""; + ResultMessage = message; + IsResultMessageVisible = true; + CurrentPage = 1; + TotalPages = 1; + CanGoToPreviousPage = false; + CanGoToNextPage = false; + } + + private void UpdateServiceFilters(IEnumerable serviceNames) + { + var selectedValue = SelectedServiceFilter?.Value; + ServiceFilters.Clear(); + ServiceFilters.Add(new FilterOption("All services", null)); + foreach (var serviceName in serviceNames) + { + ServiceFilters.Add(new FilterOption(serviceName, serviceName)); + } + + SelectedServiceFilter = ServiceFilters.FirstOrDefault( + option => string.Equals(option.Value, selectedValue, StringComparison.OrdinalIgnoreCase)) + ?? ServiceFilters[0]; + } + + private async Task EnsureSuccessAsync(OperationStatusMessage status) + { + if (status.Success) + { + return true; + } + await DialogHelper.ShowMessageDialogAsync("Error", $"{status.Code}: {status.Message}"); + return false; + } + + private async Task TryGetDegreeOfParallelismAsync() + { + if (int.TryParse(DegreeOfParallelismText, out var value) && value >= 0) + { + DegreeOfParallelismText = value.ToString(); + return true; + } + await DialogHelper.ShowMessageDialogAsync( + "Error", + "Degree of parallelism must be a non-negative integer."); + return false; + } + + private async Task TryGetSelectedLogFileAsync() + { + if (SelectedLogFile is not null) + { + return true; + } + await DialogHelper.ShowMessageDialogAsync("Error", "Please select a file first."); + return false; + } + + private static string GetSortLabel(LogSortFieldEnum field) => field switch + { + LogSortFieldEnum.LineNo => "Line", + LogSortFieldEnum.Timestamp => "Timestamp", + LogSortFieldEnum.PodName => "Service", + LogSortFieldEnum.Severity => "Severity", + LogSortFieldEnum.EventType => "Type", + LogSortFieldEnum.RequestId => "Request ID", + _ => field.ToString(), + }; + + [RelayCommand] + private async Task AboutAsync() + { + await DialogHelper.ShowMessageDialogAsync( + "About", + "LogAnalyzerClient\nEESAST Software Center\nhttps://github.com/eesast/dotnet-workshop"); } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..2404169 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml @@ -1,15 +1,15 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:vm="using:LogAnalyzerClient.ViewModels" + xmlns:models="using:LogAnalyzerClient.Models" + mc:Ignorable="d" + d:DesignWidth="1280" + d:DesignHeight="760" + x:Class="LogAnalyzerClient.Views.MainView" + x:DataType="vm:MainViewModel"> - @@ -24,118 +24,253 @@ to set the actual DataContext for runtime, set the DataContext property in code - - - - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + - - - - -