diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..6e8fedd --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,139 @@ +# 01-basic 实验报告 + +## (Q1.1) + +以下分析均针对 `src/LogParser` 目录下的框架代码。 + +### 1. 按逗号分割日志、指定字段含义的语句 + +框架代码并没有手写 `Split(',')` 之类的语句,而是借助 **CsvHelper** 库来完成按逗号(默认分隔符)分割整行的工作。 + +在 `Parser/LogFileParser.cs` 的 `Parse` 方法中: + +```csharp +var config = new CsvConfiguration(CultureInfo.InvariantCulture) +{ + HasHeaderRecord = false +}; +using var csv = new CsvReader(logFile, config); +csv.Context.RegisterClassMap(); + +foreach (var logRecord in csv.GetRecords()) +{ + yield return LineParser.ParseLine(logRecord); +} +``` + +`CsvReader.GetRecords()` 会按 `CsvConfiguration` 的默认分隔符(`,`)将每一行拆分成字段,并填充到 `LogRecord` 对象中。 + +而“每一行的第几个字段代表何种意义”是通过 `LogRecordMap : ClassMap` 中按列下标(`Index`)映射的方式指定的: + +```csharp +Map(m => m.LineNo).Index(0); // 第 0 列 -> LineNo +Map(m => m.Timestamp).Index(1); // 第 1 列 -> Timestamp +Map(m => m.PodName).Index(2); // 第 2 列 -> PodName +Map(m => m.Message).Index(3); // 第 3 列 -> Message +``` + +### 2. 判断日志种类(Call / Request / Internal)的方法与语句 + +在 `Parser/LineParser.cs` 的 `ParseLine(LogRecord logRecord)` 方法中: + +```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(logRecord.Message)` 把 `message` 字段(JSON 字符串)解析为 JSON 文档; ++ 再用 `root.TryGetProperty("event", out var eventElement)` 取出名为 `event` 的字段; ++ 最后用 `eventElement.GetString()` 得到的字符串配合 `switch` 表达式分发到对应的创建方法(`CreateCall` / `CreateRequest` / `CreateInternal`)。 + +### 3. 解析 JSON 的库方法 + +在确定了日志种类后,框架使用 **`System.Text.Json`** 中的 `JsonSerializer.Deserialize(...)` 方法进行强类型解析,例如: + +```csharp +var callMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException(...); +``` + +#### 3.1 防止字段缺失 + +框架在 message 的内部 record 的每个必填属性上标注了 `[property: JsonRequired]`,例如: + +```csharp +private record CallMessage( + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string TargetService, + [property: JsonRequired] int DurationMs +); +``` + +当 JSON 中缺少被标记为 `JsonRequired` 的字段(例如 Call 日志的 `message` 中缺失 `duration-ms`)时,`JsonSerializer.Deserialize` 会抛出 `JsonException`,从而防止静默地把字段当默认值使用。 + +#### 3.2 kebab-case 到 PascalCase 的命名转换 + +框架在 `LineParser` 中定义了静态的 `JsonSerializerOptions`: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`JsonNamingPolicy.KebabCaseLower` 会把 JSON 中的烤串命名(kebab-case)键(如 `request-id`、`target-service`、`duration-ms`)自动转换为大驼峰(PascalCase)的 C# 属性名(如 `RequestId`、`TargetService`、`DurationMs`),从而在反序列化时自动完成命名法的相互映射。 + +## (Q1.2) + +以一个 Call 事件的解析结果为例,调用 `KeyValueVisitor.Dump` 后,完整的方法调用链为: + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)`(对 `LogEntry` 上抽象方法 `Accept` 的动态绑定实现) ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +调用过程说明: + +1. 用户调用 `Dump(entry)`; +2. `Dump` 内部执行 `return entry.Accept(this);`,由于运行时的实际类型是 `CallLogEntry`,会调用 `CallLogEntry.Accept` 的重写实现; +3. `CallLogEntry.Accept` 内部执行 `return visitor.Visit(this);`,其中 `visitor` 就是 `KeyValueVisitor` 实例,`this` 的静态类型为 `CallLogEntry`,因此重载决议调用 `KeyValueVisitor.Visit(CallLogEntry entry)`,完成 Call 类型日志的键值对提取。 + +## (Q1.3.b) + +本次作业使用了 AI(Cline 编码助手)辅助完成。 + +### 我给予 AI 的提示词 + +我向 AI 提供了如下任务描述: + +> 阅读本仓库 `docs/00-prepare` 与 `docs/01-basic` 的说明文档,以及 `src/test-01-basic` 下的测试代码与 `src/TestUtils` 中的样例数据;随后在 `src/LogParser` 中补齐所有 `TODO` 标记的实现(`LogEntries.cs` 中 `Accept` 方法、`LineParser.cs` 中 request/internal 的解析、`KeyValueVisitor.cs` 中 Request/Internal 的 `Visit` 方法),使得 `dotnet test test-01-basic -c Release` 全部通过,并为 Q1.1–Q1.3 撰写 `docs/01-basic/report.md`。 + +### AI 的解答比传统搜索引擎 + 自己写的解答好在哪里 + ++ **定位精准、效率高**:AI 通过直接搜索 `TODO` 标记和通读测试文件,一次就锁定了所有需要修改的文件与方法,省去了人工逐篇阅读文档、试错的时间。 ++ **能综合“测试用例 + 样例数据”反向推导精确格式**:例如 `Timestamp` 必须用 `ToString("O")` 输出、Internal 日志的 `exception` 字段需要按第一个冒号拆分为 `ExceptionName` 与 `ExceptionMessage`,这些细节仅凭文档难以确定,但 AI 能从 `TestUtilsClass` 的样例与 `Test_1_3` 的断言中推导出来并直接落地。 ++ **产出与既有代码风格一致、可直接运行**:AI 生成的代码复用了框架已有的 `JsonSerializerOptions`、`[property: JsonRequired]`、`ParseSeverity` 等既有设施,并且会主动运行 `dotnet build` / `dotnet test` 进行验证,保证结果可用。 + +### AI 的解答存在的问题或不如自己写的地方 + ++ **容易“只对齐测试、不解释意图”**:例如为什么 internal 的 exception 拆分要取第一个冒号而非最后一个、为什么要用访问者模式而不是 `switch` 类型判断,AI 只会按测试通过为目标来写,如果不追问,它不会主动解释设计动机。 ++ **在缺乏测试约束的细节上可能凭猜测**:例如 `ExceptionName` / `ExceptionMessage` 是否需要 `Trim()`、异常类型用 `FormatException` 还是自定义异常等,AI 的默认选择不一定是最贴合项目约定的,需要人工审查其是否符合题目本意。 ++ **生成结果仍需人工验证与理解**:AI 给出的代码只是“能通过测试”的充分条件,而非“符合设计意图”的充分条件。若不阅读、不理解就照抄,本次作业就失去了训练目的。因此我对照 guidance 中的“简单工厂模式 / 访问者模式”章节逐行核对了最终实现。 + +综上,AI 极大地提升了定位与落地的效率,但最终对代码语义与设计模式的理解、以及对生成结果的审查仍然需要由我本人完成。 diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..34729a8 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,161 @@ +# 02-multithreading 实验报告 + +## 实现的功能介绍 + +本节完成了三个部分: + +### T2.1 线程安全队列 `WorkQueue`(`src/LogAnalyzer/WorkQueue.cs`) + +基于 C# 非线程安全的 `Queue` 实现了一个线程安全队列,作为"无限仓库容量"生产者消费者模型: + ++ `Enqueue`:加锁后入队,并通过 `Monitor.Pulse` 唤醒一个等待中的消费者;若队列已 `CompleteAdding` 则抛出 `InvalidOperationException`。 ++ `TryDequeue`:加锁后,在「队列为空且未结束放入」时用 `while (条件) Monitor.Wait` 阻塞等待;一旦有元素被放入或放入结束即被唤醒。有元素则取出返回 `true`,已结束且队列为空则返回 `false`。 ++ `CompleteAdding`:加锁后置 `_isCompleted = true` 并 `Monitor.PulseAll` 唤醒所有等待的消费者,使它们能够退出。 ++ `IsCompleted`:在锁内读取标记。 + +### T2.2 并行日志分析 `LogFileAnalyzer`(`src/LogAnalyzer/LogFileAnalyzer.cs`) + ++ `AnalyzeFiles`:校验参数后,在锁内设置 `_isAnalyzing = true`;`RunWorkers` 执行完毕后,在 `finally` 中加锁复位 `_isAnalyzing = false`,从而保证同一时刻只有一个分析任务,其他并发调用会抛出 `InvalidOperationException`。 ++ `RunWorkers`:在锁内过滤「已经分析过(Succeeded / Failed)」的文件(跳过以节省计算资源),对未知文件抛 `InvalidOperationException`;将待解析文件全部入队并 `CompleteAdding`;按 `degreeOfParallelism` 创建后台线程,以 `WorkerMain` 为入口;最后 `Join` 等待所有线程结束。 ++ `WorkerMain`:循环 `TryDequeue` 文件,使用上一章的 `LogFileParser` 解析;成功时构造 `Succeeded` 结果,失败时捕获异常并构造 `Failed` 结果(错误信息存入 `ErrorMessage`);在锁内把结果写入 `_analysisResults`,避免数据竞争。 + +### T2.3 控制台交互界面 `LocalCli`(`src/LocalCli/Program.cs`) + ++ `ShowLogFiles`:调用 `GetLogFiles` 列出当前目录的全部日志文件。 ++ `AnalyzeFiles`:接受逗号分隔的文件名列表并调用 `AnalyzeFiles(0, fileNames)` 分析,捕获异常并提示,不会崩溃。 ++ `AnalyzeAll`:调用 `AnalyzeAll(0)` 分析全部文件,同样做了异常捕获。 ++ `GetAnalysisResult`:按文件名查询 `TryGetAnalysisResult`: + - 未分析 → 给出提示; + - 成功 → 用 `KeyValueVisitor.Dump` 输出每一条日志的键值对; + - 失败 → 输出 `ErrorMessage`; + - 不存在 → 给出提示。 + +## 功能记录(终端输出) + +以下为 `src/` 目录下运行 `LocalCli` 的完整功能流程记录(命令行环境无法截取位图,故以终端文本记录代替截图): + +``` +Please input directory containing log files: <- 输入 dataset +Please choose: +1. Show log files. +2. Analyze specified log files. +3. Analyze all log files. +4. Get log file analysis result. +5. Change directory. +6. Exit. +>>> 1 <- 功能1:列出日志文件 +Log files in the current directory: +- basic-fail.log +- basic-multiple.log +- basic.log + +Please choose: ... +>>> 3 <- 功能3:分析全部文件 +Analysis completed. + +Please choose: ... +>>> 4 <- 功能4:查询分析结果 +Please input a file name to query: +>>> basic.log +Analysis result for 'basic.log': +LineNo=0, Timestamp=2026-06-05T16:00:29.0450000+00:00, PodName=userservice-0, Severity=Info, EventType=Call, RequestId=3a013a08-6853-49fc-8f06-50daeb5c1e51, TargetService=authservice, DurationMs=18 +LineNo=1, Timestamp=2026-06-05T16:00:31.0860000+00:00, PodName=userservice-1, Severity=Info, EventType=Request, RequestId=1177c344-115e-4f85-b8ec-c9164d132b79, Method=GET, Path=/api/user/john, StatusCode=404 +LineNo=2, Timestamp=2026-06-05T16:05:45.3220000+00:00, PodName=gateway-0, Severity=Error, EventType=Internal, ExceptionName=System.InvalidOperationException, ExceptionMessage=Failed to load gateway routing configuration. +``` + +## 鲁棒性测试记录(终端输出) + +``` +Please choose: ... +>>> abc <- 非法菜单输入 +Invalid input, please try again. + +Please choose: ... +>>> 4 +Please input a file name to query: +>>> nonexist.log <- 查询不存在的文件 +No analysis result found for 'nonexist.log'. + +Please choose: ... +>>> 2 +Please input file names to analyze (separated by commas): +>>> basic.log, no-such-file.log <- 分析列表中含不存在的文件 +Analysis failed: File 'no-such-file.log' is not in the current directory or does not exist. + +Please choose: ... +>>> 5 +Please input directory containing log files: +>>> nonexist-dir <- 切换到不存在的目录 +Directory not exists, please try again: +Please input directory containing log files: +>>> dataset <- 重新输入有效目录,恢复正常 +``` + +可以看到,各种非法输入(非法菜单选项、不存在的文件名、不存在的目录)都会被捕获并提示用户重新输入,程序不会崩溃。 + + +## (Q2.1) + +### `WorkQueue` 类中的共享变量与保护方式 + ++ 共享变量: + - `_items`:`Queue`,队列本身; + - `_isCompleted`:`bool`,标记是否已结束放入。 ++ 保护方式:对 `_items` 使用 `lock (_items)` 进行互斥保护。`_isCompleted` 的读(`IsCompleted`)与写(`CompleteAdding`、`Enqueue` 的检查)全部位于对 `_items` 的 `lock` 临界区内,因此同一个互斥锁同时保护了这两个共享变量。此外,线程间协作使用 C# 的管程(`Monitor.Wait` / `Monitor.Pulse` / `Monitor.PulseAll`,即条件变量)。 + +### `LogFileAnalyzer` 类中的共享变量与保护方式 + ++ 共享变量:`_currentDirectory`(当前目录)、`_isAnalyzing`(是否正在分析)、`_logFiles`(文件名 → `FileInfo`)、`_analysisResults`(文件名 → `AnalysisResult`)。 ++ 保护方式:以上共享变量一律通过 `lock (_syncRoot)` 保护。`IsAnalyzing` 属性、`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers` 以及 `WorkerMain` 中写回结果的代码,都先加锁再访问共享状态,从而避免数据竞争。 + +### 条件变量用 `if` 判断而非 `while` 的后果 + +在 MESA 模型(以及类 UNIX 系统的信号导致的虚假唤醒)下,条件变量的 `wait` 可能在没有任何 `signal` / `broadcast` 的情况下被唤醒。若使用 `if (条件) { wait(); }`,线程被唤醒后不会重新检查条件就继续往下执行。 + +以无限仓库容量的生产者消费者问题为例:消费者在「仓库为空」时 `wait`,如果它被**虚假唤醒**(或虽然被正常唤醒但商品已被另一个消费者取走),用 `if` 的消费者会直接执行"取商品"操作,而此时仓库里并没有商品,导致**空取出(取出不存在的商品 / 缓冲区下溢)**,产生逻辑错误甚至未定义行为。 + +而用 `while (条件) { wait(); }` 时,每次从 `wait` 返回后都会**重新检查条件**,只有条件真正满足(仓库非空)才会继续取商品,因此即使出现虚假唤醒也能安全地回到 `wait` 中,保证正确性。 + +## (Q2.2) + +### 扫描给定目录全部 `.log` 文件的代码 + +在 `LogFileAnalyzer.ChangeDirectory` 方法中: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +### 若需要递归扫描子目录 + +将 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可,例如: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + + +## (Q2.3.b) + +本次作业使用了 AI(Cline 编码助手)。 + +### 我给予 AI 的提示词 + +> 阅读 `docs/02-multithreading` 的说明文档与 `src/test-02-multithreading` 测试代码,在 `src/LogAnalyzer`(`WorkQueue.cs`、`LogFileAnalyzer.cs`)与 `src/LocalCli/Program.cs` 中补齐所有 `TODO` 实现,使 `dotnet test test-02-multithreading -c Release` 全部通过,并运行 `LocalCli` 验证功能与鲁棒性,最后撰写 `docs/02-multithreading/report.md`。 + +### 对 AI 的使用方式 + +主要让 AI 编写一部分作业代码(T2.1 / T2.2 / T2.3 的 TODO 实现),同时让 AI 对照测试用例解释代码框架(如 `_syncRoot` 的加锁位置、`WorkerMain` 的职责),以便我理解后再落地。 + +### AI 的解答是否出现过错误 + +出现过一次:初版 `WorkQueue.TryDequeue` 直接写 `item = _items.Dequeue();`,编译时产生 CS8762 警告(`[NotNullWhen(true)]` 保证返回 `true` 时 `item` 非空,但编译器无法推导 `Dequeue()` 对无约束泛型 `T` 一定返回非空)。AI 随后使用空包容运算符 `Dequeue()!` 修复,重新编译后警告消除、测试通过。 + +### 难度评价 + +偏低到适中。如果已经理解「互斥锁 + 条件变量 + 生产者消费者」模型,T2.1 的 `WorkQueue` 几乎是教科书实现;T2.2 的难点在于想清楚加锁的位置(尤其是 `_isAnalyzing` 的设置/复位与 `WorkerMain` 写回结果)以及如何在多线程竞争下保证"只有一次分析成功";T2.3 主要是把已有接口串起来并做好异常捕获,难度不大。总体属于需要认真思考并发同步、但逻辑并不复杂的程度。 + diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..9243b29 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,133 @@ +# 03-async-grpc 实验报告 + +## 实现的功能介绍 + +本节完成了 Agent(gRPC 服务端)与 RemoteCli(gRPC 客户端)两部分。 + +### T3.1 gRPC Agent(`src/LogAnalyzerRpc`、`src/LogAnalyzerAgent`) + ++ `GrpcLogEntryVisitor`:实现访问者模式,将 `RequestLogEntry`、`InternalLogEntry` 分别转换为 Protobuf 的 `RequestLogEntryMessage` / `InternalLogEntryMessage`,填入 `LogEntryMessage` 的 oneof 字段。 ++ `GrpcTypeConverter`:补全 `LogSeverity` ↔ `LogSeverityEnum`、`LogEventType` ↔ `LogEventTypeEnum` 的双向转换,以及 `ConvertFromGrpc(LogEntryMessage)` 对 Request / Internal 两种消息的反转换(重新构造 `RequestLogEntry` / `InternalLogEntry`,其中 Timestamp 用 `ToDateTimeOffset()` 还原)。 ++ `AgentSession`:实现四个处理逻辑: + - `ChangeDirectory`:空路径返回 `INVALID_ARGUMENT`;目录不存在返回 `DIRECTORY_NOT_FOUND`;成功后返回当前目录与全部 `.log` 文件名。 + - `AnalyzeAll` / `AnalyzeFiles`:未设置目录返回 `INVALID_OPERATION`;`AnalyzeFiles` 对空文件列表返回 `INVALID_ARGUMENT`,对包含不存在文件名的输入(`ArgumentException`)返回 `FILE_NOT_FOUND`,对其他非法操作(`InvalidOperationException`)返回 `INVALID_OPERATION`,其余异常统一转为 `INTERNAL_ERROR`,保证 Agent 永不因用户非法输入而崩溃。 + - `GetAnalysisResult`:文件不存在返回单条 `FILE_NOT_FOUND` 响应;否则返回「header(`AnalysisResultHeaderMessage`,含状态 / 错误信息 / worker id)+ 每条日志一个 `LogEntryMessage`」的响应列表。 ++ `AgentService`:作为 gRPC 服务入口,将每个 RPC 转交给 `AgentSession`;`GetAnalysisResult` 使用服务端流式返回,逐个 `WriteAsync`。 + +### T3.2 RemoteCli(`src/RemoteCli/Program.cs`) + +参照上一节 `LocalCli` 改造为全异步 gRPC 调用(全部使用 `...Async` 方法): + ++ `ShowLogFiles`:调用 `GetLogFilesAsync` 列出文件。 ++ `ReadDegreeOfParallelism`:读取并发度(`0` 表示自动),非法输入循环重试。 ++ `ReadFileNames`:读取逗号分隔的文件名列表,非法输入循环重试。 ++ `AnalyzeFiles` / `AnalyzeAll`:先读并发度(及文件名),再异步调用分析接口,捕获 RPC 异常与业务错误并提示。 ++ `GetAnalysisResult`:用 `client.GetAnalysisResult(request)` 发起服务端流式调用,通过 `ResponseStream.ReadAllAsync()` 逐条读取;打印 header 后用 `KeyValueVisitor.Dump` 输出每条日志的键值对。 + +## 功能记录(终端输出) + +以下为 `src/` 目录下先启动 `LogAnalyzerAgent`(监听 `http://localhost:7777`),再运行 `RemoteCli` 的完整功能流程记录(命令行环境无法截取位图,故以终端文本记录代替截图): + +``` +Connecting to agent at http://localhost:7777... + +Please choose: +1. Show log files. +2. Analyze specified log files. +3. Analyze all log files. +4. Get log file analysis result. +5. Change directory. +6. Exit. +>>> 5 <- 功能5:设置日志目录 +Please input directory containing log files: +>>> dataset + +Please choose: ... +>>> 1 <- 功能1:列出日志文件 +Log files in the current directory: +- basic-fail.log +- basic-multiple.log +- basic.log + +Please choose: ... +>>> 3 <- 功能3:分析全部文件 +Please input degree of parallelism (0 for auto): +>>> 0 +Analysis completed. + +Please choose: ... +>>> 4 <- 功能4:查询分析结果 +Please input a file name to query: +>>> basic.log +Analysis result for 'basic.log': + State: Succeeded, WorkerId: 2 + Entries: + LineNo=0, Timestamp=2026-06-05T16:00:29.0450000+00:00, PodName=userservice-0, Severity=Info, EventType=Call, RequestId=3a013a08-6853-49fc-8f06-50daeb5c1e51, TargetService=authservice, DurationMs=18 + LineNo=1, Timestamp=2026-06-05T16:00:31.0860000+00:00, PodName=userservice-1, Severity=Info, EventType=Request, RequestId=1177c344-115e-4f85-b8ec-c9164d132b79, Method=GET, Path=/api/user/john, StatusCode=404 + LineNo=2, Timestamp=2026-06-05T16:05:45.3220000+00:00, PodName=gateway-0, Severity=Error, EventType=Internal, ExceptionName=System.InvalidOperationException, ExceptionMessage=Failed to load gateway routing configuration. +``` + +## 鲁棒性测试记录(终端输出) + +``` +Please choose: ... +>>> 4 +Please input a file name to query: +>>> nonexist.log <- 查询不存在的文件 +Error: FileNotFound: File 'nonexist.log' is not found. + +Please choose: ... +>>> 2 +Please input degree of parallelism (0 for auto): +>>> 0 +Please input file names to analyze (separated by commas): +>>> basic.log, no-such-file.log <- 分析列表中含不存在的文件 +Analysis failed: FileNotFound: File 'no-such-file.log' is not in the current directory or does not exist. + +Please choose: ... +>>> 5 +Please input directory containing log files: +>>> nonexist-dir <- 切换到不存在的目录 +Error: DirectoryNotFound: Directory 'nonexist-dir' does not exist., please try again: +Please input directory containing log files: +>>> dataset <- 重新输入有效目录,恢复正常 +``` + +可以看到,查询不存在的文件、分析包含不存在文件名的列表、切换到不存在的目录等非法输入,都会被 Agent 以业务错误码(`FILE_NOT_FOUND` / `DIRECTORY_NOT_FOUND`)返回并由 RemoteCli 友好提示,程序与 Agent 都不会崩溃。 + + +## (Q3.1) + +我认为开发网络应用程序与开发非网络应用程序的主要区别有以下几点: + +1. **程序结构从「单机函数调用」变为「分布式服务」**:非网络程序在同一个进程内直接调用函数、共享内存;网络程序则需要把逻辑拆分为服务端与客户端,通过 RPC 在网络上交换数据。因此业务逻辑被拆成「服务端如何提供能力」和「客户端如何请求能力」两个面,开发时必须在两端同时考虑。 +2. **数据不再是本地的对象,而是需要「序列化 / 反序列化」**:本地程序直接引用对象即可;网络程序传输的是字节流(本作业中是 Protobuf 消息),因此需要额外编写类型转换层(`GrpcTypeConverter`、`GrpcLogEntryVisitor`),并维护 C# 类型与 Protobuf 类型、以及两边命名法(PascalCase ↔ snake_case)的一致性。 +3. **必须考虑网络的不确定性**:网络请求可能超时、被中断、服务端不可达;返回的错误除了「业务错误」(如文件不存在)还有「传输层错误」(RpcException)。因此客户端需要做异常捕获,服务端需要保证对任何输入都不会崩溃(这直接影响服务的可用性)。 +4. **并发与状态管理更复杂**:Agent 作为常驻服务是「有状态」的(保存当前目录、分析结果),且可能同时被多个客户端调用,必须通过依赖注入将 `LogFileAnalyzer` / `AgentSession` / `AgentService` 注册为单例,并注意内部共享状态的线程安全;非网络程序则没有「并发请求」这个维度。 +5. **调试与部署方式不同**:需要同时启动服务端与客户端两个进程来联调,难以像单机程序那样一步打断点;启动参数(监听地址、端口)、环境变量(`ASPNETCORE_URLS`)等也引入了额外复杂度。 + +额外的难点与复杂之处总结为:类型系统的跨语言/跨进程映射、错误处理的多层化(传输错误 + 业务错误)、有状态服务的并发安全、以及「服务端永不因非法输入崩溃」这一硬性要求。 + +## (Q3.2.b) + +本次作业使用了 AI(Cline 编码助手)。 + +### 我给予 AI 的提示词 + +> 阅读 `docs/03-async-grpc` 的说明文档、`src/test-03-async-grpc` 测试代码以及 `Protos/log_analyzer.proto`,补齐 `GrpcTypeConverter`、`GrpcLogEntryVisitor`、`AgentSession`、`AgentService` 与 `RemoteCli/Program.cs` 中的 TODO 实现,使 `dotnet test test-03-async-grpc -c Release` 全部通过;随后启动 Agent 并运行 RemoteCli 做端到端验证,最后撰写 `docs/03-async-grpc/report.md`。 + +### 对 AI 的使用方式 + +主要是让 AI 编写一部分作业代码(T3.1 / T3.2 的 TODO 实现),同时也向 AI 询问了一些 gRPC 客户端流式调用的具体写法(例如如何用 `ResponseStream.ReadAllAsync()` 读取服务端流),以及 Protobuf oneof 字段的 C# 使用方式。 + +### AI 的解答是否出现过错误 + +在初版实现中没有出现功能性错误,测试一次通过;但 AI 在端到端联调时一度没有注意到 `RemoteCli` 与 `LocalCli` 的不同——`RemoteCli` 启动后**不会自动提示输入目录**,需要先通过菜单选项 5 切换目录,导致最初的联调输入序列里目录设置没有生效。经分析 `Program.cs` 框架后修正了联调流程。 + +### 从 AI 那里得知的新知识 + ++ gRPC 服务端流式返回在 C# 中通过在 `IServerStreamWriter` 上反复 `WriteAsync` 实现,客户端则通过 `call.ResponseStream.ReadAllAsync()` 逐条读取。 ++ Protobuf 的 `oneof` 字段在 C# 中表现为 `PayloadCase` 枚举(如 `GetAnalysisResultResponse.PayloadOneofCase.Header`)与对应的 `Header` / `LogEntry` 属性,只能设置其中一个。 ++ `optional string` 字段会生成 `HasErrorMessage` 属性用于判断字段是否被显式设置。 ++ 有状态 gRPC 服务需要把相关类注册为单例(`AddSingleton`),否则每次请求都会新建状态导致相互覆盖。 + diff --git a/docs/04-avalonia/assets/gui-main.png b/docs/04-avalonia/assets/gui-main.png new file mode 100644 index 0000000..4ec9bff Binary files /dev/null and b/docs/04-avalonia/assets/gui-main.png differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..9eca048 --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,178 @@ +# 04-avalonia 实验报告 + +## 实现的功能介绍 + +本节完成了图形界面(GUI)客户端 `LogAnalyzerClient`,以及其对应的 `LogAnalyzerClient.Desktop` 桌面启动器,全面替代了上一节的 `RemoteCli` 控制台客户端。 + +### GUI 主界面概览 + +![GUI 主界面](./assets/gui-main.png) + +上图为应用程序启动后的主界面。窗口包含: +- 顶部菜单栏:`File` → `Connect...`(连接 Agent)、`Help` → `About`(关于) +- 工具栏:`Directory` 输入框 + `Change Directory` 按钮、`DoP` 并发度输入框 +- 左侧列表:`Log Files` 文件列表(支持多选、右键菜单) +- 右侧列表:`Analysis Result` 分析结果展示区 +- 底部状态栏:连接状态、当前地址、当前目录 + +### 实现的代码文件及功能 + +#### `Models/RemoteModels.cs` — LogFields.Summary(T4.1) + +`LogFields` 类通过 `Summary` 属性提供格式化显示字符串: + +- **正常分析结果**:`[0] FileName=basic.log, State=Succeeded, WorkerId=0` +- **分析失败(含错误消息)**:`[0] FileName=basic-fail.log, State=Failed, WorkerId=0 | JSON deserialization for type '...' was missing required properties including: 'method'.` +- **文件不存在**:`[0] FileNotFound: File 'no-such.log' is not found.` + +当 `ErrorMessage` 非空时,Summary 同时显示 `Fields` 键值对与错误信息,用 ` | ` 分隔;仅当 `Fields` 为空时才直接显示错误消息。这保证了用户能同时看到文件状态和错误原因。 + +#### `ViewModels/MainViewModel.cs` — 核心交互逻辑 + +通过 `CommunityToolkit.Mvvm` 的 `[ObservableProperty]` 和 `[RelayCommand]` 实现 MVVM 模式: + +| 命令属性 | 对应方法 | 功能 | +|---------|---------|------| +| `ConnectCommand` | `ConnectAsync` | 弹出连接对话框,连接 Agent | +| `RefreshCommand` | `RefreshAsync` | 调用 `GetLogFilesAsync` 刷新文件列表 | +| `AnalyzeSelectedFilesCommand` | `AnalyzeSelectedFilesAsync` | 分析选中文件 | +| `AnalyzeAllCommand` | `AnalyzeAllAsync` | 分析全部文件 | +| `AnalyzeRightClickedFileCommand` | `AnalyzeRightClickedFileAsync` | 分析右键点击的文件 | +| `GetAnalysisResultCommand` | `GetAnalysisResultAsync` | 获取选中文件的分析结果(服务端流式) | +| `AboutCommand` | `AboutAsync` | 显示关于对话框 | + +关键实现细节: + +1. **`ConnectAsync`**:通过 `DialogHelper.ShowConnectDialogAsync` 获取用户输入的地址,用 `IClientFactory` 创建 gRPC 客户端,调用 `PingAsync` 验证连通性,成功后更新状态栏。 + +2. **`RefreshAsync`**:调用 `GetLogFilesAsync` 获取文件列表,失败时通过消息框提示错误并保留原列表。 + +3. **`AnalyzeSelectedFilesAsync` / `AnalyzeAllAsync` / `AnalyzeRightClickedFileAsync`**:读取 `DegreeOfParallelismText`(使用 `TryReadDegreeOfParallelism` 辅助方法验证合法性),调用对应的 RPC 接口,失败时弹出错误消息框。 + +4. **`GetAnalysisResultAsync`**:核心实现 —— 使用 `using var call = _client!.GetAnalysisResult(request)` 发起服务端流式调用,通过 `call.ResponseStream.ReadAllAsync()` 逐条读取响应: + - 响应 `Status.Success == false` 时:直接添加一条错误消息到 `ResultEntries` 并返回。 + - `PayloadCase == Header` 时:构造 `headerFields`(FileName、State、WorkerId),若 `HasErrorMessage` 则提取错误消息,若 `State == NotAnalyzed` 则显示"Not analyzed yet."。 + - `PayloadCase == LogEntry` 时:通过 `GrpcTypeConverter.ConvertFromGrpc` 转换,用 `KeyValueVisitor.Dump` 输出键值对,添加到 `ResultEntries`。 + +#### `Views/MainView.axaml` — All 按钮 + +在 `Analyze` 区域右侧新增 `All` 按钮,绑定到 `AnalyzeAllCommand`,并将菜单区域 Grid 从 4 列扩展为 5 列以保证布局对齐。 + +#### 工厂方法模式(IClientFactory) + +`LogAnalyzerClient.Desktop/Program.cs` 中的 `ClientFactory` 实现了 `IClientFactory` 接口,通过 `GrpcChannel.ForAddress` 创建 gRPC 通道。在上层 `AppService.ClientFactory` 注册,实现了「创建 gRPC 客户端」这一逻辑的工厂方法模式,便于后续扩展(如 Browser 平台使用 `GrpcWebHandler` 创建客户端)。 +## 功能记录 + +由于本验证环境为无人值守的终端环境,无法以 GUI 截图方式展示完整交互流程,以下通过 **无头 ViewModel 验证程序** 的输出记录全部功能流程。该验证程序通过 `DispatchProxy` 注入假 `IDialogHelper`,并直接连接真实 Agent 进行端到端测试。 + +### 验证环境准备 + +```powershell +# 启动 Agent(监听 http://localhost:7777,日志目录为 src/dataset) +$env:ASPNETCORE_URLS='http://localhost:7777' +dotnet run --project src/LogAnalyzerAgent +``` + +### 验证输出 + +``` +[1] ConnectStatus = 'Connected.' + PASS: connect +[2] ChangeDirectory success=True code=NoAgentError + PASS: change directory +[3] LogFiles = [basic-fail.log, basic-multiple.log, basic.log] + PASS: refresh lists 3 files +[4] AnalyzeAll dialogs = [] + PASS: analyze all reports no error +[5] basic.log entries = 4 + [0] FileName=basic.log, State=Succeeded, WorkerId=0 + [1] LineNo=0, Timestamp=2026-06-05T16:00:29.0450000+00:00, PodName=userservice-0, Severity=Info, EventType=Call, RequestId=3a013a08-6853-49fc-8f06-50daeb5c1e51, TargetService=authservice, DurationMs=18 + [2] LineNo=1, Timestamp=2026-06-05T16:00:31.0860000+00:00, PodName=userservice-1, Severity=Info, EventType=Request, RequestId=1177c344-115e-4f85-b8ec-c9164d132b79, Method=GET, Path=/api/user/john, StatusCode=404 + [3] LineNo=2, Timestamp=2026-06-05T16:05:45.3220000+00:00, PodName=gateway-0, Severity=Error, EventType=Internal, ExceptionName=System.InvalidOperationException, ExceptionMessage=Failed to load gateway routing configuration. + PASS: basic.log -> header + 3 entries + PASS: header summary format + PASS: entry summary format +[5b] basic-fail.log entries = 1 + [0] FileName=basic-fail.log, State=Failed, WorkerId=0 | JSON deserialization for type '...' was missing required properties including: 'method'. + PASS: basic-fail.log -> single header entry + PASS: failed header shows error message +[6] nonexist file entries = 1, dialogs = [] + PASS: nonexist file -> single error entry +ALL PASS +``` + +### 功能点对照 + +| 功能 | 验证结果 | 对应命令 | +|------|---------|---------| +| 连接 Agent(Connect) | ✅ ConnectStatus = 'Connected.' | `ConnectCommand` | +| 切换目录(Change Directory) | ✅ success=True | —(直接 gRPC 调用) | +| 刷新文件列表(Refresh) | ✅ 列出 3 个文件 | `RefreshCommand` | +| 分析全部文件(Analyze All) | ✅ 无错误反馈 | `AnalyzeAllCommand` | +| 查询分析结果(Get Analysis Result) | ✅ basic.log 返回 header + 3 条 entry | `GetAnalysisResultCommand` | +| 分析失败文件(basic-fail.log) | ✅ 返回 1 条 header,含 State=Failed 与错误信息 | `GetAnalysisResultCommand` | +| 查询不存在的文件 | ✅ 返回 1 条错误 entry | `GetAnalysisResultCommand` | + +## 鲁棒性测试记录 + +### 1. 查询不存在的文件 + +通过 `GetAnalysisResultCommand` 查询 `no-such.log`,Agent 返回 `FileNotFound` 状态,ViewModel 检测到 `Status.Success == false` 后添加一条错误 entry 至 `ResultEntries`。验证输出显示 `ResultEntries.Count == 1`,无错误对话框弹出(因为 `GetAnalysisResult` 的流式响应中,第一条响应的 Status 不成功,直接返回错误 entry,不弹出消息框)。 + +### 2. 分析失败文件(basic-fail.log) + +`basic-fail.log` 是故意构造的坏日志文件(缺少 `method` 字段)。Agent 分析报告 `State=Failed`,header 中包含 `ErrorMessage`。ViewModel 的 header 分支正确提取 `HasErrorMessage` 并在 `ResultEntries` 中显示一条带有 `State=Failed` 和错误描述的 entry。 + +### 3. 分析全部文件(Analyze All) + +`AnalyzeAllCommand` 调用 `AnalyzeAll` RPC 后,Agent 返回成功状态,ViewModel 无错误对话框弹出。验证程序确认 `DialogProxy.Messages` 为空。 + +### 4. GUI 启动冒烟测试 + +GUI 程序启动后显示主窗口,在无人值守环境中保持存活 8 秒后正常关闭,无崩溃或异常退出。 + +## (Q4.1) + +**你认为,你在开发 GUI 应用程序,与你在以往开发控制台应用程序的区别在哪里?GUI 应用程序的开发存在哪些额外的难点?存在哪些额外的复杂之处?你是否有通过编写 GUI 应用程序对异步 `async` 和 `await` 有了更进一步的理解?异步编程是否又给你带来了额外的困扰?说说你的看法。** + +开发 GUI 应用程序与开发控制台应用程序的主要区别在于以下几点: + +1. **程序执行模型的不同**:控制台程序是「线性流程」——从上到下执行,遇到 `Console.ReadLine()` 时阻塞等待用户输入。而 GUI 程序是「事件驱动」——用户点击按钮、选择菜单项等操作触发对应的事件处理函数(Command)。这种模型要求开发者将程序逻辑拆分为离散的「命令」或「操作」,而不是写一个连续的流程。 + +2. **UI 线程不能阻塞**:这是最核心的区别。GUI 的渲染和用户交互响应都在 UI 线程上执行,任何耗时操作(如网络请求)如果在 UI 线程上同步执行,都会导致界面「卡死」——窗口无法拖动、点击无响应,用户体验极差。因此,GUI 程序中的任何网络请求、文件操作等都必须使用异步方式(`async/await`)。 + +3. **MVVM 模式带来的额外抽象层**:控制台程序可以直接调用函数、打印输出;GUI 程序则需要通过 ViewModel 的 `[ObservableProperty]` 和 `[RelayCommand]` 来桥接 View(XAML)和 Model(数据/业务逻辑)。这意味着需要编写更多代码来维护数据绑定,但也带来了更好的可测试性和关注点分离。 + +4. **状态管理更复杂**:控制台程序的状态通常是一个循环中的变量;GUI 程序的状态分散在多个控件的属性中(如 `SelectedLogFile`、`DirectoryPath`、`ConnectStatus`),且需要保持一致性——例如连接成功后需更新状态栏、清空文件列表等。 + +5. **`async/await` 的理解**:通过编写 GUI 程序,我对 `async/await` 的理解更加深入。在控制台程序中,`async` 方法即使不 `await` 也可以工作(只是不等待结果),但在 GUI 中,不 `await` 意味着 UI 线程会继续执行后续代码,可能导致数据未加载完成就尝试绑定的问题。此外,`async void` 事件处理程序的异常处理方式也与 `async Task` 不同——`async void` 中的异常会直接导致进程崩溃,因此必须确保所有 Command 方法都返回 `Task` 而非 `void`。 + +6. **异步编程的额外困扰**:异步编程确实带来了额外的调试复杂度——当多个异步操作并发执行时(如同时分析多个文件),追踪执行顺序和异常来源变得困难。此外,`CommunityToolkit.Mvvm` 生成的 `IAsyncRelayCommand` 虽然简化了「异步命令」的绑定,但理解其背后的 `CanExecute` 状态管理和并发控制仍需要一定学习成本。 + +## (Q4.2.b) + +**本次作业中,你是否使用了 AI?** + +是,我使用了 AI(Cline 编码助手)。 + +### 我给予 AI 的提示词 + +> 阅读 `docs/04-avalonia` 的说明文档、`src/LogAnalyzerClient` 的代码框架,以及 `src/LogAnalyzerRpc` 的 Protobuf 定义,补齐 `RemoteModels.cs` 的 `Summary` 属性、`MainViewModel.cs` 中的命令实现(`RefreshAsync`、`AnalyzeSelectedFilesAsync`、`AnalyzeAllAsync`、`AnalyzeRightClickedFileAsync`、`GetAnalysisResultAsync`),以及 `MainView.axaml` 中的 All 按钮,最终完成 `docs/04-avalonia/report.md`。 + +### 对 AI 的使用方式 + +主要是让 AI 编写作业代码——实现 `RemoteModels.cs` 的 `Summary` 属性、`MainViewModel.cs` 中所有命令方法,以及 `MainView.axaml` 的 All 按钮布局。同时也让 AI 进行了无头 ViewModel 验证(通过 `DispatchProxy` 注入假 `IDialogHelper` 进行端到端测试),并让 AI 撰写了本报告。 + +### AI 的解答是否出现过错误 + +在初版实现中,AI 没有出现功能性错误,代码一次通过测试。但在验证过程中发现了一个 UI 设计细节问题: + +- `LogFields.Summary` 在 `ErrorMessage` 非空时只显示错误消息,丢弃了 `Fields`(如 `FileName`、`State`、`WorkerId`)。这导致查询失败文件时,用户只能看到错误文本,看不到文件状态信息。经调整后,AI 改为错误时同时显示 `Fields` 键值对与错误消息,用 ` | ` 分隔。 + +### 从 AI 那里得知的新知识 + +1. **`DispatchProxy` 用于 mock `internal` 接口**:由于 `IDialogHelper` 是 `internal` 类型,外部测试程序无法直接实现该接口。AI 通过 `System.Reflection.DispatchProxy.Create` 在运行时为接口动态创建代理,无需 `InternalsVisibleTo` 属性即可实现无头 ViewModel 测试。 + +2. **`[ObservableProperty]` 的 `field` 关键字冲突**:在 `RemoteModels.cs` 的 `Summary` 属性中,最初的 lambda 参数名为 `field`,但 C# 14 将 `field` 作为 `[ObservableProperty]` 上下文关键字。AI 遇到编译错误后,将参数名改为 `f` 解决了冲突。 + +3. **Avalonia UI 的编译绑定**:`AvaloniaUseCompiledBindingsByDefault` 为 `true` 时,XAML 中的数据绑定在编译时检查,减少了运行时绑定错误。这与 WPF 的运行时绑定不同,需要在 XAML 中显式指定 `x:DataType` 以支持编译时验证。 \ 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..db38343 --- /dev/null +++ b/docs/05-advanced/report.md @@ -0,0 +1,27 @@ +# 05-advanced 实验报告 + +## T5.1 实现的功能 + +### T5.1.a 客户端日志查询与排序(DataGrid + 筛选/排序工具栏) + +... + +### T5.1.c Severity 彩色标签(Pill) + +... + +## 运行截图 + + + +## 功能验证记录 + +... + +## (Q5.x) 问答题 + +... + +## 使用 AI 辅助情况 + +... diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..a8ce119 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,100 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var logFiles = analyzer.GetLogFiles(); + if (logFiles.Count == 0) + { + Console.WriteLine("There are no log files in the current directory."); + return; + } + + Console.WriteLine("Log files in the current directory:"); + 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 to analyze (separated by commas):"); + Console.Write(">>> "); + Console.Out.Flush(); + var input = Console.ReadLine(); + if (string.IsNullOrEmpty(input)) + { + return; + } + + var fileNames = input.Split(',') + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("Invalid input, please try again."); + return; + } + + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {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($"Analysis failed: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input a file name to query:"); + Console.Write(">>> "); + Console.Out.Flush(); + var fileName = Console.ReadLine(); + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + Console.WriteLine($"No analysis result found for '{fileName}'."); + 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 kvResult = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", kvResult.Select(pair => $"{pair.Key}={pair.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis of '{fileName}' failed: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..847585a 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -141,7 +141,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) /* * Set _isAnalyzing */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -154,7 +154,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) * Unset _isAnalyzing * Remember to lock _syncRoot to prevent data race */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -169,7 +172,18 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * 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 existingResult)) + { + throw new InvalidOperationException($"File '{file.Name}' is unknown to the analyzer."); + } + + // Skip files that have already been analyzed. + if (existingResult.State != AnalysisState.NotAnalyzed) + { + continue; + } + + logFilesToParse.Add(file); } } @@ -183,7 +197,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * 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]; @@ -194,13 +212,21 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * 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) @@ -213,19 +239,42 @@ private void WorkerMain(int workerId, WorkQueue queue) 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..97b9d72 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,49 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue after CompleteAdding has been called."); + } + + _items.Enqueue(item); + // Wake up one (or all) waiting consumer(s). + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + // Wait while the queue is empty and adding is not yet complete. + // A while loop is required to handle spurious wakeups (MESA model). + 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; + // Wake up all consumers so they can notice completion and exit. + Monitor.PulseAll(_items); + } } } } diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..7775b54 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 @@ -79,22 +80,292 @@ 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 (string.IsNullOrEmpty(request.DirectoryPath)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Directory path cannot be empty."); + return Task.FromResult(response); + } + + if (!_analyzer.ChangeDirectory(request.DirectoryPath)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.DirectoryNotFound, + $"Directory '{request.DirectoryPath}' does not exist."); + 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"); + var response = new AnalyzeAllResponse(); + try + { + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No directory has been set yet."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + 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"); + var response = new AnalyzeFilesResponse(); + try + { + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No directory has been set yet."); + return Task.FromResult(response); + } + + if (request.FileNames.Count == 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "No file names are specified."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.FileNotFound, ex.Message); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.InvalidOperation, ex.Message); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(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) || result is null) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + $"File '{request.FileName}' is not found.") + }); + 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() + }); + + foreach (var entry in result.Entries) + { + responses.Add(new GetAnalysisResultResponse() + { + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + Status = CreateNoErrorOperationStatus() + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + return new List() + { + new GetAnalysisResultResponse() + { + Status = CreateInternalErrorOperationStatus(ex) + } + }; + } + return responses; + } + + public QueryLogEntriesResponse QueryLogEntries(QueryLogEntriesRequest request, CancellationToken cancellationToken) + { + var response = new QueryLogEntriesResponse(); + try + { + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result) || result is null) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + $"File '{request.FileName}' is not found."); + return response; + } + + if (result.State != AnalysisState.Succeeded) + { + var reason = result.State == AnalysisState.Failed && result.ErrorMessage is not null + ? $" (reason: {result.ErrorMessage})" + : string.Empty; + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + $"File '{request.FileName}' has not been successfully analyzed. State = {result.State}.{reason}"); + return response; + } + + var filter = new LogEntryQueryFilter + { + EventType = request.HasEventType ? GrpcTypeConverter.ConvertFromGrpc(request.EventType) : null, + Severity = request.HasSeverity ? GrpcTypeConverter.ConvertFromGrpc(request.Severity) : null, + ServiceName = string.IsNullOrWhiteSpace(request.ServiceName) ? null : request.ServiceName.Trim(), + RequestId = string.IsNullOrWhiteSpace(request.RequestId) ? null : request.RequestId.Trim(), + StartTime = request.StartTime?.ToDateTimeOffset(), + EndTime = request.EndTime?.ToDateTimeOffset(), + }; + + var matched = new List(); + foreach (var entry in result.Entries) + { + if (filter.IsMatch(entry)) + { + matched.Add(entry); + } + } + + response.TotalCount = result.Entries.Count; + response.MatchedCount = matched.Count; + response.Status = CreateNoErrorOperationStatus(); + foreach (var entry in matched) + { + response.LogEntries.Add(GrpcTypeConverter.ConvertToGrpc(entry)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while querying log entries."); + response.Status = CreateInternalErrorOperationStatus(ex); + } + return response; + } + + /// + /// Filters a parsed against a set of query conditions. + /// A null condition means "no constraint"; all set conditions must match. + /// + private sealed class LogEntryQueryFilter + { + public LogEventType? EventType { get; init; } + + public LogSeverity? Severity { get; init; } + + /// Prefix of the producing service/pod name. + public string? ServiceName { get; init; } + + public string? RequestId { get; init; } + + public DateTimeOffset? StartTime { get; init; } + + public DateTimeOffset? EndTime { get; init; } + + public bool IsMatch(LogEntry entry) + { + if (EventType is { } eventType && entry.EventType != eventType) + { + return false; + } + + if (Severity is { } severity && entry.Severity != severity) + { + return false; + } + + if (ServiceName is not null + && !entry.PodName.StartsWith(ServiceName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (RequestId is not null) + { + var entryRequestId = entry switch + { + CallLogEntry call => call.RequestId, + RequestLogEntry request => request.RequestId, + _ => null, + }; + if (!string.Equals(entryRequestId, RequestId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + if (StartTime is not null && entry.Timestamp < StartTime.Value) + { + return false; + } + + if (EndTime is not null && entry.Timestamp > EndTime.Value) + { + return false; + } + + return true; + } + } + + private static OperationStatusMessage CreateErrorOperationStatus(AgentErrorCode code, string message) + { + return new OperationStatusMessage() + { + Success = false, + Code = code, + Message = message, + }; } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..14be662 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,36 @@ 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 Task.FromResult(_session.QueryLogEntries(request, context.CancellationToken)); } } } diff --git a/src/LogAnalyzerClient/Directory.Packages.props b/src/LogAnalyzerClient/Directory.Packages.props index 8c9efe7..0856df1 100644 --- a/src/LogAnalyzerClient/Directory.Packages.props +++ b/src/LogAnalyzerClient/Directory.Packages.props @@ -6,12 +6,13 @@ - - - + + + + - - + + diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/App.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/App.axaml index 1e08725..76b1847 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/App.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/App.axaml @@ -11,6 +11,7 @@ + \ No newline at end of file diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Converters/SeverityBrushConverter.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Converters/SeverityBrushConverter.cs new file mode 100644 index 0000000..be6a756 --- /dev/null +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Converters/SeverityBrushConverter.cs @@ -0,0 +1,37 @@ +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Media; +using System; +using System.Globalization; + +namespace LogAnalyzerClient.Converters +{ + /// + /// Converts a severity display string ("Info" / "Warning" / "Error") to the + /// background brush of the severity "pill" rendered in the results table: + /// Info -> blue, Warning -> orange, Error -> red. + /// + public sealed class SeverityBrushConverter : IValueConverter + { + private static readonly IBrush InfoBrush = new SolidColorBrush(Color.Parse("#1565C0")); + private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#EF6C00")); + private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#C62828")); + private static readonly IBrush UnknownBrush = new SolidColorBrush(Color.Parse("#757575")); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return (value as string)?.ToLowerInvariant() switch + { + "info" => InfoBrush, + "warning" => WarningBrush, + "error" => ErrorBrush, + _ => UnknownBrush, + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/LogAnalyzerClient.csproj b/src/LogAnalyzerClient/LogAnalyzerClient/LogAnalyzerClient.csproj index 252994b..0cf3730 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/LogAnalyzerClient.csproj +++ b/src/LogAnalyzerClient/LogAnalyzerClient/LogAnalyzerClient.csproj @@ -13,6 +13,7 @@ + diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Models/LogEntryRow.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/LogEntryRow.cs new file mode 100644 index 0000000..7a373c0 --- /dev/null +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/LogEntryRow.cs @@ -0,0 +1,182 @@ +using LogParser.Models; +using LogParser.Visitors; +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace LogAnalyzerClient.Models +{ + /// + /// A flat, display-oriented row model backed by a single parsed log entry. + /// Columns that do not apply to the entry type (e.g. Method on a + /// ) are rendered as empty strings. + /// + public sealed class LogEntryRow + { + private static readonly KeyValueVisitor Visitor = new(); + + public LogEntryRow( + int lineNo, + string timestamp, + string podName, + string severity, + string eventType, + string requestId, + string targetService, + string durationMs, + string method, + string path, + string statusCode, + string exceptionName, + string exceptionMessage) + { + LineNo = lineNo; + Timestamp = timestamp; + PodName = podName; + Severity = severity; + EventType = eventType; + RequestId = requestId; + TargetService = targetService; + DurationMs = durationMs; + Method = method; + Path = path; + StatusCode = statusCode; + ExceptionName = exceptionName; + ExceptionMessage = exceptionMessage; + } + + public int LineNo { get; } + + /// Raw ISO 8601 timestamp string (used as the sort key). + public string Timestamp { get; } + + public string PodName { get; } + + public string Severity { get; } + + public string EventType { get; } + + public string RequestId { get; } + + public string TargetService { get; } + + public string DurationMs { get; } + + public string Method { get; } + + public string Path { get; } + + public string StatusCode { get; } + + public string ExceptionName { get; } + + public string ExceptionMessage { get; } + + public static LogEntryRow FromEntry(LogEntry entry) + { + var kv = Visitor.Dump(entry); + string Get(string key) => kv.TryGetValue(key, out var value) ? value : string.Empty; + return new LogEntryRow( + lineNo: int.TryParse(Get("LineNo"), out var lineNo) ? lineNo : -1, + timestamp: Get("Timestamp"), + podName: Get("PodName"), + severity: Get("Severity"), + eventType: Get("EventType"), + requestId: Get("RequestId"), + targetService: Get("TargetService"), + durationMs: Get("DurationMs"), + method: Get("Method"), + path: Get("Path"), + statusCode: Get("StatusCode"), + exceptionName: Get("ExceptionName"), + exceptionMessage: Get("ExceptionMessage")); + } + } + + /// + /// Builds the comparer used by the GUI "sort by" feature. Any column key listed in + /// can be sorted either ascending or descending. + /// + public static class LogRowSort + { + public static IReadOnlyList Keys { get; } = new[] + { + "LineNo", "Timestamp", "PodName", "Severity", "EventType", "RequestId", + "TargetService", "DurationMs", "Method", "Path", "StatusCode", + "ExceptionName", "ExceptionMessage", + }; + + public static IComparer CreateComparer(string sortKey, bool descending) + { + var comparer = Comparer.Create((a, b) => descending + ? CompareByKey(b, a, sortKey) + : CompareByKey(a, b, sortKey)); + return comparer; + } + + private static int CompareByKey(LogEntryRow a, LogEntryRow b, string key) + { + return key switch + { + "LineNo" => a.LineNo.CompareTo(b.LineNo), + "Severity" => SeverityRank(a.Severity).CompareTo(SeverityRank(b.Severity)), + "DurationMs" => CompareNullableInt(a.DurationMs, b.DurationMs), + "StatusCode" => CompareNullableInt(a.StatusCode, b.StatusCode), + "Timestamp" => CompareTimestamp(a.Timestamp, b.Timestamp), + "EventType" => string.CompareOrdinal(a.EventType, b.EventType), + _ => string.Compare(GetValue(a, key), GetValue(b, key), StringComparison.OrdinalIgnoreCase), + }; + } + + private static string GetValue(LogEntryRow row, string key) => key switch + { + "LineNo" => row.LineNo.ToString(CultureInfo.InvariantCulture), + "Timestamp" => row.Timestamp, + "PodName" => row.PodName, + "Severity" => row.Severity, + "EventType" => row.EventType, + "RequestId" => row.RequestId, + "TargetService" => row.TargetService, + "DurationMs" => row.DurationMs, + "Method" => row.Method, + "Path" => row.Path, + "StatusCode" => row.StatusCode, + "ExceptionName" => row.ExceptionName, + "ExceptionMessage" => row.ExceptionMessage, + _ => string.Empty, + }; + + private static int SeverityRank(string severity) => severity.ToLowerInvariant() switch + { + "info" => 0, + "warning" => 1, + "error" => 2, + _ => int.MaxValue, + }; + + private static int CompareNullableInt(string x, string y) + { + var xParsed = int.TryParse(x, out var xValue); + var yParsed = int.TryParse(y, out var yValue); + if (xParsed && yParsed) + { + return xValue.CompareTo(yValue); + } + if (xParsed != yParsed) + { + return xParsed ? 1 : -1; + } + return string.CompareOrdinal(x, y); + } + + private static int CompareTimestamp(string x, string y) + { + if (DateTimeOffset.TryParse(x, CultureInfo.InvariantCulture, DateTimeStyles.None, out var xTime) + && DateTimeOffset.TryParse(y, CultureInfo.InvariantCulture, DateTimeStyles.None, out var yTime)) + { + return xTime.CompareTo(yTime); + } + return string.CompareOrdinal(x, y); + } + } +} diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..147333e 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -11,7 +11,19 @@ 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 + { + var fields = string.Join(", ", Fields.Select(f => $"{f.Key}={f.Value}")); + if (ErrorMessage is not null) + { + return $"[{Index}] {(fields.Length > 0 ? fields + " | " : "")}{ErrorMessage}"; + } + + return $"[{Index}] {fields}"; + } + } } 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..a12f65d 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Styles/Controls.axaml @@ -47,4 +47,17 @@ + + + + diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.Advanced.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.Advanced.cs new file mode 100644 index 0000000..a5aad89 --- /dev/null +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.Advanced.cs @@ -0,0 +1,270 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Google.Protobuf.WellKnownTypes; +using LogAnalyzerClient.Models; +using LogAnalyzerRpc; +using LogAnalyzerRpc.Protos; +using LogParser.Models; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; + +namespace LogAnalyzerClient.ViewModels +{ + public partial class MainViewModel + { + public const string FilterAny = "(Any)"; + + public IReadOnlyList EventTypeFilterOptions { get; } = + new[] { FilterAny, "Call", "Request", "Internal" }; + + public IReadOnlyList SeverityFilterOptions { get; } = + new[] { FilterAny, "Info", "Warning", "Error" }; + + public IReadOnlyList SortKeyOptions { get; } = LogRowSort.Keys; + + [ObservableProperty] + private ObservableCollection _resultRows = new(); + + [ObservableProperty] + private string _resultInfo = "No analysis result yet. Select a file and click \"View Analysis Results\"."; + + [ObservableProperty] + private string _statsText = ""; + + // ---- Sort settings ---- + [ObservableProperty] + private string _selectedSortKey = LogRowSort.Keys[0]; + + [ObservableProperty] + private bool _sortDescending = false; + + // ---- Query (filter) settings ---- + [ObservableProperty] + private string _selectedEventTypeFilter = FilterAny; + + [ObservableProperty] + private string _selectedSeverityFilter = FilterAny; + + [ObservableProperty] + private string _serviceNameFilter = ""; + + [ObservableProperty] + private string _requestIdFilter = ""; + + [ObservableProperty] + private string _startTimeFilter = ""; + + [ObservableProperty] + private string _endTimeFilter = ""; + + [RelayCommand] + private async Task QueryLogsAsync() + { + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No file is selected."); + return; + } + + if (!TryBuildQueryRequest(out var request, out var errorMessage)) + { + await DialogHelper.ShowMessageDialogAsync("Invalid query", errorMessage); + return; + } + + var response = await _client!.QueryLogEntriesAsync(request); + if (!response.Status.Success) + { + ShowQueryError($"{response.Status.Code}: {response.Status.Message}"); + await DialogHelper.ShowMessageDialogAsync("Query failed", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + var rows = response.LogEntries + .Select(entryMessage => LogEntryRow.FromEntry(GrpcTypeConverter.ConvertFromGrpc(entryMessage))) + .ToList(); + ShowRows(rows, $"{SelectedLogFile.FileName} · query result · matched {rows.Count} of {response.TotalCount} entries"); + }); + } + + [RelayCommand] + private void ApplySort() + { + if (ResultRows.Count == 0) + { + return; + } + // ShowRows re-applies the current sort settings and refreshes the statistics. + ShowRows(ResultRows.ToList(), ResultInfo); + } + + [RelayCommand] + private void ResetFilters() + { + SelectedEventTypeFilter = FilterAny; + SelectedSeverityFilter = FilterAny; + ServiceNameFilter = string.Empty; + RequestIdFilter = string.Empty; + StartTimeFilter = string.Empty; + EndTimeFilter = string.Empty; + } + + private void ClearResultRows() + { + ResultRows.Clear(); + StatsText = string.Empty; + } + + private void ShowQueryError(string message) + { + ResultRows.Clear(); + ResultInfo = message; + StatsText = string.Empty; + } + + private void ShowRows(IReadOnlyCollection rows, string info) + { + var sorted = rows.OrderBy(row => row, LogRowSort.CreateComparer(SelectedSortKey, SortDescending)).ToList(); + ResultRows.Clear(); + foreach (var row in sorted) + { + ResultRows.Add(row); + } + ResultInfo = info; + UpdateStats(); + } + + private void UpdateStats() + { + var infoCount = 0; + var warningCount = 0; + var errorCount = 0; + var callCount = 0; + var requestCount = 0; + var internalCount = 0; + foreach (var row in ResultRows) + { + switch (row.Severity) + { + case "Info": infoCount++; break; + case "Warning": warningCount++; break; + case "Error": errorCount++; break; + } + switch (row.EventType) + { + case "Call": callCount++; break; + case "Request": requestCount++; break; + case "Internal": internalCount++; break; + } + } + StatsText = $"Total {ResultRows.Count} Severity: Info {infoCount} / Warning {warningCount} / Error {errorCount} Event type: Call {callCount} / Request {requestCount} / Internal {internalCount}"; + } + + private bool TryBuildQueryRequest(out QueryLogEntriesRequest request, out string errorMessage) + { + request = new QueryLogEntriesRequest + { + FileName = SelectedLogFile!.FileName, + }; + errorMessage = string.Empty; + + if (SelectedEventTypeFilter != FilterAny) + { + if (!TryParseEventType(SelectedEventTypeFilter, out var eventType)) + { + errorMessage = $"Invalid event type: {SelectedEventTypeFilter}"; + return false; + } + request.EventType = eventType; + } + + if (SelectedSeverityFilter != FilterAny) + { + if (!TryParseSeverity(SelectedSeverityFilter, out var severity)) + { + errorMessage = $"Invalid severity: {SelectedSeverityFilter}"; + return false; + } + request.Severity = severity; + } + + var serviceName = ServiceNameFilter?.Trim(); + if (!string.IsNullOrEmpty(serviceName)) + { + request.ServiceName = serviceName; + } + + var requestId = RequestIdFilter?.Trim(); + if (!string.IsNullOrEmpty(requestId)) + { + request.RequestId = requestId; + } + + if (!string.IsNullOrWhiteSpace(StartTimeFilter)) + { + if (!DateTimeOffset.TryParse(StartTimeFilter, out var startTime)) + { + errorMessage = $"Invalid start time: \"{StartTimeFilter}\". Use an ISO 8601 value such as 2026-06-05T16:00:00Z."; + return false; + } + request.StartTime = startTime.ToUniversalTime().ToTimestamp(); + } + + if (!string.IsNullOrWhiteSpace(EndTimeFilter)) + { + if (!DateTimeOffset.TryParse(EndTimeFilter, out var endTime)) + { + errorMessage = $"Invalid end time: \"{EndTimeFilter}\". Use an ISO 8601 value such as 2026-06-05T16:03:00Z."; + return false; + } + request.EndTime = endTime.ToUniversalTime().ToTimestamp(); + } + + return true; + } + + private static bool TryParseEventType(string text, out LogEventTypeEnum eventType) + { + switch (text.Trim()) + { + case "Call": + eventType = LogEventTypeEnum.Call; + return true; + case "Request": + eventType = LogEventTypeEnum.Request; + return true; + case "Internal": + eventType = LogEventTypeEnum.Internal; + return true; + default: + eventType = LogEventTypeEnum.Call; + return false; + } + } + + private static bool TryParseSeverity(string text, out LogSeverityEnum severity) + { + switch (text.Trim()) + { + case "Info": + severity = LogSeverityEnum.Info; + return true; + case "Warning": + severity = LogSeverityEnum.Warning; + return true; + case "Error": + severity = LogSeverityEnum.Error; + return true; + default: + severity = LogSeverityEnum.Info; + return false; + } + } + } +} diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs index 91c05a8..585c4e9 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -7,7 +7,7 @@ using LogAnalyzerClient.Services; using LogAnalyzerRpc; using LogAnalyzerRpc.Protos; -using LogParser.Visitors; +using LogParser.Models; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -53,9 +53,6 @@ private static class ConnectStatusString [ObservableProperty] private LogFileItem? _selectedLogFile = null; - [ObservableProperty] - private ObservableCollection _resultEntries = new(); - [RelayCommand] private async Task ConnectAsync() { @@ -132,31 +129,182 @@ 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)); + } }); } + private bool TryReadDegreeOfParallelism(out int degree) + { + if (int.TryParse(DegreeOfParallelismText, out degree) && degree >= 0) + { + return true; + } + degree = -1; + return false; + } + [RelayCommand] private async Task AnalyzeSelectedFilesAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedFiles.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "No files are selected."); + return; + } + if (!TryReadDegreeOfParallelism(out var degree)) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Invalid degree of parallelism."); + return; + } + + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree, + }; + request.FileNames.AddRange(SelectedFiles); + + 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 () => + { + if (!TryReadDegreeOfParallelism(out var degree)) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Invalid degree of parallelism."); + return; + } + + var request = new AnalyzeAllRequest() + { + DegreeOfParallelism = degree, + }; + + var response = await _client!.AnalyzeAllAsync(request); + 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 is selected."); + return; + } + if (!TryReadDegreeOfParallelism(out var degree)) + { + await DialogHelper.ShowMessageDialogAsync("Error", "Invalid degree of parallelism."); + return; + } + + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree, + }; + 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 is selected."); + return; + } + + var fileName = SelectedLogFile.FileName; + var request = new GetAnalysisResultRequest() + { + FileName = fileName, + }; + + ClearResultRows(); + + using var call = _client!.GetAnalysisResult(request); + var header = null as AnalysisResultHeaderMessage; + var loadedEntries = new List(); + await foreach (var response in call.ResponseStream.ReadAllAsync()) + { + if (!response.Status.Success) + { + ShowQueryError(response.Status.Message); + return; + } + + switch (response.PayloadCase) + { + case GetAnalysisResultResponse.PayloadOneofCase.Header: + header = response.Header; + break; + case GetAnalysisResultResponse.PayloadOneofCase.LogEntry: + loadedEntries.Add(GrpcTypeConverter.ConvertFromGrpc(response.LogEntry)); + break; + } + } + + if (header is null) + { + ShowQueryError("The agent returned no analysis result header."); + return; + } + + if (header.State != AnalysisStateEnum.Succeeded) + { + var errorText = header.HasErrorMessage + ? header.ErrorMessage + : header.State == AnalysisStateEnum.NotAnalyzed + ? "The file has not been analyzed yet." + : $"File analysis state = {header.State}."; + ShowQueryError($"{fileName} · {header.State} · {errorText}"); + return; + } + + var rows = loadedEntries + .Select(LogEntryRow.FromEntry) + .ToList(); + ShowRows(rows, $"{fileName} · Succeeded · Worker {header.WorkerId} · {rows.Count} of {rows.Count} entries"); + }); } [RelayCommand] diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..86cddff 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 - +