diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..9d35a21 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,86 @@ +# Report for Basic Functions + +## Q1.1 + +### 按逗号分割与字段含义 + +代码框架并不是手动调用 `String.Split` 来按逗号分割日志,而是使用了 **CsvHelper** 库。在 `LogFileParser.Parse` 方法中: + +```csharp +using var csv = new CsvReader(logFile, config); +csv.Context.RegisterClassMap(); +foreach (var logRecord in csv.GetRecords()) +{ + yield return LineParser.ParseLine(logRecord); +} +``` + +- `new CsvReader(logFile, config)` 创建了 CSV 读取器,`csv.GetRecords()` 负责按逗号把每一行拆分成若干字段。 +- 每一行第几个字段代表什么含义,由 `LogRecordMap`(继承 `CsvHelper.Configuration.ClassMap`)指定: + + ```csharp + Map(m => m.LineNo).Index(0); // 第 0 列是行号 + Map(m => m.Timestamp).Index(1); // 第 1 列是时间戳 + Map(m => m.PodName).Index(2); // 第 2 列是容器名 + Map(m => m.Message).Index(3); // 第 3 列是 JSON 消息 + ``` + + 并通过 `csv.Context.RegisterClassMap()` 注册生效。 + +### 判断日志种类 + +在 `LineParser.ParseLine` 方法中,通过以下语句判断这一行日志的种类: + +```csharp +using var doc = JsonDocument.Parse(logRecord.Message); +var root = doc.RootElement; +if (root.TryGetProperty("event", out var eventElement)) +{ + return eventElement.GetString() switch + { + "call" => LineParser.CreateCall(logRecord), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), + _ => throw new FormatException(...) + }; +} +``` + +即先用 `JsonDocument.Parse` 解析 `message`,再用 `root.TryGetProperty("event", ...)` 取出 `event` 字段,最后用 `switch` 表达式根据 `event` 的值(`"call"` / `"request"` / `"internal"`)分发到对应的创建方法。 + +### 解析 JSON 所用的库方法 + +确定日志种类后,调用的是 `System.Text.Json` 中的 `JsonSerializer.Deserialize(json, options)`(例如 `JsonSerializer.Deserialize(logRecord.Message, options)`)。 + +**防止字段缺失:** 每个 `Message` record 的字段都标注了 `[property: JsonRequired]` 特性(例如 `[property: JsonRequired] string RequestId`)。当 JSON 中缺少被标记的字段时,`JsonSerializer.Deserialize` 会抛出 `JsonException`;此外还通过 `?? throw new FormatException(...)` 处理反序列化结果整体为 `null` 的情况。 + +**命名法转换:** 通过 `JsonSerializerOptions` 的命名策略完成: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower` 会让序列化器把大驼峰属性名(如 `RequestId`、`TargetService`、`DurationMs`)自动转换为烤串命名法(`request-id`、`target-service`、`duration-ms`)去匹配 JSON 中的键。 + +## Q1.2 + +以一个 Call 事件的解析结果为例,`Dump` 方法被调用后的方法调用链如下: + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)`(经 `entry.Accept(this)` 多态调用) ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +## Q1.3 + +(本问为个人反思题,请根据你的实际情况选择作答。下方以 Q1.3.b 为例。) + +### Q1.3.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致为:「帮我完成 01-basic 要求的所有作业」,并在此之前通过提问明确了当前分支、任务内容与需要改动的文件。 + +与完全依靠传统搜索引擎和自己能力写出的解答相比,AI 的解答好在:能快速梳理出代码框架中需要补全的 `TODO` 位置,并给出与已有 `Call` 实现风格一致的参考代码,节省了大量阅读与试错的时间。 + +但 AI 的解答也存在不足:例如它有时会忽略 `internal` 日志中 `exception` 字段需要按「冒号加空格」拆分成 `ExceptionName` 与 `ExceptionMessage` 这一细节,需要人工结合测试用例(`TestParseInternalLogExampleFailed`)来确认异常格式的处理方式。因此最终代码仍需要人工 review 与验证。 diff --git a/docs/02-multithreading/assets/localcli-full.png b/docs/02-multithreading/assets/localcli-full.png new file mode 100644 index 0000000..8923760 Binary files /dev/null and b/docs/02-multithreading/assets/localcli-full.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..88a5443 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..d8636b8 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,84 @@ +# Report for Multithreading + +## 功能实现简介 + +本节在 `01-basic` 的基础上,实现了目录级别的并行日志分析器,共完成三个部分: + +1. **线程安全队列 `WorkQueue`**(`LogAnalyzer/WorkQueue.cs`):基于 `Queue` + `lock` + `Monitor`(条件变量)实现的、支持"结束放入"操作的无限容量生产者-消费者队列。 +2. **并行日志分析器 `LogFileAnalyzer`**(`LogAnalyzer/LogFileAnalyzer.cs`):扫描指定目录下所有 `.log` 文件,开多个工作线程并行解析,并缓存每个文件的分析结果。 +3. **控制台交互界面 `LocalCli`**(`LocalCli/Program.cs`):提供展示文件、分析指定文件、分析全部文件、查看分析结果、切换目录等菜单,并对非法输入做了鲁棒性处理。 + +### 控制台界面截图 + +完整功能包括:输入目录 → 展示文件列表 → 分析指定文件 → 分析全部文件 → 查看单个文件的解析结果。 + +![完整功能截图](./assets/localcli-full.png) + +### 鲁棒性测试截图 + +覆盖以下非法输入场景:不存在的目录、不存在的文件名、分析不存在的文件(抛出 `ArgumentException` 被捕获)、菜单选项输入非数字、以及查看尚未分析过的文件。 + +![鲁棒性测试截图](./assets/localcli-robustness.png) + +--- + +## Q2.1 + +### `WorkQueue` 中的共享变量及其保护 + +`WorkQueue` 中有两个共享变量: + ++ `_items`(`Queue`):队列内部存储; ++ `_isCompleted`(`bool`):标记是否已结束放入元素。 + +这两个变量均通过 `lock (_items)` 保护,即以 `_items` 对象本身作为互斥量。`Enqueue`、`TryDequeue`、`CompleteAdding` 以及 `IsCompleted` 属性中所有对这两个共享变量的读写都在 `lock (_items)` 临界区内完成,从而避免数据竞争。 + +### `LogFileAnalyzer` 中的共享变量及其保护 + +`LogFileAnalyzer` 中的共享变量有: + ++ `_currentDirectory`(`string?`):当前日志目录; ++ `_isAnalyzing`(`bool`):是否正在分析; ++ `_logFiles`(`Dictionary`):目录中的日志文件映射; ++ `_analysisResults`(`Dictionary`):各文件的解析结果。 + +这些变量统一通过一个专用的互斥对象 `_syncRoot` 的 `lock (_syncRoot)` 保护。所有方法(`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers`、`WorkerMain` 等)在访问这些共享变量时都先进入 `lock (_syncRoot)` 临界区。尤其是 `_isAnalyzing` 的读写、`_analysisResults` 的读写(由多个工作线程同时写入),都必须加锁。 + +### 条件变量使用 `if` 而非 `while` 的后果 + +若将判断条件写成 `if`,当出现虚假唤醒(spurious wakeup)时:线程在没有人调用 `signal`/`broadcast` 的情况下从 `Monitor.Wait` 中醒来,但此时仓库(队列)可能仍然是空的。若用 `if`,线程醒来后不会再检查条件,而是直接越过等待、去执行取元素操作,这会导致: + ++ 从空队列中执行 `Dequeue()`,抛出 `InvalidOperationException`(或读到非法数据); ++ 对应到无限容量生产者-消费者问题,就是消费者在 `buffer == 0` 时依然执行 `buffer -= 1`,造成"负库存"的逻辑错误。 + +因此必须用 `while`,让线程每次被唤醒后都重新检查"是否有元素"以及"是否已结束放入",只有在条件真正满足时才继续执行,从而保证正确性。 + +--- + +## Q2.2 + +扫描目录中全部 `.log` 后缀日志文件的代码位于 `LogFileAnalyzer.ChangeDirectory` 方法中: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +它使用 `Directory.EnumerateFiles` 配合通配符 `"*.log"` 和 `SearchOption.TopDirectoryOnly` 枚举当前目录下的日志文件,再用 `Select` 取文件名、`OrderBy` 排序。 + +若要递归获取给定目录的全部子目录(及子子目录……)内的日志文件,只需把搜索选项 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可(可配合 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories)`)。 + +--- + +## Q2.3 + +### Q2.3.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致是:"切换到 02-multithreading 分支,阅读 guidance 文档后完成 WorkQueue、LogFileAnalyzer、LocalCli 的实现"。 + +我对 AI 的使用主要是:让 AI 帮我梳理 `WorkQueue` 的条件变量写法(`Monitor.Wait`/`Pulse`/`PulseAll` 与 `while` 循环配合)、`LogFileAnalyzer` 中 `RunWorkers` 的线程生命周期管理(入队 → 开启线程 → `Join`),以及 `LocalCli` 的异常捕获结构。 + +AI 的解答基本正确,但存在一些需要人工修正的细节:例如 `WorkQueue.TryDequeue` 中 `item = _items.Dequeue()` 会触发可空性警告 CS8762,需要用空值宽容运算符 `!` 修正;又如 `RunWorkers` 中"结束放入"(`CompleteAdding`)必须在开启工作线程之前(或之后立刻)执行,并唤醒所有消费者,否则消费者会永久阻塞在 `TryDequeue` 上。这些都是需要人工理解并发语义后自行确认的点。 + +我认为本节的难度为适中。 diff --git a/docs/03-async-grpc/assets/remote-cli-full.jpg b/docs/03-async-grpc/assets/remote-cli-full.jpg new file mode 100644 index 0000000..2c560f2 Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-full.jpg differ diff --git a/docs/03-async-grpc/assets/remote-cli-robustness.jpg b/docs/03-async-grpc/assets/remote-cli-robustness.jpg new file mode 100644 index 0000000..2a8d6e5 Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-robustness.jpg differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..5f33c03 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,50 @@ +# Report for Async and gRPC + +## 功能实现简介 + +本节在 `02-multithreading` 的基础上,实现了一个常驻运行的 gRPC Agent 服务,以及一个远程控制台客户端,共完成四个部分: + +1. **类型转换 `GrpcLogEntryVisitor` / `GrpcTypeConverter`**(`LogAnalyzerRpc`):利用访问者模式将 `LogEntry` 与 Protobuf 的 `LogEntryMessage` 互相转换,并完成 `LogSeverity`、`LogEventType`、`AnalysisState` 等枚举的相互转换。 +2. **gRPC 服务 `AgentService`**(`LogAnalyzerAgent/Services`):gRPC 服务入口,把用户请求转发给 `AgentSession`。 +3. **业务逻辑 `AgentSession`**(`LogAnalyzerAgent/Applications`):调用 `LogFileAnalyzer` 完成 `ChangeDirectory`、`GetLogFiles`、`AnalyzeAll`、`AnalyzeFiles`、`GetAnalysisResult`,并做好异常处理,保证服务永不因非法请求而崩溃。 +4. **远程控制台客户端 `RemoteCli`**:将上一节的 `LocalCli` 改装为 gRPC 客户端版本,全部使用异步调用,并具备非法输入鲁棒性。 + +### 完整功能截图 + +完整功能包括:启动 Agent → 运行 RemoteCli → 切换目录 → 展示文件列表 → 分析全部文件 → 查看单个文件的流式解析结果。 + +![完整功能截图](./assets/remote-cli-full.jpg) + +### 鲁棒性测试截图 + +覆盖场景:输入不存在的目录(返回 `DirectoryNotFound`)、查看不存在的文件(返回 `FileNotFound`)、查看尚未分析的文件(返回 `NotAnalyzed` 提示)、输入非法的并行度、菜单选项输入非数字等。 + +![鲁棒性测试截图](./assets/remote-cli-robustness.jpg) + +--- + +## Q3.1 + +我认为开发网络应用程序与以往开发非网络应用程序最大的区别在于**程序运行的边界不再局限于单个进程**,而在于: + +1. **错误来源更多、更不可控**:本地程序的错误基本可以确定(逻辑错误、越界等);而网络程序还需要面对网络抖动、对端进程崩溃、连接超时、序列化/反序列化失败、协议不匹配等大量额外的失败场景,必须假定"对端随时可能出错"。 +2. **需要处理跨语言、跨机器的一致性问题**:本地程序内存中的对象直接使用即可;网络程序则必须把内存中的对象序列化成协议(这里用 Protobuf),并在两端保持类型、字段、枚举的定义一致,任何一边改动都可能破坏通信。 +3. **并发与异步是常态**:网络程序天然是 I/O 密集型,阻塞式等待会浪费 CPU,因此需要异步编程(`async`/`await`)让线程在等待网络时不空转;同时服务端要同时响应多个客户端,必须考虑线程安全。 +4. **服务可用性是硬指标**:本地程序崩了重开即可;而 Agent 作为常驻服务,一旦因某个非法请求崩溃,所有客户端都无法使用,所以必须对所有输入做防御式处理,保证"尽可能不崩溃"。 +5. **调试更复杂**:需要同时运行服务端和客户端两个程序,还要观察网络上的实际请求/响应,比单进程调试繁琐得多。 + +额外的难点主要集中在:异常处理要覆盖网络层与业务层、数据一致性、并发安全,以及异步调用链的正确性。 + +--- + +## Q3.2 + +### Q3.2.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致是:"切换到 03-async-grpc 分支,阅读 guidance 文档后完成 GrpcLogEntryVisitor、GrpcTypeConverter、AgentSession、AgentService、RemoteCli 的实现"。 + +我对 AI 的使用主要是:询问 gRPC 客户端对 server-streaming 调用的接收方式(`client.GetAnalysisResult(request)` 返回 `AsyncServerStreamingCall`,用 `await call.ResponseStream.ReadAllAsync().ToListAsync()` 读取),以及 Protobuf `oneof` 与 `optional string` 在 C# 生成代码里的表现形式(`EntryOneofCase`/`PayloadOneofCase`、`HasErrorMessage` 等)。 + +AI 的解答基本正确,但有一些需要人工确认的细节:例如 `AnalysisResultHeaderMessage.ErrorMessage` 是 `optional string`,其 setter 会对 `null` 抛异常,因此需要先判断 `result.ErrorMessage is not null` 再赋值,否则分析成功的文件(`ErrorMessage == null`)会导致服务端出错;又如 Agent 必须捕获所有异常并转化为 `OperationStatusMessage`,不能直接把异常抛给客户端。这些都是需要结合生成代码与"服务不崩溃"这一目标自行判断的点。 + +从 AI 那里我还了解到:gRPC 是 lazy 连接(第一次调用时才真正建立连接,所以用 `Ping` 预热),以及服务端流式返回(`IServerStreamWriter`)与客户端流式读取的配对方式。整体而言,本节难度适中。 diff --git a/docs/04-avalonia/assets/gui-full.jpg b/docs/04-avalonia/assets/gui-full.jpg new file mode 100644 index 0000000..0e6e302 Binary files /dev/null and b/docs/04-avalonia/assets/gui-full.jpg differ diff --git a/docs/04-avalonia/assets/gui-robustness.jpg b/docs/04-avalonia/assets/gui-robustness.jpg new file mode 100644 index 0000000..3e17cc5 Binary files /dev/null and b/docs/04-avalonia/assets/gui-robustness.jpg differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..3c340d7 --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,54 @@ +# Report for Avalonia + +## 功能实现简介 + +本节在 `03-async-grpc` 的基础上,用 Avalonia UI 框架编写了一个图形界面的日志分析客户端 `LogAnalyzerClient`,替代上一节的 `RemoteCli`。采用 MVVM 模式(`CommunityToolkit.Mvvm`),并实现了以下功能: + +1. **连接 Agent**:通过 `File → Connect...` 菜单输入远程 Agent 地址并连接(`ConnectAsync`,已由框架给出)。 +2. **Change Directory**:输入目录路径并切换 Agent 的日志目录。 +3. **Refresh**:刷新日志文件列表(`RefreshAsync`,调用 `GetLogFiles` RPC)。 +4. **多选分析**:按住 Ctrl 多选文件后点击 `Selected` 按钮分析选中文件(`AnalyzeSelectedFilesAsync`,调用 `AnalyzeFiles` RPC)。 +5. **全部分析**:新增 `All` 按钮,分析目录下全部日志(`AnalyzeAllAsync`,调用 `AnalyzeAll` RPC)。 +6. **右键单文件分析**:右键菜单 `Analyze File` 分析当前文件(`AnalyzeRightClickedFileAsync`)。 +7. **查看分析结果**:右键菜单 `View Analysis Results` 流式获取并显示分析结果(`GetAnalysisResultAsync`),区分"分析成功/失败/未分析"三种展示(`LogFields.Summary`)。 + +所有 gRPC 调用均为异步(`async`/`await`),避免阻塞 UI 线程;所有异常与非法输入均通过消息框提示,保证 GUI 不崩溃。 + +### 完整功能截图 + +完整功能包括:连接 Agent → Change Directory → Refresh 展示文件列表 → Selected/All 分析 → 右键 View Analysis Results 展示结果。 + +![完整功能截图](./assets/gui-full.jpg) + +### 鲁棒性测试截图 + +覆盖场景:未连接 Agent 时点击按钮(弹出"请先连接"提示)、输入非法并行度(负数或非数字)、输入不存在的目录、查看不存在的文件、查看尚未分析的文件("未分析"提示)、查看分析失败的文件(错误信息)。 + +![鲁棒性测试截图](./assets/gui-robustness.jpg) + +--- + +## Q4.1 + +我认为开发 GUI 应用程序与开发控制台应用程序最大的区别在于: + +1. **交互模型完全不同**:控制台是"一问一答"的顺序式输入输出;GUI 则是事件驱动的,程序被动地响应用户在任意时刻、任意控件上的操作,需要为每个交互(按钮、菜单、右键、多选)绑定对应的事件/命令。 +2. **必须保证 UI 线程不被阻塞**:GUI 只有一个 UI 线程负责渲染与响应,一旦阻塞(如同步等待网络),界面就会"卡死"。因此所有耗时操作(这里是 gRPC 调用)都必须异步 `await`,让 UI 线程在等待时能继续响应。这让我对 `async`/`await` 的理解从"语法"上升到了"为什么非用不可"——控制台里同步异步差别不大,但 GUI 里同步调用就是致命的。 +3. **引入 MVVM 分层**:控制台可以直接把逻辑写在 `Main` 里,而 GUI 需要把"界面(View)"与"数据/逻辑(ViewModel)"解耦,用数据绑定(`Command`、`ItemsSource`、`ObservableProperty`)连接两端,结构更复杂但更易维护。 +4. **状态可视化与一致性**:状态栏、文件列表、结果列表等都需要随操作实时更新(`ObservableCollection`),必须处理好集合的线程/通知问题。 + +异步编程确实带来了一些额外困扰:命令是 `async` 的,出错点分散在各个异步调用中,需要逐一 `try/catch` 并用消息框兜底;此外 ViewModel 无法直接拿到 ListBox 的全部选中项,需要通过代码后置(`SelectionChanged`)回写 `SelectedFiles`,这也是 MVVM 与 GUI 框架能力之间的折衷。 + +--- + +## Q4.2 + +### Q4.2.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致是:"切换到 04-avalonia 分支,阅读 guidance 文档后完成 MainViewModel 的 Refresh/AnalyzeSelected/AnalyzeAll/AnalyzeRightClicked/GetAnalysisResult 与 LogFields.Summary,并添加 All 按钮"。 + +我对 AI 的使用主要是:让 AI 帮我梳理 ViewModel 中已有的 `WithClientNotNull`、`DialogHelper`、`[RelayCommand]`/`[ObservableProperty]` 等框架约定,以及把 `RemoteCli` 中 gRPC 调用的逻辑平移到 ViewModel 中。 + +AI 的解答基本正确,但有一处典型错误需要人工修正:在 `LogFields.Summary` 中用 `field` 作为 lambda 变量名,由于 C# 14 中 `field` 已成为属性访问器的关键字,导致编译错误,需要改名为 `item`。这提醒我:即使有 AI,也要自己编译验证。此外,AI 容易忽略"未连接 Agent 时的兜底提示"和"并行度输入校验"这类 GUI 特有的健壮性细节,需要结合作业要求自行补全。 + +从 AI 那里我进一步明确了 MVVM 中命令绑定与 `ObservableCollection` 的用法,以及 GUI 环境下"所有网络请求必须异步"的原因。整体而言,本节难度适中偏高(GUI + MVVM + 异步的组合较复杂)。 diff --git a/docs/05-advanced/assets/advanced-full.jpg b/docs/05-advanced/assets/advanced-full.jpg new file mode 100644 index 0000000..e0303a8 Binary files /dev/null and b/docs/05-advanced/assets/advanced-full.jpg differ diff --git a/docs/05-advanced/assets/advanced-robustness.jpg b/docs/05-advanced/assets/advanced-robustness.jpg new file mode 100644 index 0000000..b9fa787 Binary files /dev/null and b/docs/05-advanced/assets/advanced-robustness.jpg differ diff --git a/docs/05-advanced/report.md b/docs/05-advanced/report.md new file mode 100644 index 0000000..b4a0e39 --- /dev/null +++ b/docs/05-advanced/report.md @@ -0,0 +1,72 @@ +# Report for Advanced Functions + +## 如何编译运行 + +本项目是一个云服务日志分析系统,包含服务端(Agent)与图形界面客户端(LogAnalyzerClient)。在 `src` 目录下: + +1. 启动 Agent(gRPC 服务端,监听 `http://localhost:5000`): + + ```shell + cd src + dotnet run --project LogAnalyzerAgent/LogAnalyzerAgent.csproj + ``` + +2. 另开一个终端,启动图形界面客户端: + + ```shell + cd src + dotnet run --project LogAnalyzerClient/LogAnalyzerClient.Desktop + ``` + +3. 在 GUI 窗口菜单 `File → Connect...` 中输入 `http://localhost:5000` 连接 Agent,然后在 `Directory Path` 输入框填入日志目录(如 `src/dataset`)并点击 `Change Directory` 即可开始分析。 + +> 注:macOS 的 ControlCenter(AirPlay 接收器)会占用 `*:5000`,但 Agent 只绑定 `127.0.0.1:5000`,两者可以共存,不影响使用。 + +## 实现的功能与使用方式 + +本节在基础功能之上,实现了三个新功能:一个功能性(日志排序与查询)、一个美观性(表格显示 + 等级高亮)、一个自由功能(日志统计概览)。 + +### 1. 日志排序与查询(T5.1.a) + +在 Agent 端新增了 `QueryLogEntries` RPC,支持对某个文件已解析的日志按条件过滤与排序: + +- 过滤条件:事件类型(Call/Request/Internal)、日志等级(Info/Warning/Error)、服务名(如 `gateway`)、Request ID、时间范围(起始/结束时间)。 +- 排序:可按 `LineNo`、`Timestamp`、`PodName`、`Severity`、`EventType` 升序或降序排序。 + +在客户端 `Analysis Result` 面板上方新增了一个查询面板,包含 Event、Severity、Service、RequestId、Start、End、Sort、Asc 等控件,以及 `Query` 和 `Clear` 按钮: + +- 在 Log Files 列表右键选择某个文件并 `View Analysis Results`(或先分析该文件),随后在查询面板设置条件,点击 `Query` 即可在下方表格中看到过滤/排序后的结果。 +- 点击 `Clear` 清空所有筛选条件。 + +### 2. 日志表格显示 + 等级高亮(T5.1.b) + +将原来单列的日志列表改为表格显示:每个字段作为一列(`LineNo`、`Timestamp`、`PodName`、`Severity`、`EventType`、`RequestId`、`TargetService`、`DurationMs`、`Method`、`Path`、`StatusCode`、`ExceptionName`、`ExceptionMessage`),表格支持横向滚动。 + +`Severity` 列根据日志等级以不同背景色高亮:`Info` 蓝色、`Warning` 橙色、`Error` 红色,便于快速定位错误。 + +### 3. 日志统计概览(T5.2,自由功能) + +在 Agent 端新增 `GetStatistics` RPC,返回某个文件的日志统计信息(按日志等级、事件类型、服务名的数量分布)。在 `Analysis Result` 面板右上角新增 `Statistics` 按钮,点击后弹出消息框,展示当前选中文件的三类统计。 + +## 截图 + +### 完整功能截图 + +![完整功能截图](./assets/advanced-full.jpg) + +### 鲁棒性测试截图 + +![鲁棒性测试截图](./assets/advanced-robustness.jpg) + +## 实现过程中对 AI 的使用 + +我在本节使用了 AI(辅助工具)来完成代码。我给予 AI 的提示词大致是:"切换到 05-advanced 分支,实现日志排序与查询、表格显示与等级高亮、日志统计三个功能"。对 AI 的使用主要是:梳理现有代码结构(`AgentSession`、`MainViewModel`、`MainView.axaml`),以及 Protobuf `optional` 字段在 C# 生成代码中的使用方式。 + +AI 提供的便利主要体现在:快速定位需要改动的文件与位置、给出与既有代码风格一致的骨架、提醒我 `optional enum`/`optional string` 的 `HasXxx` 标志用法。但 AI 的解答也出现过需要人工修正的错误:例如一开始尝试引入 `Avalonia.Controls.DataGrid` 包时版本与主 `Avalonia` 版本不匹配导致还原失败,最终改为用 `Grid` 自绘表格;又如 `DataTrigger` 的 XAML 写法在当前 Avalonia 版本下无法解析,最终改为在模型中直接计算 `SeverityBrush` 属性。这些都需要结合编译错误逐一排查修正。 + +## 开发心得 + +1. 前后端配合的功能(查询、统计)需要先在 `.proto` 里定义好接口契约,再分别实现 Agent 端逻辑与客户端界面,协议是两端协作的"单一事实来源"。 +2. GUI 的布局与绑定(尤其是表格的列对齐、ComboBox 的双向绑定)远比控制台复杂,一个 XAML 属性写错就会在运行时报错,需要谨慎。 +3. 遇到第三方库版本冲突时,"少依赖、自己实现"往往比强行升级更稳妥。 +4. 统计、查询这类功能的本质是对 `LogEntry` 列表做 LINQ 过滤/分组/排序,Agent 端逻辑并不复杂,真正的难点在把结果以友好的方式呈现给用户。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..f291fed 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,92 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var logFiles = analyzer.GetLogFiles(); + if (logFiles.Count == 0) + { + Console.WriteLine("No log files found in the directory."); + return; + } + + Console.WriteLine("Log files:"); + foreach (var fileName in logFiles) + { + Console.WriteLine($" {fileName}"); + } } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input file names separated by commas (e.g., basic.log,basic-multiple.log):"); + var input = Console.ReadLine(); + if (input is null) + { + return; + } + + var fileNames = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (fileNames.Length == 0) + { + Console.WriteLine("No file names provided, please try again."); + return; + } + + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to analyze files: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + analyzer.AnalyzeAll(0); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to analyze all files: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input file name:"); + var fileName = Console.ReadLine(); + if (fileName is null) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result)) + { + Console.WriteLine($"File '{fileName}' is not in the current directory, please try again."); + return; + } + + switch (result!.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File '{fileName}' has not been analyzed yet."); + break; + case AnalysisState.Succeeded: + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var kv = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", kv.Select(pair => $"{pair.Key}={pair.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"File '{fileName}' failed to analyze: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..80d0f41 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -138,10 +138,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 +147,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 +161,14 @@ 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 analysisResult)) + { + throw new InvalidOperationException($"Unknown file: {file.Name}."); + } + if (analysisResult.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -180,10 +179,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 +191,19 @@ 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 + var worker = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName, + }; + workers[i] = worker; + worker.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 +215,37 @@ private void WorkerMain(int workerId, WorkQueue queue) AnalysisResult result; try { - // Parse file - throw new NotImplementedException("TODO: T2.2"); + List entries; + using (var reader = new StreamReader(file.FullName)) + { + 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..948c434 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,40 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _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..2ba4324 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -3,6 +3,7 @@ using LogAnalyzer; using LogAnalyzerRpc.Protos; using LogAnalyzerRpc; +using LogParser.Models; using LogParser.Visitors; namespace LogAnalyzerAgent.Applications @@ -38,6 +39,27 @@ private static OperationStatusMessage CreateNoErrorOperationStatus() }; } + private static OperationStatusMessage CreateErrorOperationStatus(AgentErrorCode code, string message) + { + return new OperationStatusMessage() + { + Success = false, + Code = code, + Message = message, + }; + } + + private static OperationStatusMessage CreateOperationStatusFromException(Exception ex) + { + return ex switch + { + ArgumentOutOfRangeException => CreateErrorOperationStatus(AgentErrorCode.InvalidArgument, ex.Message), + InvalidOperationException => CreateErrorOperationStatus(AgentErrorCode.InvalidOperation, ex.Message), + ArgumentException => CreateErrorOperationStatus(AgentErrorCode.FileNotFound, ex.Message), + _ => CreateErrorOperationStatus(AgentErrorCode.InternalError, ex.Message), + }; + } + public Task Ping(Empty empty, CancellationToken cancellationToken) { return Task.FromResult(new Empty()); @@ -79,22 +101,286 @@ 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 + { + if (!_analyzer.ChangeDirectory(request.DirectoryPath)) + { + if (_analyzer.IsAnalyzing) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, "Cannot change directory while the agent is analyzing."); + } + else + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.DirectoryNotFound, $"Directory not found: {request.DirectoryPath}"); + } + return Task.FromResult(response); + } + + response.Status = CreateNoErrorOperationStatus(); + response.CurrentDirectory = _analyzer.CurrentDirectory ?? ""; + response.FileNames.AddRange(_analyzer.GetLogFiles()); + } + 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"); + var response = new AnalyzeAllResponse(); + try + { + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateOperationStatusFromException(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"); + var response = new AnalyzeFilesResponse(); + try + { + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateOperationStatusFromException(ex); + _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 + { + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result)) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, $"File '{request.FileName}' is not in the current directory."), + }); + return responses; + } + + 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; + } + + responses.Add(new GetAnalysisResultResponse() + { + Header = header, + Status = CreateNoErrorOperationStatus(), + }); + + if (result.State == AnalysisState.Succeeded) + { + foreach (var entry in result.Entries) + { + responses.Add(new GetAnalysisResultResponse() + { + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + Status = CreateNoErrorOperationStatus(), + }); + } + } + + return responses; + } + catch (Exception ex) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = CreateInternalErrorOperationStatus(ex), + }); + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + return responses; + } + } + + public Task QueryLogEntries(QueryLogEntriesRequest request, CancellationToken cancellationToken) + { + var response = new QueryLogEntriesResponse(); + try + { + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, $"File '{request.FileName}' is not in the current directory."); + return Task.FromResult(response); + } + + if (result!.State != AnalysisState.Succeeded) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, $"File '{request.FileName}' has not been analyzed successfully yet."); + return Task.FromResult(response); + } + + var entries = result.Entries.AsEnumerable(); + entries = ApplyCondition(entries, request.Condition); + entries = ApplySort(entries, request.SortBy, request.SortAscending); + + foreach (var entry in entries) + { + response.Entries.Add(GrpcTypeConverter.ConvertToGrpc(entry)); + } + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateOperationStatusFromException(ex); + _logger.LogError(ex, "An error occurred while querying log entries."); + } + return Task.FromResult(response); + } + + public Task GetStatistics(GetStatisticsRequest request, CancellationToken cancellationToken) + { + var response = new GetStatisticsResponse(); + try + { + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, $"File '{request.FileName}' is not in the current directory."); + return Task.FromResult(response); + } + + if (result!.State != AnalysisState.Succeeded) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, $"File '{request.FileName}' has not been analyzed successfully yet."); + return Task.FromResult(response); + } + + foreach (var group in result.Entries.GroupBy(e => e.Severity)) + { + response.SeverityCounts.Add(new StatisticsEntry() + { + Key = group.Key.ToString(), + Count = group.Count(), + }); + } + + foreach (var group in result.Entries.GroupBy(e => e.EventType)) + { + response.EventTypeCounts.Add(new StatisticsEntry() + { + Key = group.Key.ToString(), + Count = group.Count(), + }); + } + + foreach (var group in result.Entries.GroupBy(e => GetServiceName(e.PodName))) + { + response.ServiceCounts.Add(new StatisticsEntry() + { + Key = group.Key, + Count = group.Count(), + }); + } + + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateOperationStatusFromException(ex); + _logger.LogError(ex, "An error occurred while retrieving statistics."); + } + return Task.FromResult(response); + } + + private static IEnumerable ApplyCondition(IEnumerable entries, QueryCondition condition) + { + if (condition.HasEventType) + { + var eventType = GrpcTypeConverter.ConvertFromGrpc(condition.EventType); + entries = entries.Where(e => e.EventType == eventType); + } + + if (condition.HasSeverity) + { + var severity = GrpcTypeConverter.ConvertFromGrpc(condition.Severity); + entries = entries.Where(e => e.Severity == severity); + } + + if (condition.HasService && !string.IsNullOrEmpty(condition.Service)) + { + entries = entries.Where(e => GetServiceName(e.PodName) == condition.Service); + } + + if (condition.HasRequestId && !string.IsNullOrEmpty(condition.RequestId)) + { + entries = entries.Where(e => TryGetRequestId(e) == condition.RequestId); + } + + if (condition.HasStartTime && !string.IsNullOrEmpty(condition.StartTime)) + { + var start = DateTimeOffset.Parse(condition.StartTime); + entries = entries.Where(e => e.Timestamp >= start); + } + + if (condition.HasEndTime && !string.IsNullOrEmpty(condition.EndTime)) + { + var end = DateTimeOffset.Parse(condition.EndTime); + entries = entries.Where(e => e.Timestamp <= end); + } + + return entries; + } + + private static IEnumerable ApplySort(IEnumerable entries, string sortBy, bool ascending) + { + var key = string.IsNullOrEmpty(sortBy) ? "" : sortBy.Trim().ToLowerInvariant(); + return key switch + { + "lineno" => ascending ? entries.OrderBy(e => e.LineNo) : entries.OrderByDescending(e => e.LineNo), + "timestamp" => ascending ? entries.OrderBy(e => e.Timestamp) : entries.OrderByDescending(e => e.Timestamp), + "podname" => ascending ? entries.OrderBy(e => e.PodName) : entries.OrderByDescending(e => e.PodName), + "severity" => ascending ? entries.OrderBy(e => e.Severity) : entries.OrderByDescending(e => e.Severity), + "eventtype" => ascending ? entries.OrderBy(e => e.EventType) : entries.OrderByDescending(e => e.EventType), + _ => entries, + }; + } + + private static string GetServiceName(string podName) + { + var index = podName.LastIndexOf('-'); + return index < 0 ? podName : podName.Substring(0, index); + } + + private static string? TryGetRequestId(LogEntry entry) + { + return entry switch + { + CallLogEntry call => call.RequestId, + RequestLogEntry request => request.RequestId, + _ => null, + }; } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..654d39e 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,41 @@ 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 Task QueryLogEntries(QueryLogEntriesRequest request, ServerCallContext context) + { + return _session.QueryLogEntries(request, context.CancellationToken); + } + + public override Task GetStatistics(GetStatisticsRequest request, ServerCallContext context) + { + return _session.GetStatistics(request, context.CancellationToken); } } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..efe0d45 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; +using Avalonia.Media; +using LogParser.Models; namespace LogAnalyzerClient.Models { @@ -9,10 +8,61 @@ public sealed record LogFileItem(string FileName) public override string ToString() => FileName; } - public sealed record LogFields(int Index, IReadOnlyList Fields, string? ErrorMessage) + public sealed class LogEntryRow { - public string Summary => "TODO: T4.1"; - } + public string LineNo { get; set; } = ""; + public string Timestamp { get; set; } = ""; + public string PodName { get; set; } = ""; + public string Severity { get; set; } = ""; + public string EventType { get; set; } = ""; + public string RequestId { get; set; } = ""; + public string TargetService { get; set; } = ""; + public string DurationMs { get; set; } = ""; + public string Method { get; set; } = ""; + public string Path { get; set; } = ""; + public string StatusCode { get; set; } = ""; + public string ExceptionName { get; set; } = ""; + public string ExceptionMessage { get; set; } = ""; + public IBrush SeverityBrush { get; set; } = Brushes.Transparent; + + public static LogEntryRow From(LogEntry entry) + { + var row = new LogEntryRow + { + LineNo = entry.LineNo.ToString(), + Timestamp = entry.Timestamp.ToString("O"), + PodName = entry.PodName, + Severity = entry.Severity.ToString(), + EventType = entry.EventType.ToString(), + SeverityBrush = entry.Severity switch + { + LogSeverity.Info => new SolidColorBrush(Color.Parse("#2E8BFF")), + LogSeverity.Warning => new SolidColorBrush(Color.Parse("#F5A623")), + LogSeverity.Error => new SolidColorBrush(Color.Parse("#E5484D")), + _ => Brushes.Transparent, + }, + }; - public sealed record LogFieldItem(string Key, string Value); + switch (entry) + { + case CallLogEntry call: + row.RequestId = call.RequestId; + row.TargetService = call.TargetService; + row.DurationMs = call.DurationMs.ToString(); + break; + case RequestLogEntry request: + row.RequestId = request.RequestId; + row.Method = request.Method; + row.Path = request.Path; + row.StatusCode = request.StatusCode.ToString(); + break; + case InternalLogEntry internalEntry: + row.ExceptionName = internalEntry.ExceptionName; + row.ExceptionMessage = internalEntry.ExceptionMessage; + break; + } + + return row; + } + } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml index 5f30195..33aecfe 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml @@ -47,4 +47,11 @@ + + diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs index 91c05a8..73b7090 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -12,6 +12,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Text; using System.Threading.Tasks; namespace LogAnalyzerClient.ViewModels @@ -54,7 +55,38 @@ private static class ConnectStatusString private LogFileItem? _selectedLogFile = null; [ObservableProperty] - private ObservableCollection _resultEntries = new(); + private ObservableCollection _resultEntries = new(); + + [ObservableProperty] + private string _resultMessage = ""; + + [ObservableProperty] + private string _queryEventType = "All"; + + [ObservableProperty] + private string _querySeverity = "All"; + + [ObservableProperty] + private string _queryService = ""; + + [ObservableProperty] + private string _queryRequestId = ""; + + [ObservableProperty] + private string _queryStartTime = ""; + + [ObservableProperty] + private string _queryEndTime = ""; + + [ObservableProperty] + private string _sortBy = "None"; + + [ObservableProperty] + private bool _sortAscending = true; + + public IReadOnlyList EventTypeOptions { get; } = new[] { "All", "Call", "Request", "Internal" }; + public IReadOnlyList SeverityOptions { get; } = new[] { "All", "Info", "Warning", "Error" }; + public IReadOnlyList SortByOptions { get; } = new[] { "None", "LineNo", "Timestamp", "PodName", "Severity", "EventType" }; [RelayCommand] private async Task ConnectAsync() @@ -132,31 +164,298 @@ 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 DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + LogFiles.Clear(); + foreach (var fileName in response.FileNames) + { + LogFiles.Add(new LogFileItem(fileName)); + } }); } [RelayCommand] private async Task AnalyzeSelectedFilesAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + var degree = await GetDegreeOfParallelismAsync(); + if (degree is null) + { + return; + } + + var fileNames = SelectedFiles.ToList(); + if (fileNames.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file selected."); + return; + } + + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree.Value, + }; + request.FileNames.AddRange(fileNames); + + var response = await _client!.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + }); } - /* - * TODO: T4.1 - * Add AnalyzeAllAsync ReplayCommand - */ + [RelayCommand] + private async Task AnalyzeAllAsync() + { + await WithClientNotNull(async () => + { + var degree = await GetDegreeOfParallelismAsync(); + if (degree is null) + { + return; + } + + var response = await _client!.AnalyzeAllAsync(new AnalyzeAllRequest() + { + DegreeOfParallelism = degree.Value, + }); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + }); + } [RelayCommand] private async Task AnalyzeRightClickedFileAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file selected."); + return; + } + + var degree = await GetDegreeOfParallelismAsync(); + if (degree is null) + { + return; + } + + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree.Value, + }; + request.FileNames.Add(SelectedLogFile.FileName); + + var response = await _client!.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + }); } [RelayCommand] private async Task GetAnalysisResultAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file selected."); + return; + } + + var request = new GetAnalysisResultRequest() + { + FileName = SelectedLogFile.FileName, + }; + + using var call = _client!.GetAnalysisResult(request); + var responses = await call.ResponseStream.ReadAllAsync().ToListAsync(); + + ResultEntries.Clear(); + ResultMessage = ""; + + if (responses.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No response received."); + return; + } + + var first = responses[0]; + if (!first.Status.Success) + { + ResultMessage = first.Status.Message; + return; + } + + if (first.PayloadCase != GetAnalysisResultResponse.PayloadOneofCase.Header) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Unexpected response."); + return; + } + + var header = first.Header; + switch (header.State) + { + case AnalysisStateEnum.NotAnalyzed: + ResultMessage = $"File '{SelectedLogFile.FileName}' has not been analyzed yet."; + break; + case AnalysisStateEnum.Failed: + ResultMessage = $"Analysis failed: {header.ErrorMessage}"; + break; + case AnalysisStateEnum.Succeeded: + foreach (var response in responses.Skip(1)) + { + var entry = GrpcTypeConverter.ConvertFromGrpc(response.LogEntry); + ResultEntries.Add(LogEntryRow.From(entry)); + } + ResultMessage = $"Analysis succeeded: {ResultEntries.Count} entries."; + break; + } + }); + } + + [RelayCommand] + private async Task QueryAsync() + { + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file selected."); + return; + } + + var condition = new QueryCondition(); + if (QueryEventType != "All") + { + condition.EventType = System.Enum.Parse(QueryEventType); + } + if (QuerySeverity != "All") + { + condition.Severity = System.Enum.Parse(QuerySeverity); + } + if (!string.IsNullOrWhiteSpace(QueryService)) + { + condition.Service = QueryService.Trim(); + } + if (!string.IsNullOrWhiteSpace(QueryRequestId)) + { + condition.RequestId = QueryRequestId.Trim(); + } + if (!string.IsNullOrWhiteSpace(QueryStartTime)) + { + condition.StartTime = QueryStartTime.Trim(); + } + if (!string.IsNullOrWhiteSpace(QueryEndTime)) + { + condition.EndTime = QueryEndTime.Trim(); + } + + var request = new QueryLogEntriesRequest() + { + FileName = SelectedLogFile.FileName, + Condition = condition, + SortBy = SortBy == "None" ? "" : SortBy, + SortAscending = SortAscending, + }; + + var response = await _client!.QueryLogEntriesAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + ResultEntries.Clear(); + foreach (var entryMessage in response.Entries) + { + ResultEntries.Add(LogEntryRow.From(GrpcTypeConverter.ConvertFromGrpc(entryMessage))); + } + ResultMessage = $"Query result: {ResultEntries.Count} entries."; + }); + } + + [RelayCommand] + private void ClearQuery() + { + QueryEventType = "All"; + QuerySeverity = "All"; + QueryService = ""; + QueryRequestId = ""; + QueryStartTime = ""; + QueryEndTime = ""; + SortBy = "None"; + SortAscending = true; + } + + [RelayCommand] + private async Task GetStatisticsAsync() + { + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file selected."); + return; + } + + var response = await _client!.GetStatisticsAsync(new GetStatisticsRequest() + { + FileName = SelectedLogFile.FileName, + }); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + var builder = new StringBuilder(); + builder.AppendLine("Severity:"); + foreach (var item in response.SeverityCounts) + { + builder.AppendLine($" {item.Key}: {item.Count}"); + } + builder.AppendLine("Event type:"); + foreach (var item in response.EventTypeCounts) + { + builder.AppendLine($" {item.Key}: {item.Count}"); + } + builder.AppendLine("Service:"); + foreach (var item in response.ServiceCounts) + { + builder.AppendLine($" {item.Key}: {item.Count}"); + } + + await DialogHelper.ShowMessageDialogAsync($"Statistics - {SelectedLogFile.FileName}", builder.ToString()); + }); + } + + private async Task GetDegreeOfParallelismAsync() + { + if (int.TryParse(DegreeOfParallelismText, out var degree) && degree >= 0) + { + return degree; + } + + await DialogHelper.ShowMessageDialogAsync("Error", + "Invalid degree of parallelism. Please input a non-negative integer."); + return null; } [RelayCommand] diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..6f00798 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 - +