diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..15cb63d --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,31 @@ +# 01-basic 问答报告 + +## Q1.1 + +日志的 CSV 字段不是通过手动调用字符串的 `Split` 方法进行分割的,而是由 CsvHelper 完成。`LogFileParser.Parse` 中的 `csv.GetRecords()` 逐条读取 CSV 记录;`LogRecordMap` 通过 `Map(...).Index(...)` 指定字段含义,其中索引 0 到 3 依次对应 `LineNo`、`Timestamp`、`PodName` 和 `Message`。 + +JSON 格式的 `message` 在 `LineParser.ParseLine` 中被判断种类。代码先调用 `JsonDocument.Parse(logRecord.Message)` 得到 JSON 文档,再通过 `root.TryGetProperty("event", out var eventElement)` 读取 `event`,最后根据 `eventElement.GetString()` 的结果在 `switch` 表达式中选择 `CreateCall`、`CreateRequest` 或 `CreateInternal`。 + +确定日志种类后,代码调用 System.Text.Json 提供的 `JsonSerializer.Deserialize(logRecord.Message, options)`,将 JSON 反序列化为对应的消息记录。各消息记录的属性使用了 `[property: JsonRequired]`,因此必需字段缺失时,反序列化会抛出 `JsonException`。`options` 的 `PropertyNamingPolicy` 被设置为 `JsonNamingPolicy.KebabCaseLower`,所以 C# 中的 `RequestId`、`StatusCode` 等大驼峰属性能够分别对应 JSON 中的 `request-id`、`status-code` 等烤串命名字段。 + +## Q1.2 + +以 Call 事件为例,调用链如下: + +1. `Dictionary KeyValueVisitor.Dump(LogEntry entry)` +2. `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)` +3. `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +`Dump` 持有的是 `LogEntry` 基类引用,它先调用具体日志对象的 `Accept`。`CallLogEntry.Accept` 再执行 `visitor.Visit(this)`;由于此处 `this` 的类型是 `CallLogEntry`,最终会进入接收 `CallLogEntry` 参数的 `Visit` 重载。这使访问者能够针对不同日志类型执行不同的导出逻辑。 + +## Q1.3.b + +本次作业使用了 AI。我使用过的主要提示词包括: + +> 请你读dotnet-guidance,教我完成01-basic的作业 + +> 我还是不会T1.3,请给我修改,并完成问答报告 + +AI 的主要帮助是快速定位了 T1.2 和 T1.3 对应的文件、未实现的方法和测试要求,并结合已有的 Call 实现解释了 Request、Internal 的实现方式。对我不熟悉的访问者模式,AI 把 `Dump -> Accept -> Visit` 的调用关系具体对应到项目代码,比只搜索访问者模式的通用定义更直接。它还可以统一核对字典键名、时间格式和数值转换,减少因为字段拼写不一致造成的测试失败。 + +AI 的不足是,第一次说明仍然包含较多概念和步骤,没有立刻解决我对 T1.3 具体写法的疑问。AI 能生成能够通过当前测试的实现,但不能证明我已经理解访问者模式,也可能忽略课程测试之外的边界情况。问答报告中的个人体验也无法由 AI 代替,因此我仍需要阅读代码、运行 Release 测试,并根据自己的理解检查和修改生成的内容。相比完全依靠搜索引擎,AI 整理项目内信息更快;相比自己独立完成,它减少了探索过程,但也更容易让我在没有理解代码的情况下直接接受答案。 diff --git a/docs/02-multithreading/assets/t2.3-functional.png b/docs/02-multithreading/assets/t2.3-functional.png new file mode 100644 index 0000000..691b732 Binary files /dev/null and b/docs/02-multithreading/assets/t2.3-functional.png differ diff --git a/docs/02-multithreading/assets/t2.3-robustness.png b/docs/02-multithreading/assets/t2.3-robustness.png new file mode 100644 index 0000000..afe3350 Binary files /dev/null and b/docs/02-multithreading/assets/t2.3-robustness.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..2cbbbfc --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,57 @@ +# 02-multithreading 实现报告 + +## T2.3 功能说明 + +本节实现了一个目录级多线程日志分析器及其控制台交互界面,主要功能包括: + +1. 输入或切换日志目录,并扫描目录第一层中的全部 `.log` 文件。 +2. 显示当前目录中的日志文件名。 +3. 根据指定并行度分析一组逗号分隔的日志文件,或者分析全部日志文件;并行度为 `0` 时使用逻辑处理器数量。 +4. 查询文件的分析结果。未分析文件会显示提示;分析成功时使用 `KeyValueVisitor.Dump` 输出每条日志;分析失败时显示异常信息。 +5. 检查空目录、不存在目录、非法菜单选项、非整数或负数并行度、空文件名以及不存在的日志文件,发生输入错误后返回交互流程,不会导致程序崩溃。 + +完整功能的自动化控制台会话如下。该会话覆盖文件列表、未分析状态、指定文件分析、成功结果、失败结果和全部文件分析,程序最终以退出码 `0` 结束。 + +![T2.3 完整功能](./assets/t2.3-functional.png) + +鲁棒性测试会话如下。输入序列包含空目录、不存在目录、非数字菜单、越界菜单、非数字和负数并行度、空文件列表、未知文件、空查询文件名等情况,程序均给出提示并继续运行。 + +![T2.3 鲁棒性测试](./assets/t2.3-robustness.png) + +## Q2.1 + +`WorkQueue` 中需要在线程间共享的状态是 `_items` 队列中的内容和 `_isCompleted` 完成标志。它们都使用 `_items` 对象作为同一把管程锁,通过 `lock (_items)` 保护。消费者发现队列为空且尚未完成添加时调用 `Monitor.Wait(_items)`,等待期间会释放锁;生产者入队后调用 `Monitor.Pulse(_items)` 唤醒一个消费者;完成添加后调用 `Monitor.PulseAll(_items)` 唤醒全部消费者,使其在队列排空后退出。 + +`LogFileAnalyzer` 中的共享状态包括 `_currentDirectory`、`_isAnalyzing`、`_logFiles` 和 `_analysisResults`。目录切换、分析状态检查和修改、文件列表快照、结果查询以及工作线程写回结果等操作主要通过 `lock (_syncRoot)` 互斥。`AnalysisResult` 使用不可变记录表示,构造后不再修改,而是整体替换字典中的结果。需要注意,当前框架的 `CurrentDirectory` 和 `HasDirectory` 属性直接读取 `_currentDirectory`,没有获得 `_syncRoot`;现有 CLI 使用方式不会在切换目录时并发读取它们,但如果要求任意公开接口都支持并发调用,这两个 getter 也应加锁。 + +如果等待条件只使用 `if`,线程从 `Monitor.Wait` 返回后就会继续执行,不会重新检查队列是否仍为空。发生虚假唤醒,或者另一个消费者先取走了刚加入的元素时,当前消费者可能对空队列执行 `Dequeue` 并抛出异常。对应无限容量生产者消费者问题,就是消费者在仓库仍为空时错误地取走不存在的产品,可能使产品计数变为负数。因此必须使用 `while`,每次醒来并重新取得锁后再次检查“队列为空且添加未完成”这一条件。 + +## Q2.2 + +`LogFileAnalyzer.ChangeDirectory` 中以下调用负责扫描给定目录第一层的 `.log` 文件: + +```csharp +Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) +``` + +如果需要递归扫描全部子目录,可以把 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories`。递归后不同子目录可能存在同名文件,因此不能继续只用 `Path.GetFileName` 作为字典键;可以改用相对于根目录的路径或完整路径来区分文件。 + +## Q2.3.b + +本次作业使用了 AI。主要提示词包括: + +> 请阅读我更新的内容,确定第二节要完成的多线程的任务 + +> 请你帮我完善这个文件,并告诉我每个地方为什么这样做。我已经在 visual studio 2026 里打开了这个文件 + +> 请继续完善这个文件中的代码 + +> 现在请你继续阅读 guidance.md 完成 S2.3 和 T2.3,文件已在 visual studio 2026 中打开 + +我既让AI帮我解释代码,又让AI在我无法完善代码的情况下帮我完善代码,并给我解释为什么要这样做。 + +AI 首先解释了代码框架中类、对象、方法和共享变量的作用,随后帮助实现了 `LogFileAnalyzer`、`WorkQueue` 和 `LocalCli` 的代码,并运行 `test-02-multithreading` 以及控制台正常/非法输入会话进行验证。使用方式不仅是查询接口,还包括让 AI 编写部分作业代码、解释同步设计,并根据测试结果修正实现。 + +AI 的第一版 `WorkQueue` 能通过功能测试,但对 `Dequeue` 的赋值触发了 `CS8762` 可空性警告;随后通过与 `[NotNullWhen(true)]` 返回约定一致的空宽容标注消除了警告。`LogFileAnalyzer` 首次运行 T2.2 测试时也因为当时尚未实现 T2.1 的 `WorkQueue.Enqueue` 而全部停止,这不是 T2.2 本身的测试结论,因此完成队列后重新运行了完整测试。最终 `test-02-multithreading` 的 8 项测试全部通过。 + +我认为本节整体难度偏高。单个 `lock`、`Monitor.Wait` 或 `Thread.Join` 接口并不复杂,主要难点是同时维护队列状态和完成状态、使用 `while` 应对虚假唤醒、保证异常时恢复 `_isAnalyzing`,以及区分某个测试失败究竟来自队列还是日志分析器。 diff --git a/docs/03-async-grpc/assets/remote-cli-functional-result.png b/docs/03-async-grpc/assets/remote-cli-functional-result.png new file mode 100644 index 0000000..4106ca3 Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-functional-result.png differ diff --git a/docs/03-async-grpc/assets/remote-cli-robustness-result.png b/docs/03-async-grpc/assets/remote-cli-robustness-result.png new file mode 100644 index 0000000..9c3cc6c Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-robustness-result.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..b954ad4 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,54 @@ +# 03-async-grpc 实现报告 + +## T3.1 Agent + +本节完成了日志分析系统与 Protobuf 类型之间的转换,以及 gRPC Agent 的请求处理逻辑。 + +`GrpcLogEntryVisitor` 使用访问者模式把 `CallLogEntry`、`RequestLogEntry` 和 `InternalLogEntry` 转换为对应的 `LogEntryMessage`。`GrpcTypeConverter` 负责分析状态、日志级别、事件类型和三种日志记录的双向转换。 + +`AgentSession` 实现了目录切换、全部日志分析、指定日志分析和分析结果查询。请求参数或当前状态不合法时,Agent 会返回对应的 `INVALID_ARGUMENT`、`DIRECTORY_NOT_FOUND`、`FILE_NOT_FOUND` 或 `INVALID_OPERATION`,其他异常转换为 `INTERNAL_ERROR`,避免请求异常导致服务退出。分析成功的结果首先返回 header,随后按原顺序返回每条日志记录。 + +`AgentService` 只负责把 gRPC 请求和取消令牌转交给 `AgentSession`。对于分析结果,它使用服务器流逐条写出 `GetAnalysisResultResponse`。 + +在 Release 配置运行 `test-03-async-grpc`,T3.1.1 至 T3.1.3 共 3 项测试全部通过。 + +## T3.2 RemoteCli + +`RemoteCli` 实现了以下功能: + +1. 切换 Agent 使用的日志目录。 +2. 获取当前目录中的日志文件列表。 +3. 按指定并行度分析部分或全部日志文件,其中并行度 `0` 使用逻辑处理器数量。 +4. 查询未分析、分析成功和分析失败三种状态。 +5. 异步读取 gRPC 服务器流,并把每条 Protobuf 日志转换回日志模型后输出。 +6. 处理空目录、无效目录、非法菜单、非法并行度、空文件列表和不存在的日志文件。 + +正常会话覆盖了目录切换、文件列表、未分析状态、指定文件分析、三种日志类型的成功结果、失败结果和全部文件分析。程序最终以退出码 `0` 结束。 + +![RemoteCli 完整功能](./assets/remote-cli-functional-result.png) + +鲁棒性会话连续输入多种非法内容。每次错误后程序都会给出原因并返回菜单或重新提示输入,没有因客户端参数错误退出。 + +![RemoteCli 鲁棒性测试](./assets/remote-cli-robustness-result.png) + +## Q3.1 + +网络应用与以前本地程序最直接的区别是调用跨越了进程边界。调用方不能直接共享 Agent 中的对象,只能使用 `.proto` 约定的消息,因此需要维护内部模型和 Protobuf 模型之间的转换。网络调用还可能遇到服务未启动、地址错误、连接中断和请求取消;这些情况在普通本地方法调用中通常不需要考虑。 + +本节另一个明显区别是调试时必须同时运行 Agent 和客户端。客户端创建 channel 并不表示连接已经成功,第一次 RPC 才真正建立连接,所以程序先调用 `PingAsync`。查询分析结果又不能假定响应可以一次装入内存,而是要先处理 header,再异步读取服务器流中的日志记录。 + +服务端的输入也不能被信任。目录、文件名和并行度都需要检查,分析器当前是否已有目录或正在分析也会影响请求是否合法。Agent 是常驻服务,因此这些错误必须转换为协议状态,而不能让异常终止服务。 + +## Q3.2.b + +本次作业使用了 AI。主要提示词包括: + +> 请你阅读 guidance 中的更新内容,帮助我完成 03-async-grpc 中的任务。 + +> 我在 Visual Studio 2026 中已经打开了这个项目,请你帮助我完成第 3 节全部要求。 + +> 请告诉我为什么要这样做 + +AI 用于阅读任务说明、解释代码框架、补全 T3.1/T3.2 的 TODO、运行测试和组织控制台验证。过程中 AI 一开始为了增强鲁棒性改动了 `RemoteCli` 已有的入口和菜单流程,这不符合工程只修改 TODO 区域的规则。指出问题后,AI 阅读了 `00-prepare/guidance.md`,撤回了 TODO 之外的改动,并通过 `git diff` 重新检查修改范围。 + +通过本次实现,我更清楚地理解了 gRPC 的惰性连接、普通异步 RPC 与服务器流的区别,以及 `oneof` 消息如何承载 header 或日志记录。服务器流客户端方法本身没有 `Async` 后缀,但响应通过 `ReadAllAsync` 异步读取;这与一次返回单个响应的 `ChangeDirectoryAsync` 等调用不同。 diff --git a/docs/04-avalonia/assets/analysis-failed.png b/docs/04-avalonia/assets/analysis-failed.png new file mode 100644 index 0000000..ee3e188 Binary files /dev/null and b/docs/04-avalonia/assets/analysis-failed.png differ diff --git a/docs/04-avalonia/assets/functional-client.png b/docs/04-avalonia/assets/functional-client.png new file mode 100644 index 0000000..f62f321 Binary files /dev/null and b/docs/04-avalonia/assets/functional-client.png differ diff --git a/docs/04-avalonia/assets/result-not-analyzed.png b/docs/04-avalonia/assets/result-not-analyzed.png new file mode 100644 index 0000000..438dd32 Binary files /dev/null and b/docs/04-avalonia/assets/result-not-analyzed.png differ diff --git a/docs/04-avalonia/assets/robustness-empty-address.png b/docs/04-avalonia/assets/robustness-empty-address.png new file mode 100644 index 0000000..1757bb2 Binary files /dev/null and b/docs/04-avalonia/assets/robustness-empty-address.png differ diff --git a/docs/04-avalonia/assets/robustness-invalid-directory.png b/docs/04-avalonia/assets/robustness-invalid-directory.png new file mode 100644 index 0000000..5877cf0 Binary files /dev/null and b/docs/04-avalonia/assets/robustness-invalid-directory.png differ diff --git a/docs/04-avalonia/assets/robustness-invalid-dop.png b/docs/04-avalonia/assets/robustness-invalid-dop.png new file mode 100644 index 0000000..fb4318a Binary files /dev/null and b/docs/04-avalonia/assets/robustness-invalid-dop.png differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..3e144cc --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,66 @@ +# 04-avalonia 实验报告 + +## 功能实现 + +本次作业完成了以下 GUI 客户端功能: + +- 使用 `Refresh` RPC 异步刷新远程日志目录中的文件列表,并清理已经失效的选择状态。 +- 支持多选日志文件后调用 `AnalyzeFiles`,并对空选择进行本地校验。 +- 添加 `All` 按钮,通过 `AnalyzeAll` 分析当前目录内的全部日志文件。 +- 支持通过右键菜单分析单个日志文件,以及通过服务端流式 RPC 获取分析结果。 +- 将日志条目转换为键值字段并按 `序号 | 字段: 值` 的形式显示。 +- 分别显示分析成功、分析失败和尚未分析三种结果状态。 +- 对空地址、非法目录、非法 DoP、未连接、空文件选择和 RPC 异常使用消息框提示,避免非法输入导致程序退出。 + +所有 gRPC 调用均使用异步接口并进行 `await`。连接新 Agent 时,只有候选客户端成功完成 `PingAsync` 后才会替换当前客户端;如果切换失败,已有可用连接仍会保留。 + +## 功能截图 + +下图展示了连接 Agent、切换日志目录、刷新文件列表、使用 `All` 分析以及查看成功分析结果后的完整界面。 + +![完整功能界面](./assets/functional-client.png) + +尚未分析的文件会显示明确的状态信息: + +![尚未分析](./assets/result-not-analyzed.png) + +日志解析失败时会显示解析器返回的错误原因: + +![分析失败](./assets/analysis-failed.png) + +## 鲁棒性测试 + +连接地址为空时,客户端在创建 gRPC Client 前进行拦截: + +![空连接地址](./assets/robustness-empty-address.png) + +远程目录不存在时,客户端显示 Agent 返回的 `DirectoryNotFound` 状态,文件列表和程序进程保持可用: + +![非法目录](./assets/robustness-invalid-directory.png) + +DoP 不是非负整数时,客户端进行本地校验,不发送分析请求: + +![非法 DoP](./assets/robustness-invalid-dop.png) + +## 构建与测试 + +- `LogAnalyzerClient.Desktop` 在 Release 配置下构建成功,0 个错误;完整构建会产生 5 个来自 gRPC 自动生成文件中 `pb/pbc/pbr/grpc` 类型名称的既有 `CS8981` 警告,本次修改的客户端代码没有编译警告。 +- `test-00-prepare`:1/1 通过。 +- `test-01-basic`:16/16 通过。 +- `test-02-multithreading`:8/8 通过。 +- `test-03-async-grpc`:3/3 通过。 +- 使用本地 Agent `http://127.0.0.1:5000` 和 `src/dataset` 完成了连接、目录切换、刷新、Analyze All、单文件分析及流式结果显示的实际操作验证。 + +## Q4.1 + +控制台程序通常按照“读取输入、执行、输出结果”的顺序运行,程序在某一步等待时是容易理解的。GUI 程序则由事件驱动,连接、按钮点击、列表选择和右键菜单可能以不同顺序发生,因此需要维护连接状态、当前目录、选择项和结果列表之间的一致性。界面还增加了数据绑定、命令生成、控件选择状态和消息框生命周期等问题,同一个错误不能只写到标准输出,而要转化为用户能看到并能恢复操作的界面状态。 + +这次作业让我更直观地理解了 `async`/`await` 的作用。gRPC 请求如果同步等待,会占用唯一的 UI 线程,窗口不能重绘也不能响应输入;`await` 会在等待网络结果时把 UI 线程让出来,并在请求完成后继续更新绑定属性。异步也带来了额外复杂度,例如请求失败后的状态恢复、多个命令可能交叉执行,以及异常需要在 `await` 的调用链上统一处理。本次实现通过异步命令、统一的客户端非空检查和消息框异常处理来控制这些问题。 + +## Q4.2.b + +本次作业使用了 AI。主要提示词是:“仔细阅读 `04-avalonia` 和 `00-prepare` 的指导页面,了解写作业和提交作业的要求后,完成 `04-avalonia` 任务并直接完善项目。” + +AI 参与了指导文档梳理、代码框架分析、ViewModel 和结果模型实现、Release 构建、测试运行以及 GUI 自动化验证。它不只是查询接口,也编写了本次作业的一部分代码。过程中出现过可验证的错误:最初在属性访问器中使用了变量名 `field`,它在 C# 14 中成为上下文关键字,Release 构建失败后改名并重新构建通过;自动化截图脚本也出现过 PowerShell 变量名和窗口焦点问题,但这些脚本不属于提交代码,修正后才生成报告截图。 + +通过这次过程,我进一步理解了 gRPC 服务端流需要使用 `await foreach` 异步读取,以及 UI 状态只能在请求成功并完成校验后更新。AI 给出的实现仍然需要依靠编译、现有测试和真实界面操作来判断是否正确,不能只根据生成的代码表面判断。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..3934102 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -27,6 +27,13 @@ public static void Main(string[] args) { return null; } + + if (string.IsNullOrWhiteSpace(directory)) + { + Console.WriteLine("Directory cannot be empty, please try again:"); + continue; + } + try { if (!analyzer.ChangeDirectory(directory)) @@ -36,9 +43,12 @@ public static void Main(string[] args) } break; } - catch (ArgumentException) + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or UnauthorizedAccessException) { - Console.WriteLine("Directory illegal, please try again:"); + Console.WriteLine($"Directory illegal: {ex.Message}, please try again:"); continue; } } @@ -112,22 +122,134 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var fileNames = analyzer.GetLogFiles(); + Console.WriteLine($"[{string.Join(", ", fileNames)}]"); + } + + private static int? ReadDegreeOfParallelism() + { + Console.WriteLine("Please input degree of parallelism (0 uses the logical processor count):"); + var input = Console.ReadLine(); + if (input is null) + { + return null; + } + + if (!int.TryParse(input.Trim(), out var degreeOfParallelism) + || degreeOfParallelism < 0) + { + Console.WriteLine("Degree of parallelism must be a non-negative integer."); + return null; + } + + return degreeOfParallelism; + } + + private static List? ReadFileNames() + { + Console.WriteLine("Please input log file names (comma separated):"); + var input = Console.ReadLine(); + if (input is null) + { + return null; + } + + var fileNames = input + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct() + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("At least one log file name is required."); + return null; + } + + return fileNames; } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + if (degreeOfParallelism is null) + { + return; + } + + var fileNames = ReadFileNames(); + if (fileNames is null) + { + return; + } + + try + { + analyzer.AnalyzeFiles(degreeOfParallelism.Value, fileNames); + Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]"); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + Console.WriteLine($"Unable to analyze files: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + if (degreeOfParallelism is null) + { + return; + } + + try + { + analyzer.AnalyzeAll(degreeOfParallelism.Value); + Console.WriteLine($"Analysis completed: [{string.Join(", ", analyzer.GetLogFiles())}]"); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + Console.WriteLine($"Unable to analyze files: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input log file name:"); + var fileName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(fileName)) + { + Console.WriteLine("Log file name cannot be empty."); + return; + } + + fileName = fileName.Trim(); + if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + Console.WriteLine($"File {fileName} does not exist in the current directory."); + return; + } + + switch (result.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File {fileName} has not been analyzed yet."); + break; + case AnalysisState.Succeeded: + Console.WriteLine($"Analysis result for {fileName}:"); + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var keyValues = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", + keyValues.Select(keyValue => $"{keyValue.Key}: {keyValue.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis failed for {fileName}: {result.ErrorMessage}"); + break; + default: + Console.WriteLine($"File {fileName} has an unknown analysis state."); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..36747da 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -1,7 +1,5 @@ using LogParser.Models; using LogParser.Parser; -using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography.X509Certificates; namespace LogAnalyzer { @@ -137,11 +135,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 +144,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 +158,15 @@ 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($"Unknown log file: {file.Name}."); + } + + if (result.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -179,11 +176,11 @@ 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 +188,18 @@ 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)) + { + IsBackground = true, + Name = threadName + }; + 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 +211,33 @@ 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..40799db 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,45 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue items after adding has been 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..e27f660 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -79,22 +79,291 @@ public Task GetLogFiles(Empty empty, CancellationToken canc public Task ChangeDirectory(ChangeDirectoryRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new ChangeDirectoryResponse(); + try + { + if (request is null || string.IsNullOrWhiteSpace(request.DirectoryPath)) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Directory path cannot be empty."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Cannot change directory while analysis is in progress."); + return Task.FromResult(response); + } + + var directoryPath = request.DirectoryPath.Trim(); + if (!Directory.Exists(directoryPath)) + { + response.Status = Error( + AgentErrorCode.DirectoryNotFound, + $"Directory '{directoryPath}' does not exist."); + return Task.FromResult(response); + } + + if (!_analyzer.ChangeDirectory(directoryPath)) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Cannot change directory while analysis is in progress."); + return Task.FromResult(response); + } + + response.CurrentDirectory = _analyzer.CurrentDirectory ?? ""; + response.FileNames.AddRange(_analyzer.GetLogFiles()); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _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"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new AnalyzeAllResponse(); + try + { + if (request is null || request.DegreeOfParallelism < 0) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be a non-negative integer."); + return Task.FromResult(response); + } + + if (!_analyzer.HasDirectory) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before analysis."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Analysis is already in progress."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentOutOfRangeException ex) + { + response.Status = Error(AgentErrorCode.InvalidArgument, ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = Error(AgentErrorCode.InvalidOperation, ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _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"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new AnalyzeFilesResponse(); + try + { + if (request is null || request.DegreeOfParallelism < 0) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be a non-negative integer."); + return Task.FromResult(response); + } + + if (!_analyzer.HasDirectory) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before analysis."); + return Task.FromResult(response); + } + + var fileNames = request.FileNames + .Select(fileName => fileName?.Trim() ?? "") + .Where(fileName => fileName.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (fileNames.Count == 0 || fileNames.Count != request.FileNames.Count) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "At least one non-empty log file name is required."); + return Task.FromResult(response); + } + + var availableFiles = _analyzer.GetLogFiles().ToHashSet(StringComparer.Ordinal); + var missingFile = fileNames.FirstOrDefault(fileName => !availableFiles.Contains(fileName)); + if (missingFile is not null) + { + response.Status = Error( + AgentErrorCode.FileNotFound, + $"File '{missingFile}' does not exist in the current directory."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Analysis is already in progress."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, fileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentOutOfRangeException ex) + { + response.Status = Error(AgentErrorCode.InvalidArgument, ex.Message); + } + catch (ArgumentException ex) + { + response.Status = Error(AgentErrorCode.FileNotFound, ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = Error(AgentErrorCode.InvalidOperation, ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while analyzing selected log files."); + } + return Task.FromResult(response); } public IReadOnlyList GetAnalysisResult(GetAnalysisResultRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + try + { + if (request is null || string.IsNullOrWhiteSpace(request.FileName)) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.InvalidArgument, + "Log file name cannot be empty.") + } + }; + } + + if (!_analyzer.HasDirectory) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before retrieving analysis results.") + } + }; + } + + var fileName = request.FileName.Trim(); + if (!_analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.FileNotFound, + $"File '{fileName}' does not exist in the current directory.") + } + }; + } + + var header = new AnalysisResultHeaderMessage + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + WorkerId = result.WorkerId, + }; + if (result.ErrorMessage is not null) + { + header.ErrorMessage = result.ErrorMessage; + } + + var responses = new List + { + new() + { + Status = CreateNoErrorOperationStatus(), + Header = header, + } + }; + if (result.State == AnalysisState.Succeeded) + { + responses.AddRange(result.Entries.Select(entry => new GetAnalysisResultResponse + { + Status = CreateNoErrorOperationStatus(), + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + })); + } + + return responses; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + return new[] + { + new GetAnalysisResultResponse + { + Status = CreateInternalErrorOperationStatus(ex) + } + }; + } } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..d38d1cf 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,31 @@ 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); + } } } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..ab7dfda 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -11,7 +11,20 @@ public sealed record LogFileItem(string FileName) public sealed record LogFields(int Index, IReadOnlyList Fields, string? ErrorMessage) { - public string Summary => "TODO: T4.1"; + public string Summary + { + get + { + if (ErrorMessage is not null) + { + return ErrorMessage; + } + + var fields = string.Join(", ", + Fields.Select(item => $"{item.Key}: {item.Value}")); + return Index < 0 ? fields : $"{Index} | {fields}"; + } + } } public sealed record LogFieldItem(string Key, string Value); diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs index 91c05a8..4b96b46 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -73,17 +73,24 @@ private async Task ConnectAsync() try { ConnectStatus = ConnectStatusString.CONNECTING; - _client = AppService.ClientFactory.CreateClient(address); - await _client.PingAsync(new Empty()); - CurrentAddress = address; + var normalizedAddress = address.Trim(); + var client = AppService.ClientFactory.CreateClient(normalizedAddress); + await client.PingAsync(new Empty()); + _client = client; + CurrentAddress = normalizedAddress; ConnectStatus = ConnectStatusString.CONNECTED; LogFiles.Clear(); + SelectedFiles = Array.Empty(); + SelectedLogFile = null; + ResultEntries.Clear(); } catch (Exception ex) { ConnectStatus = ConnectStatusString.CONNECT_FAILED; await DialogHelper.ShowMessageDialogAsync("Error", $"Failed to connect to agent: {ex.Message}"); - ConnectStatus = ConnectStatusString.NOT_CONNECTED; + ConnectStatus = _client is null + ? ConnectStatusString.NOT_CONNECTED + : ConnectStatusString.CONNECTED; } } } @@ -108,6 +115,62 @@ await DialogHelper.ShowMessageDialogAsync("Error", } } + private async Task GetDegreeOfParallelismAsync() + { + if (!int.TryParse(DegreeOfParallelismText?.Trim(), out var degreeOfParallelism) + || degreeOfParallelism < 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Degree of parallelism must be a non-negative integer."); + return null; + } + + return degreeOfParallelism; + } + + private async Task ShowFailedOperationAsync(OperationStatusMessage status) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{status.Code}: {status.Message}"); + } + + private async Task AnalyzeFilesAsync(IEnumerable fileNames) + { + var degreeOfParallelism = await GetDegreeOfParallelismAsync(); + if (degreeOfParallelism is null) + { + return; + } + + var names = fileNames + .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) + .Select(fileName => fileName.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (names.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Please select at least one log file."); + return; + } + + var request = new AnalyzeFilesRequest + { + DegreeOfParallelism = degreeOfParallelism.Value, + }; + request.FileNames.AddRange(names); + + var response = await _client!.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + await ShowFailedOperationAsync(response.Status); + return; + } + + await DialogHelper.ShowMessageDialogAsync("Analysis completed", + $"Analyzed files: {string.Join(", ", names)}"); + } + [RelayCommand] private async Task ChangeDirectoryAsync() { @@ -120,9 +183,12 @@ await WithClientNotNull(async() => var response = await _client!.ChangeDirectoryAsync(request); if (!response.Status.Success) { - await DialogHelper.ShowMessageDialogAsync("Error", - $"{response.Status.Code}: {response.Status.Message}"); + await ShowFailedOperationAsync(response.Status); + return; } + + DirectoryPath = response.CurrentDirectory; + ResultEntries.Clear(); await RefreshAsync(); }); } @@ -132,31 +198,138 @@ private async Task RefreshAsync() { await WithClientNotNull(async () => { - throw new NotImplementedException("TODO: T4.1"); + var response = await _client!.GetLogFilesAsync(new Empty()); + if (!response.Status.Success) + { + await ShowFailedOperationAsync(response.Status); + return; + } + + LogFiles = new ObservableCollection( + response.FileNames.Select(fileName => new LogFileItem(fileName))); + SelectedFiles = Array.Empty(); + SelectedLogFile = null; }); } [RelayCommand] private async Task AnalyzeSelectedFilesAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(() => AnalyzeFilesAsync(SelectedFiles)); } - /* - * TODO: T4.1 - * Add AnalyzeAllAsync ReplayCommand - */ + [RelayCommand] + private async Task AnalyzeAllAsync() + { + await WithClientNotNull(async () => + { + var degreeOfParallelism = await GetDegreeOfParallelismAsync(); + if (degreeOfParallelism is null) + { + return; + } + + var response = await _client!.AnalyzeAllAsync(new AnalyzeAllRequest + { + DegreeOfParallelism = degreeOfParallelism.Value, + }); + if (!response.Status.Success) + { + await ShowFailedOperationAsync(response.Status); + return; + } + + await DialogHelper.ShowMessageDialogAsync("Analysis completed", + "All log files have been analyzed."); + }); + } [RelayCommand] private async Task AnalyzeRightClickedFileAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Please select a log file."); + return; + } + + await AnalyzeFilesAsync(new[] { SelectedLogFile.FileName }); + }); } [RelayCommand] private async Task GetAnalysisResultAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Please select a log file."); + return; + } + + ResultEntries.Clear(); + using var call = _client!.GetAnalysisResult(new GetAnalysisResultRequest + { + FileName = SelectedLogFile.FileName, + }); + var entries = new List(); + var visitor = new KeyValueVisitor(); + var index = 0; + + await foreach (var response in call.ResponseStream.ReadAllAsync()) + { + if (!response.Status.Success) + { + ResultEntries.Clear(); + await ShowFailedOperationAsync(response.Status); + return; + } + + switch (response.PayloadCase) + { + case GetAnalysisResultResponse.PayloadOneofCase.Header: + var header = response.Header; + switch (header.State) + { + case AnalysisStateEnum.Succeeded: + entries.Add(new LogFields(-1, + [ + new LogFieldItem("File", header.FileName), + new LogFieldItem("Worker ID", header.WorkerId.ToString()), + ], null)); + break; + case AnalysisStateEnum.Failed: + entries.Add(new LogFields(-1, Array.Empty(), + $"Analysis failed: {header.ErrorMessage}")); + break; + case AnalysisStateEnum.NotAnalyzed: + entries.Add(new LogFields(-1, Array.Empty(), + $"File {header.FileName} has not been analyzed yet.")); + break; + default: + throw new ClientInternalException( + $"Unknown analysis state: {header.State}"); + } + break; + case GetAnalysisResultResponse.PayloadOneofCase.LogEntry: + var fields = visitor + .Dump(GrpcTypeConverter.ConvertFromGrpc(response.LogEntry)) + .Select(field => new LogFieldItem(field.Key, field.Value)) + .ToList(); + entries.Add(new LogFields(index++, fields, null)); + break; + default: + throw new ClientInternalException("The Agent returned an empty result payload."); + } + } + + ResultEntries = new ObservableCollection(entries); + }); } [RelayCommand] diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..935ac14 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml @@ -79,7 +79,7 @@ to set the actual DataContext for runtime, set the DataContext property in code - +