commit 26620707a0b06195c317624d6d1eee54957a87b5 Author: JustMe Date: Sun Oct 4 21:46:39 2026 +0800 chore: initial project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7833fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +artifacts/ +bin/ +obj/ +*.user +.vs/ +data/ +*.db +*.db-wal +*.db-shm +TestResults/ +.idea/ +__pycache__/ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ef7dba --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Monitor + +Monitor 是一个自托管的网络线路质量观测工具。它在 Probe 与多个 Agent 之间执行主动网络测量,记录逐包和逐事件结果;Backend 保存原始数据并生成可查询的线路指标,Web 界面用于查看、比较和管理节点。 + +它面向需要比较不同网络出口、评估线路稳定性,或观察特定网络服务体验的场景。Agent 与 Probe 专注于测量和上报;它们不提供远程终端、命令执行或文件管理能力。 + +## 能力概览 + +- **主动测量**:组合 TCP、UDP 等测量模块,采集延迟、丢包、抖动、吞吐和连接事件。 +- **线路分析**:从原始测量数据生成时间序列指标,并比较线路在不同服务模型下的体验。 +- **节点管理**:通过 Fleet 节点框架处理身份、注册、连接、任务同步和数据上报。 +- **实时面板**:展示端查看线路状态和历史趋势;管理端负责节点、任务和系统配置。 +- **自托管**:Backend 使用段文件和 SQLite 保存数据;提供 Linux、systemd 和 OpenWrt 相关部署文件。 + +## 架构 + +```text +Probe <──测量会话──> Agent ──长连接与数据上报──> Backend <──REST / SSE──> Web 展示端与管理端 + ├── 段文件:原始数据 + └── SQLite:索引、指标与配置 +``` + +## 快速开始 + +### 环境要求 +[README.md](backend/README.md) +- .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。 +- Node.js 与 npm,用于构建 Web 前端。 + +### 构建 + +从仓库根目录构建后端: + +```sh +cd backend +dotnet build Monitor.slnx -c Release +``` + +安装前端依赖并构建展示端和管理端(在仓库根目录运行): + +```sh +npm ci --prefix web +npm run build --prefix web +``` + +前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 发布还需要对应平台的 C/C++ 工具链;Linux 发布脚本和 RID 说明见 [`docs/deployment.md`](docs/deployment.md)。 + +### 本地运行 + +1. **启动 Backend。** 在仓库根目录运行: + +```sh +cd backend +dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000 +``` + +2. **启动前端。** 保持 Backend 运行,在另一个终端的仓库根目录运行: + +```sh +npm ci --prefix web +npm run dev --prefix web +``` + +打开 使用展示端,或访问 进入管理端。依赖安装和前端配置见 [`web/README.md`](web/README.md);Backend 的首次启动与配置见 [`backend/README.md`](backend/README.md)。 + +## 项目布局 + +| 路径 | 说明 | +| --- | --- | +| [`backend/`](backend/README.md) | Backend 服务、Fleet 框架、共享测量代码与 .NET 测试 | +| [`backend/agent/`](backend/agent/README.md) | Agent 节点:注册到 Backend,接受 Probe 测量并上报数据 | +| [`backend/probe/`](backend/probe/README.md) | Probe 节点:发起线路测量并将采集数据交给 Agent | +| `backend/fleet/` | 可复用的节点身份、注册和连接框架 | +| `backend/shared/` | 测量引擎、协议和原始数据格式 | +| `web/display/`、`web/admin/` | 展示端和管理端 | +| `web/shared/` | 前端 API、图表、组件与主题 | +| `packaging/`、`scripts/` | 部署、构建与维护脚本 | +| `docs/` | [架构](docs/architecture.md)、[测量模型](docs/measurement.md)、[API](docs/api.md)、[部署](docs/deployment.md)等文档 | + +## 测试 + +```sh +cd backend +dotnet test --solution Monitor.slnx +``` + +```sh +# 前端样式检查(仓库根目录) +npm run lint:css --prefix web +``` + +## 许可证 + +本项目采用 [Apache License 2.0](LICENSE)。 diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..7158d15 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,81 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,props,csproj,slnx,yml,yaml,md}] +indent_size = 2 + +[*.cs] +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_prefer_braces = true:warning +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# 命名:私有/内部实例字段 _camelCase,静态字段 s_camelCase,线程静态 t_camelCase +dotnet_naming_rule.static_fields.symbols = static_fields +dotnet_naming_rule.static_fields.style = s_camel +dotnet_naming_rule.static_fields.severity = warning +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_rule.private_instance_fields.symbols = private_instance_fields +dotnet_naming_rule.private_instance_fields.style = underscore_camel +dotnet_naming_rule.private_instance_fields.severity = warning +dotnet_naming_symbols.private_instance_fields.applicable_kinds = field +dotnet_naming_symbols.private_instance_fields.applicable_accessibilities = private, internal, private_protected + +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = i_pascal +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_style.underscore_camel.required_prefix = _ +dotnet_naming_style.underscore_camel.capitalization = camel_case +dotnet_naming_style.s_camel.required_prefix = s_ +dotnet_naming_style.s_camel.capitalization = camel_case +dotnet_naming_style.i_pascal.required_prefix = I +dotnet_naming_style.i_pascal.capitalization = pascal_case + +# 对本项目不适用的建议规则 +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA1716.severity = none +dotnet_diagnostic.CA1819.severity = none +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1305.severity = none +dotnet_diagnostic.CA1308.severity = none +dotnet_diagnostic.CA1002.severity = none +dotnet_diagnostic.CA1000.severity = none +dotnet_diagnostic.CA1034.severity = none +dotnet_diagnostic.CA1062.severity = none +dotnet_diagnostic.CA1031.severity = none +dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA2201.severity = none +dotnet_diagnostic.CA1861.severity = none +dotnet_diagnostic.CA1812.severity = none +dotnet_diagnostic.CA1805.severity = none +dotnet_diagnostic.CA1822.severity = suggestion +dotnet_diagnostic.IDE0005.severity = none +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.CA1720.severity = none +dotnet_diagnostic.CA1711.severity = none + +[{**/tests/**.cs,tests/**.cs}] +dotnet_diagnostic.xUnit1051.severity = none +dotnet_diagnostic.CA1707.severity = none diff --git a/backend/BannedSymbols.Node.txt b/backend/BannedSymbols.Node.txt new file mode 100644 index 0000000..5bc7555 --- /dev/null +++ b/backend/BannedSymbols.Node.txt @@ -0,0 +1,9 @@ +T:System.Diagnostics.Process;节点是纯采集器,禁止启动进程 +M:System.Reflection.Assembly.Load(System.String);禁止加载外部代码 +M:System.Reflection.Assembly.LoadFrom(System.String);禁止加载外部代码 +M:System.Reflection.Assembly.LoadFile(System.String);禁止加载外部代码 +M:System.Runtime.InteropServices.NativeLibrary.Load(System.String);禁止加载本机库 +M:System.IO.File.WriteAllText(System.String,System.String);只有 ConfigStore 可以写文件 +M:System.IO.File.WriteAllBytes(System.String,System.Byte[]);只有 ConfigStore 可以写文件 +M:System.IO.File.Create(System.String);只有 ConfigStore 可以写文件 +T:System.Net.Http.HttpClient;常驻节点进程不使用 HttpClient(注册由 fleet-enroll 完成) diff --git a/backend/BannedSymbols.txt b/backend/BannedSymbols.txt new file mode 100644 index 0000000..1dd0313 --- /dev/null +++ b/backend/BannedSymbols.txt @@ -0,0 +1,19 @@ +M:System.Threading.Thread.Sleep(System.Int32);用 Task.Delay(TimeProvider) +M:System.Threading.Thread.Sleep(System.TimeSpan);用 Task.Delay(TimeProvider) +M:System.Threading.Tasks.Task.Wait();禁止同步等待 +M:System.Threading.Tasks.Task.Wait(System.Int32);禁止同步等待 +M:System.Threading.Tasks.Task.Wait(System.TimeSpan);禁止同步等待 +P:System.Threading.Tasks.Task`1.Result;禁止同步等待,改用 await +P:System.DateTime.Now;时间一律通过 TimeProvider +P:System.DateTime.UtcNow;时间一律通过 TimeProvider +P:System.DateTime.Today;时间一律通过 TimeProvider +P:System.DateTimeOffset.Now;时间一律通过 TimeProvider +P:System.DateTimeOffset.UtcNow;时间一律通过 TimeProvider +M:System.Diagnostics.Stopwatch.StartNew();耗时通过 TimeProvider.GetTimestamp +M:System.Activator.CreateInstance(System.Type);禁止反射创建实例(AOT) +M:System.Type.GetType(System.String);禁止按名称取类型(AOT) +M:System.Text.Json.JsonSerializer.Serialize``1(``0,System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo +M:System.Text.Json.JsonSerializer.Deserialize``1(System.String,System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo +M:System.Text.Json.JsonSerializer.Deserialize``1(System.ReadOnlySpan{System.Byte},System.Text.Json.JsonSerializerOptions);必须使用源生成的 JsonTypeInfo +M:System.Text.Json.JsonSerializer.Serialize``1(``0);必须使用源生成的 JsonTypeInfo +M:System.Text.Json.JsonSerializer.Deserialize``1(System.String);必须使用源生成的 JsonTypeInfo diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..dd2894b --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,21 @@ + + + net10.0 + latest + enable + enable + true + latest-recommended + true + true + true + $(MSBuildThisFileDirectory)artifacts + true + 0.1.0 + false + + + + + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..95a8593 --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,13 @@ + + + true + + + + + + + + + + diff --git a/backend/Monitor.slnx b/backend/Monitor.slnx new file mode 100644 index 0000000..c69c642 --- /dev/null +++ b/backend/Monitor.slnx @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7d3f43f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,52 @@ +# Backend + +Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。 + +## 组件 + +| 路径 | 职责 | +| --- | --- | +| `src/Monitor.Backend/` | 应用入口与依赖组合 | +| `src/Monitor.Backend.Api/` | Public、Admin API 和 OpenAPI | +| `src/Monitor.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 | +| `src/Monitor.Backend.Application/` | 业务用例、查询、任务和后台服务 | +| `src/Monitor.Backend.Analysis/` | 指标计算、累加器和服务模型 | +| `src/Monitor.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 | +| `fleet/` | 通用节点身份、注册和连接框架 | +| `shared/` | 测量引擎、协议和数据格式 | +| `tests/` | 集成测试和测试支持代码 | + +## 环境要求 + +使用 .NET SDK 10。仓库根目录的 `backend/global.json` 指定所需 SDK 版本及滚动策略。 + +## 构建与测试 + +在 `backend/` 目录运行: + +```sh +cd backend +dotnet build Monitor.slnx -c Release +dotnet test --solution Monitor.slnx +``` + +发布当前平台的 NativeAOT Backend: + +```sh +dotnet publish src/Monitor.Backend -c Release -r -p:PublishAot=true +``` + +例如 `` 可为 `win-x64`、`linux-x64` 或 `linux-musl-x64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh` 也提供发布和容器构建流程,详见 [`../docs/deployment.md`](../docs/deployment.md)。 + +## 本地运行 + +在 `backend/` 目录启动服务: + +```sh +cd backend +dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000 +``` + +首次启动时可用 `Monitor__InitialAdminUser` 和 `Monitor__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `Monitor` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。 + +OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。 diff --git a/backend/agent/Directory.Build.props b/backend/agent/Directory.Build.props new file mode 100644 index 0000000..22f99e2 --- /dev/null +++ b/backend/agent/Directory.Build.props @@ -0,0 +1,17 @@ + + + + Size + true + false + true + false + false + false + false + false + + + + + diff --git a/backend/agent/README.md b/backend/agent/README.md new file mode 100644 index 0000000..53ae9b4 --- /dev/null +++ b/backend/agent/README.md @@ -0,0 +1,57 @@ +# Monitor Agent + +Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。 + +Agent 仅开放配置的控制和数据端口。它不提供远程终端、命令执行或文件管理;本地配置中的网络范围、速率和并发限制由节点自行执行,Backend 下发的任务不能越过这些限制。 + +## 工作流程 + +```text +Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend + ▲ + fleet-enroll 注册 +``` + +## 构建 + +需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建: + +```sh +cd backend +dotnet build agent/src/Monitor.Agent -c Release +dotnet build fleet/tools/fleet-enroll -c Release +``` + +发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`` 应与构建环境和目标平台匹配: + +```sh +dotnet publish agent/src/Monitor.Agent -c Release -r -p:PublishAot=true +dotnet publish fleet/tools/fleet-enroll -c Release -r -p:PublishAot=true +``` + +也可在仓库根目录运行 `bash scripts/publish.sh agent ` 和 `bash scripts/publish.sh fleet-enroll `。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。 + +## 注册与运行 + +先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具: + +```sh +fleet-enroll --server https:// --join --name --identity-dir /etc/monitor +``` + +注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。 + +启动 Agent: + +```sh +monitor-agent --config /etc/monitor/agent.json +``` + +不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 + +## 设计说明 + +- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。 +- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 +- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 +- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。 diff --git a/backend/agent/src/Monitor.Agent/AgentApp.cs b/backend/agent/src/Monitor.Agent/AgentApp.cs new file mode 100644 index 0000000..a7136a7 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/AgentApp.cs @@ -0,0 +1,286 @@ +using System.Net; +using System.Text.Json; +using Fleet.Core; +using Fleet.Hosting; +using Fleet.Link; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。 +public sealed class AgentApp : IDisposable +{ + public const string Version = "0.1.0"; + + private readonly AgentConfig _config; + private readonly LoadedNodeIdentity _identity; + private readonly TimeProvider _time; + private readonly INodeLog _log; + private readonly MonoClock _clock; + private readonly AgentCounters _counters = new(); + private readonly SessionRegistry _sessions = new(); + private readonly StreamRegistry _streams = new(); + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly AgentAdmission _admission; + private readonly Outbox _outbox = new(64 << 20); + private readonly BackendUplink _uplink; + private readonly UdpMux _udp; + private readonly TcpAcceptor _tcp; + private readonly PeerListener _peers; + private readonly PeerHub _hub; + private readonly TaskManager _tasks; + private readonly TestRunner _tests; + private readonly FleetLink _link; + + public AgentApp(AgentConfig config, LoadedNodeIdentity identity, TimeProvider time, INodeLog log) + { + _config = config; + _identity = identity; + _time = time; + _log = log; + _clock = new MonoClock(time); + _uplink = new BackendUplink(time); + + IPAddress bind = IPAddress.Parse(config.Listen.Bind); + _admission = new AgentAdmission(_streams); + _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); + _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data); + + // 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听) + var env = new BlockEnv + { + Clock = _clock, + Log = log, + Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession) + Udp = _udp, + TcpListen = _tcp, + Admission = _admission, + Counters = _counters.Data, + MaxPacketsPerSecond = config.Limits.MaxTotalPps, + MaxBulkMbps = config.Limits.MaxTotalMbps, + }; + var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); + var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); + _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts); + _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); + _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log); + + _tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask(); + _tasks.OnPeersChanged = _hub.CloseNotIn; + _hub.OnProbeLost = probe => _tests.ProbeLost(probe); + _admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask(); + + _peers = new PeerListener( + new PeerListenerOptions + { + Listen = new IPEndPoint(bind, config.Listen.ControlPort), + Certificate = identity.Certificate, + LookupPeerKey = _tasks.LookupPeerKey, + }, + _hub.OnSessionAsync, + log, + time); + + _link = BuildLink(); + _uplink.Attach(_link); + } + + /// 数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。 + public Task Ready => _ready.Task; + + public void Dispose() => _udp.Dispose(); + + internal StreamRegistry Streams => _streams; + + public IReadOnlyList UdpPorts => _udp.BoundPorts; + + public IReadOnlyList TcpPorts => _tcp.BoundPorts; + + public int ControlPort => _peers.Port; + + public AgentCounters Counters => _counters; + + public FleetLink Link => _link; + + /// 期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。 + public bool IsPeerKnown(string probeId, string publicKeyBase64) => + _tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64; + + public TestRunner Tests => _tests; + + public async Task RunAsync(CancellationToken ct) + { + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + // 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口 + _udp.Bind(); + _tcp.Bind(); + Task udp = _udp.RunAsync(linked.Token); + Task tcp = _tcp.RunAsync(linked.Token); + Task peers = _peers.RunAsync(linked.Token); + await Task.WhenAny(_peers.Started.Task, peers); + if (peers.IsFaulted) + { + await linked.CancelAsync(); + await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等) + } + + _ready.TrySetResult(); + _log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts))); + try + { + await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token)); + } + finally + { + await _tasks.StopAsync(); + await linked.CancelAsync(); + } + } + + /// 每隔几秒把主机资源占用的快照发给 Backend(尽力而为:链路断开时丢弃,重连之后下一次就补上)。 + private async Task ReportHostAsync(CancellationToken ct) + { + if (!_config.Reporting.HostStats) + { + return; + } + + var sampler = new HostSampler(_time); + sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线 + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_config.Reporting.HostIntervalSeconds, 1, 60)), _time); + try + { + while (await timer.WaitForNextTickAsync(ct)) + { + await _uplink.SendHostAsync(sampler.Sample()); + } + } + catch (OperationCanceledException) + { + // 停止 + } + } + + private BudgetGuard CreateGuard() => + new( + new NodeLimits + { + MaxTotalMbps = _config.Limits.MaxTotalMbps, + MaxTotalPps = _config.Limits.MaxTotalPps, + AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs, + MaxConcurrentTests = _config.Limits.MaxConcurrentTests, + AllowedUdpPorts = _udp.BoundPorts, + AllowedTcpPorts = _tcp.BoundPorts, + }, + BlockCatalog.AgentKinds); + + private FleetLink BuildLink() + { + var router = new RpcRouter(); + HashSet? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null; + bool Allow(string method) => allowed is null || allowed.Contains(method); + + if (Allow(MonitorMethods.TestRun)) + { + router.On( + MonitorMethods.TestRun, + MonitorJsonContext.Default.TestRunParams, + MonitorJsonContext.Default.TestRunResult, + (_, run, _) => ValueTask.FromResult(_tests.Start(run))); + } + + if (Allow(MonitorMethods.TestCancel)) + { + router.On( + MonitorMethods.TestCancel, + MonitorJsonContext.Default.TestCancelParams, + MonitorJsonContext.Default.TestRunResult, + (_, cancel, _) => ValueTask.FromResult(Result.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) }))); + } + + if (Allow(MonitorMethods.ProbeDisconnect)) + { + router.On( + MonitorMethods.ProbeDisconnect, + MonitorJsonContext.Default.ProbeDisconnectParams, + MonitorJsonContext.Default.TestRunResult, + async (_, request, _) => Result.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) })); + } + + if (Allow(MonitorMethods.ProbeAgentsChanged)) + { + router.OnNotify( + MonitorMethods.ProbeAgentsChanged, + MonitorJsonContext.Default.ProbeAgentsResult, + (_, list, _) => _hub.RelayAgentsAsync(list)); + } + + var options = new FleetLinkOptions + { + Server = new Uri(_config.Fleet.Server), + Identity = _identity.Identity, + NodeId = _identity.NodeId, + NodeVersion = Version, + Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], + HelloMeta = HelloMeta, + HeartbeatExtra = HeartbeatExtra, + }; + var link = new FleetLink(options, router, [new StateHandler(TaskSet.StateType, MonitorJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log); + + // 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected) + link.OnConnected = async (_, _) => + { + foreach (ConnectedProbe probe in _hub.ConnectedProbes()) + { + await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, probe.Id.Value, probe); + } + }; + return link; + } + + private JsonElement? HelloMeta() + { + var meta = new AgentHelloMeta + { + TlsSpki = _identity.File.TlsSpki, + ProbeTcpPort = ControlPort, + ProbeUdpPorts = [.. UdpPorts], + LocalLimits = new LocalLimits + { + MaxTotalMbps = _config.Limits.MaxTotalMbps, + MaxTotalPps = _config.Limits.MaxTotalPps, + AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs, + MaxConcurrentTests = _config.Limits.MaxConcurrentTests, + }, + }; + return JsonSerializer.SerializeToElement(meta, MonitorJsonContext.Default.AgentHelloMeta); + } + + private long _lastIn; + private long _lastOut; + private long _lastAtMs; + + private JsonElement? HeartbeatExtra() + { + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long seconds = Math.Max(1, (nowMs - _lastAtMs) / 1000); + long packetsIn = _counters.PacketsIn; + long packetsOut = _counters.PacketsOut; + var extra = new AgentHeartbeatExtra + { + Probes = [.. _hub.ConnectedProbes()], + Sessions = _sessions.All.Count, + PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds, + PpsOut = _lastAtMs == 0 ? 0 : (packetsOut - _lastOut) / seconds, + DroppedInvalid = _counters.DroppedInvalid, + DroppedRateLimited = _counters.DroppedRateLimited, + QueueBytes = _outbox.PendingBytes, + }; + _lastIn = packetsIn; + _lastOut = packetsOut; + _lastAtMs = nowMs; + return JsonSerializer.SerializeToElement(extra, MonitorJsonContext.Default.AgentHeartbeatExtra); + } +} diff --git a/backend/agent/src/Monitor.Agent/AgentConfig.cs b/backend/agent/src/Monitor.Agent/AgentConfig.cs new file mode 100644 index 0000000..16ed3e9 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/AgentConfig.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; + +namespace Monitor.Agent; + +/// +/// agent.json:节点本地配置,面板无法修改。其中 limits 是本地硬上限:期望任务集超出这些范围时, +/// 调和器拒绝执行并在 rejected[] 里给出原因。默认不使用 443,目标任务默认不开放。 +/// +public sealed class AgentConfig +{ + [JsonPropertyName("fleet")] + public FleetSection Fleet { get; set; } = new(); + + [JsonPropertyName("listen")] + public ListenSection Listen { get; set; } = new(); + + [JsonPropertyName("limits")] + public LimitsSection Limits { get; set; } = new(); + + [JsonPropertyName("fleet_policy")] + public PolicySection Policy { get; set; } = new(); + + [JsonPropertyName("reporting")] + public ReportingSection Reporting { get; set; } = new(); +} + +public sealed class ReportingSection +{ + /// 上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。 + [JsonPropertyName("host_stats")] + public bool HostStats { get; set; } = true; + + [JsonPropertyName("host_interval_s")] + public int HostIntervalSeconds { get; set; } = 2; +} + +public sealed class FleetSection +{ + [JsonPropertyName("server")] + public string Server { get; set; } = ""; + + [JsonPropertyName("identity_dir")] + public string IdentityDir { get; set; } = "/etc/monitor"; +} + +public sealed class ListenSection +{ + [JsonPropertyName("bind")] + public string Bind { get; set; } = "0.0.0.0"; + + /// Probe 控制通道(TLS,点对点会话)。 + [JsonPropertyName("control_port")] + public int ControlPort { get; set; } = 7443; + + [JsonPropertyName("udp_ports")] + public List UdpPorts { get; set; } = [7443]; + + [JsonPropertyName("tcp_ports")] + public List TcpPorts { get; set; } = [7444]; +} + +public sealed class LimitsSection +{ + [JsonPropertyName("max_total_mbps")] + public double MaxTotalMbps { get; set; } = 1200; + + [JsonPropertyName("max_total_pps")] + public long MaxTotalPps { get; set; } = 20_000; + + /// 默认为空:不允许执行任何目标任务。 + [JsonPropertyName("allowed_target_cidrs")] + public List AllowedTargetCidrs { get; set; } = []; + + [JsonPropertyName("max_concurrent_tests")] + public int MaxConcurrentTests { get; set; } = 1; +} + +public sealed class PolicySection +{ + /// 在默认值的基础上进一步收窄:只响应这些产品方法。为空表示不收窄。 + [JsonPropertyName("allow_methods")] + public List? AllowMethods { get; set; } +} + +[JsonSerializable(typeof(AgentConfig))] +public sealed partial class AgentConfigContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/agent/src/Monitor.Agent/Chunks/ChunkAssembler.cs b/backend/agent/src/Monitor.Agent/Chunks/ChunkAssembler.cs new file mode 100644 index 0000000..fc0098d --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Chunks/ChunkAssembler.cs @@ -0,0 +1,75 @@ +using Fleet.Core; +using Fleet.Link; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// +/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。 +/// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。 +/// +public sealed class ChunkAssembler +{ + private readonly StreamRegistry _streams; + private readonly MonoClock _clock; + private readonly AgentCounters _counters; + private readonly Outbox _outbox; + private readonly NodeId _agent; + private readonly TimeProvider _time; + private readonly INodeLog _log; + + public ChunkAssembler(StreamRegistry streams, MonoClock clock, AgentCounters counters, Outbox outbox, NodeId agent, TimeProvider time, INodeLog log) + { + _streams = streams; + _clock = clock; + _counters = counters; + _outbox = outbox; + _agent = agent; + _time = time; + _log = log; + } + + /// + /// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。 + /// 块头里的 probe_id 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。 + /// + public bool Accept(RawChunk fragment, ProbeSession probe) + { + RawChunkHeader header = fragment.Header; + StreamInfo? stream = header switch + { + { TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, probe) ? found : null, + { TaskId: { } task } => _streams.FindByTask(task, probe), + _ => null, + }; + header.ProbeId = new ProbeId(probe.ProbeId); + KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind); + if (stream is null || spec is null || spec.Id != header.Kind) + { + _counters.FragmentDiscarded(); + return false; + } + + Result> merged = FragmentMerger.Merge(spec, fragment, stream.Id, _streams); + if (!merged.TryGet(out IReadOnlyList? streams, out FleetError? error)) + { + _log.Warn("fragment discarded", ("kind", spec.Id), ("err", error.Code)); + _counters.FragmentDiscarded(); + stream.FragmentDone(); + return false; + } + + RawChunk chunk = ChunkFinalizer.Finalize(spec, fragment, streams, _agent, _clock.Anchor(), stream.Params.Values, stream.Port, ChunkId.New(_time)); + Emit(chunk); + _counters.FragmentAccepted(); + stream.FragmentDone(); + return true; + } + + /// Agent 自己采集的块(目标任务)直接发出。 + public void Emit(RawChunk chunk) + { + _outbox.Enqueue(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk)); + } +} diff --git a/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs b/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs new file mode 100644 index 0000000..fff6753 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/DataPlane/Registries.cs @@ -0,0 +1,333 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using Fleet.Core; +using Fleet.Link; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// 进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 提供。 +public sealed class AgentCounters +{ + private long _fragments; + private long _fragmentsDiscarded; + + public DataPlaneCounters Data { get; } = new(); + + public long PacketsIn => Data.PacketsIn; + + public long PacketsOut => Data.PacketsOut; + + public long DroppedInvalid => Data.DroppedInvalid; + + public long DroppedRateLimited => Data.DroppedRateLimited; + + public long Fragments => Interlocked.Read(ref _fragments); + + public long FragmentsDiscarded => Interlocked.Read(ref _fragmentsDiscarded); + + public void FragmentAccepted() => Interlocked.Increment(ref _fragments); + + public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded); +} + +public sealed record PeerContext(ProbeSession Session, MonoClock Clock); + +/// +/// 一个已经认证的 Probe 会话:数据面的 session_id 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、 +/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。 +/// +public sealed class ProbeSession : DataSession +{ + private readonly ConcurrentDictionary _windows = new(); + private readonly ConcurrentDictionary _nonces = new(); + private SocketAddress? _udpSource; + + public ProbeSession(PeerSessionInfo info) + : base(info.Credentials.SessionIdValue, info.Credentials.Key) + { + Info = info; + } + + public PeerSessionInfo Info { get; } + + public string ProbeId => Info.PeerId; + + /// Probe 的程序版本(握手里自报,只用于展示)。 + public string? Version => Info.Version; + + /// Probe 的远端地址(经过 NAT 之后的公网 IP)。 + public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString(); + + public long ConnectedAtUtcMs { get; init; } + + public RpcSession? Rpc { get; set; } + + /// 最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。 + public override IPEndPoint? UdpEndpoint + { + get + { + SocketAddress? source = Volatile.Read(ref _udpSource); + return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source); + } + } + + /// 记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。 + public bool UpdateSource(SocketAddress source) + { + SocketAddress? previous = Volatile.Read(ref _udpSource); + if (previous is not null && previous.Equals(source)) + { + return false; + } + + // SocketAddress 的缓冲区会被接收循环复用,所以必须复制 + var copy = new SocketAddress(source.Family, source.Size); + for (int i = 0; i < source.Size; i++) + { + copy[i] = source[i]; + } + + Volatile.Write(ref _udpSource, copy); + return previous is not null; + } + + public bool AcceptSequence(uint streamId, uint seq) + { + ReplayWindow window = _windows.GetOrAdd(streamId, _ => new ReplayWindow()); + lock (window) + { + return window.TryAccept(seq); + } + } + + /// TCP 首帧的随机数只能用一次,防止被截获之后重放。 + public override bool UseNonce(ReadOnlySpan nonce) + { + ulong hash = 14695981039346656037UL; // FNV-1a:随机数本身已经是 128 位随机,这里只需要一个去重用的指纹 + foreach (byte b in nonce) + { + hash = (hash ^ b) * 1099511628211UL; + } + + if (_nonces.Count > 4096) + { + _nonces.Clear(); + } + + return _nonces.TryAdd(hash, 0); + } + + public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _); +} + +/// 当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。 +public sealed class SessionRegistry : IDataSessions +{ + private readonly object _gate = new(); + private readonly Dictionary _byProbe = new(StringComparer.Ordinal); + private readonly Dictionary _bySession = []; + + public IReadOnlyList All + { + get + { + lock (_gate) + { + return [.. _byProbe.Values]; + } + } + } + + public ProbeSession? Get(string probeId) + { + lock (_gate) + { + return _byProbe.GetValueOrDefault(probeId); + } + } + + /// 登记新会话;返回同一个 Probe 的旧会话(如果有)。 + public ProbeSession? Replace(ProbeSession session) + { + lock (_gate) + { + _byProbe.TryGetValue(session.ProbeId, out ProbeSession? old); + if (old is not null) + { + _bySession.Remove(old.SessionId); + } + + _byProbe[session.ProbeId] = session; + _bySession[session.SessionId] = session; + return old; + } + } + + public void Remove(ProbeSession session) + { + lock (_gate) + { + if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session)) + { + _byProbe.Remove(session.ProbeId); + } + + if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session)) + { + _bySession.Remove(session.SessionId); + } + } + } + + public DataSession? Find(long sessionId) + { + lock (_gate) + { + return _bySession.GetValueOrDefault(sessionId); + } + } +} + +/// 数据面上一个流(一个线路任务,或测试的一个阶段)的元数据:准入策略据此限速,合并据此找到积木的记录。 +public sealed class StreamInfo +{ + private readonly object _bucketGate = new(); + private int _fragmentsDone; + private double _tokens = -1; + private long _lastRefillUs; + + public StreamInfo(uint id, string kind, ParamValues parameters) + { + Id = id; + Kind = kind; + Params = parameters; + KindSpec spec = Kinds.Find(kind)!; + DeclaredPps = kind switch + { + "udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"), + "udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")), + _ => 20, + }; + Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0; + } + + public uint Id { get; } + + public string Kind { get; } + + public ParamValues Params { get; } + + public TaskId? TaskId { get; init; } + + public TestId? TestId { get; init; } + + public int? Phase { get; init; } + + /// 这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。 + public ProbeSession? Owner { get; init; } + + public int Port { get; } + + public double DeclaredPps { get; } + + /// 运行这个流的监听方积木(合并时从它取记录)。 + public RunningBlock? Running { get; set; } + + /// 该流已经处理(交给 Backend)的列块数:测试阶段据此等待全部列块到齐。 + public int FragmentsDone => _fragmentsDone; + + public void FragmentDone() => Interlocked.Increment(ref _fragmentsDone); + + /// 令牌桶:实际速率超过声明速率的 1.2 倍(外加一点突发余量)就丢弃。 + public bool TryAdmit(long nowUs) + { + double rate = DeclaredPps * 1.2; + double burst = Math.Max(10, rate * 0.25); + lock (_bucketGate) + { + if (_tokens < 0) + { + _tokens = burst; + _lastRefillUs = nowUs; + } + + _tokens = Math.Min(burst, _tokens + ((nowUs - _lastRefillUs) / 1_000_000.0 * rate)); + _lastRefillUs = nowUs; + if (_tokens < 1) + { + return false; + } + + _tokens -= 1; + return true; + } + } +} + +public sealed class StreamRegistry : IRecordSource +{ + private readonly ConcurrentDictionary _streams = new(); + private int _next; + + public uint Allocate() => (uint)Interlocked.Increment(ref _next); + + public void Add(StreamInfo stream) => _streams[stream.Id] = stream; + + public void Remove(uint id) => _streams.TryRemove(id, out _); + + public bool TryGet(uint id, out StreamInfo stream) => _streams.TryGetValue(id, out stream!); + + public StreamInfo? FindByTask(TaskId task, ProbeSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner)); + + public StreamInfo? FindByTest(TestId test, int phase) => _streams.Values.FirstOrDefault(s => s.TestId == test && s.Phase == phase); + + public IReadOnlyCollection All => [.. _streams.Values]; + + public RowStore? Records(uint streamId, string streamName) => + _streams.TryGetValue(streamId, out StreamInfo? info) ? info.Running?.Block.Records(streamName) : null; +} + +/// +/// Agent 的准入策略(安全边界):重放窗口 → 流的令牌桶(声明速率的 1.2 倍)→ 源地址学习(NAT 重绑定自动跟随)。 +/// 积木本身不含任何策略;这里的任何一步失败,数据面都是静默丢弃并计数、不回复。 +/// +public sealed class AgentAdmission : IAdmission +{ + private readonly StreamRegistry _streams; + + public AgentAdmission(StreamRegistry streams) + { + _streams = streams; + } + + /// NAT 映射变化,源地址变了:上报 endpoint_changed。 + public Action? OnEndpointChanged { get; set; } + + public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs) + { + if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq)) + { + return Admission.Invalid; + } + + if (!stream.TryAdmit(nowUs)) + { + return Admission.RateLimited; + } + + if (probe.UpdateSource(source)) + { + OnEndpointChanged?.Invoke(probe); + } + + return Admission.Accepted; + } + + public bool AdmitMessage(DataSession session, uint streamId, long nowUs) => + _streams.TryGet(streamId, out StreamInfo? stream) && ReferenceEquals(stream.Owner, session) && stream.TryAdmit(nowUs); +} diff --git a/backend/agent/src/Monitor.Agent/Host/HostSampler.cs b/backend/agent/src/Monitor.Agent/Host/HostSampler.cs new file mode 100644 index 0000000..23e5736 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Host/HostSampler.cs @@ -0,0 +1,181 @@ +using System.Globalization; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// +/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 +/// 只有汇总的数字:不采集 CPU 型号、主机名、网卡名和任何 IP 地址,所以面板和展示页想泄漏也泄漏不出去。 +/// Linux 读 /proc;读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 +/// +public sealed class HostSampler +{ + private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"]; + + private readonly TimeProvider _time; + private readonly Func _read; + private readonly Func<(long Total, long Used)?> _disk; + private (long Total, long Idle)? _previousCpu; + private (long At, long Rx, long Tx)? _previousNet; + + public HostSampler(TimeProvider time, Func? read = null, Func<(long Total, long Used)?>? disk = null) + { + _time = time; + _read = read ?? ReadProc; + _disk = disk ?? ReadRootDisk; + } + + public HostSnapshot Sample() + { + long now = _time.GetTimestamp(); + var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; + + if (_read("/proc/stat") is { } stat && ParseCpu(stat) is { } cpu) + { + if (_previousCpu is { } before && cpu.Total > before.Total) + { + double busy = 1.0 - ((double)(cpu.Idle - before.Idle) / (cpu.Total - before.Total)); + snapshot.CpuPercent = Math.Round(Math.Clamp(busy * 100, 0, 100), 1); + } + + _previousCpu = cpu; + } + + if (_read("/proc/meminfo") is { } meminfo && ParseMemory(meminfo) is { } memory) + { + snapshot.MemTotal = memory.Total; + snapshot.MemUsed = memory.Used; + } + + if (_disk() is { } disk) + { + snapshot.DiskTotal = disk.Total; + snapshot.DiskUsed = disk.Used; + } + + if (_read("/proc/net/dev") is { } dev && ParseNetwork(dev) is { } net) + { + snapshot.NetRxTotal = net.Rx; + snapshot.NetTxTotal = net.Tx; + if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) + { + double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); + snapshot.NetRxBps = (long)((net.Rx - before.Rx) / seconds); + snapshot.NetTxBps = (long)((net.Tx - before.Tx) / seconds); + } + + _previousNet = (now, net.Rx, net.Tx); + } + + snapshot.UptimeSeconds = _read("/proc/uptime") is { } uptime && ParseUptime(uptime) is { } up ? up : Environment.TickCount64 / 1000; + return snapshot; + } + + /// 第一行 cpu user nice system idle iowait irq softirq steal …:总时间与空闲时间(idle + iowait)。 + public static (long Total, long Idle)? ParseCpu(string stat) + { + string? line = stat.Split('\n').FirstOrDefault(l => l.StartsWith("cpu ", StringComparison.Ordinal)); + if (line is null) + { + return null; + } + + long[] fields = [.. line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1).Take(8).Select(f => long.TryParse(f, CultureInfo.InvariantCulture, out long v) ? v : 0)]; + if (fields.Length < 5) + { + return null; + } + + return (fields.Sum(), fields[3] + fields[4]); + } + + /// MemTotal 与 MemAvailable(kB)→ 字节;已用 = 总量 − 可用。 + public static (long Total, long Used)? ParseMemory(string meminfo) + { + long? total = null; + long? available = null; + foreach (string line in meminfo.Split('\n')) + { + if (line.StartsWith("MemTotal:", StringComparison.Ordinal)) + { + total = KiloBytes(line); + } + else if (line.StartsWith("MemAvailable:", StringComparison.Ordinal)) + { + available = KiloBytes(line); + } + } + + return total is { } t && available is { } a ? (t, Math.Max(0, t - a)) : null; + } + + /// 所有物理网卡(排除回环、容器、虚拟网桥)的累计收发字节。 + public static (long Rx, long Tx)? ParseNetwork(string dev) + { + long rx = 0; + long tx = 0; + bool any = false; + foreach (string line in dev.Split('\n')) + { + int colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon < 0) + { + continue; + } + + string name = line[..colon].Trim(); + if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal))) + { + continue; + } + + string[] fields = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length >= 9 && long.TryParse(fields[0], CultureInfo.InvariantCulture, out long r) && long.TryParse(fields[8], CultureInfo.InvariantCulture, out long t)) + { + rx += r; + tx += t; + any = true; + } + } + + return any ? (rx, tx) : null; + } + + public static long? ParseUptime(string uptime) + { + string first = uptime.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? ""; + return double.TryParse(first, NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds) ? (long)seconds : null; + } + + private static long? KiloBytes(string line) + { + string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 && long.TryParse(parts[1], CultureInfo.InvariantCulture, out long kb) ? kb * 1024 : null; + } + + private static string? ReadProc(string path) + { + try + { + return OperatingSystem.IsLinux() && File.Exists(path) ? File.ReadAllText(path) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static (long Total, long Used)? ReadRootDisk() + { + try + { + string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/"; + var drive = new DriveInfo(root); + return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } + } +} diff --git a/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj new file mode 100644 index 0000000..8ff3c29 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Monitor.Agent.csproj @@ -0,0 +1,14 @@ + + + Exe + monitor-agent + Monitor.Agent + true + + + + + + + + diff --git a/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs b/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs new file mode 100644 index 0000000..a782712 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Peer/PeerHub.cs @@ -0,0 +1,315 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Link; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。 +public sealed class BackendUplink +{ + private readonly TimeProvider _time; + private volatile FleetLink? _link; + + public BackendUplink(TimeProvider time) + { + _time = time; + } + + public void Attach(FleetLink link) => _link = link; + + public bool IsConnected => _link?.CurrentSession is not null; + + public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedProbe? probe = null) + { + RpcSession? session = _link?.CurrentSession; + if (session is null) + { + return; + } + + var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe }; + await SafeAsync(() => session.NotifyAsync(MonitorMethods.Event, parameters, MonitorJsonContext.Default.AgentEventParams, CancellationToken.None)); + } + + public async ValueTask SendHostAsync(HostSnapshot snapshot) + { + RpcSession? session = _link?.CurrentSession; + if (session is null) + { + return; + } + + await SafeAsync(() => session.NotifyAsync(MonitorMethods.Host, snapshot, MonitorJsonContext.Default.HostSnapshot, CancellationToken.None)); + } + + public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) + { + RpcSession? session = _link?.CurrentSession; + if (session is null) + { + return; + } + + var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode }; + await SafeAsync(() => session.NotifyAsync(MonitorMethods.TestStatus, parameters, MonitorJsonContext.Default.TestStatusParams, CancellationToken.None)); + } + + /// 代 Probe 向 Backend 取 Agent 列表(Probe 不认识 Backend)。链路断开时返回 fleet_node_offline。 + public async ValueTask> RequestProbeAgentsAsync(ProbeAgentsParams request, CancellationToken ct) + { + RpcSession? session = _link?.CurrentSession; + if (session is null) + { + return Result.Fail(FleetErrors.NodeOffline, "the agent has no link to the backend"); + } + + return await session.RequestAsync(MonitorMethods.ProbeAgents, request, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct); + } + + private static async ValueTask SafeAsync(Func send) + { + try + { + await send(); + } + catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or System.Net.WebSockets.WebSocketException) + { + // 链路刚好断开:状态在重连之后由 hello 与下一次事件补上 + } + } +} + +/// +/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给 +/// 。会话结束时通知 Backend,并让正在运行的测试以 probe_offline 结束。 +/// +public sealed class PeerHub +{ + private const int DedupeWindow = 1024; + + /// 超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。 + private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75); + + private readonly RpcRouter _router; + private readonly SessionRegistry _sessions; + private readonly MonoClock _clock; + private readonly ChunkAssembler _chunks; + private readonly TaskManager _tasks; + private readonly BackendUplink _uplink; + private readonly TimeProvider _time; + private readonly INodeLog _log; + + public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log) + { + _router = BuildRouter(uplink); + _sessions = sessions; + _clock = clock; + _chunks = chunks; + _tasks = tasks; + _uplink = uplink; + _time = time; + _log = log; + } + + /// Probe 会话断开(这个 Probe 上的测试据此中止)。 + public Action? OnProbeLost { get; set; } + + public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct) + { + var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; + var rpc = new RpcSession(transport, _router, new PeerContext(probe, _clock), _time); + probe.Rpc = rpc; + + var seen = new HashSet(); + var order = new Queue(); + rpc.OnBinary = async (frame, token) => + { + if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory payload) || channel != MonitorChannels.ColumnBlock) + { + return; + } + + // 至少一次投递:重复到达的列块只处理一次,但每次都要确认 + if (seen.Add(id)) + { + order.Enqueue(id); + if (order.Count > DedupeWindow) + { + seen.Remove(order.Dequeue()); + } + + Result decoded = RawChunkCodec.Decode(payload.Span); + if (decoded.TryGet(out RawChunk? fragment, out FleetError? error)) + { + _chunks.Accept(fragment, probe); + } + else + { + _log.Warn("fragment undecodable", ("err", error.Code), ("detail", error.Message)); + } + } + + var ack = new OutboxAckParams { Ids = [id.ToString()] }; + await rpc.NotifyAsync(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams, token); + }; + + ProbeSession? previous = _sessions.Replace(probe); + if (previous?.Rpc is { } old) + { + await old.CloseAsync("replaced", CancellationToken.None); + } + + // 这个会话自己的一组流:序号从 0 开始,上一个会话留下的记录不会算到它头上 + LineTaskSet initial = _tasks.OpenSession(probe); + + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task run = rpc.RunAsync(linked.Token); + Task watchdog = WatchIdleAsync(probe, rpc, linked.Token); + _log.Info("probe connected", ("probe", info.PeerId), ("version", info.Version ?? ""), ("remote", probe.RemoteAddress ?? "")); + await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, info.PeerId, Describe(probe)); + await PushAsync(probe, initial, linked.Token); + try + { + await run; + } + catch (OperationCanceledException) + { + // 停止 + } + finally + { + _sessions.Remove(probe); + await linked.CancelAsync(); + await watchdog; + await rpc.DisposeAsync(); + await _tasks.CloseSessionAsync(probe); + _log.Info("probe disconnected", ("probe", info.PeerId)); + OnProbeLost?.Invoke(probe); + await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId); + } + } + + /// + /// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 没有收到任何东西, + /// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。 + /// + private async Task WatchIdleAsync(ProbeSession probe, RpcSession rpc, CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time); + try + { + while (await timer.WaitForNextTickAsync(ct)) + { + if (rpc.IdleFor > IdleLimit) + { + _log.Warn("probe session is silent, closing it", ("probe", probe.ProbeId), ("idle_s", (long)rpc.IdleFor.TotalSeconds)); + await rpc.CloseAsync("idle", CancellationToken.None); + return; + } + } + } + catch (OperationCanceledException) + { + // 会话结束 + } + } + + /// 当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。 + public IReadOnlyList ConnectedProbes() => [.. _sessions.All.Select(Describe)]; + + private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs }; + + public IReadOnlyList ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)]; + + /// 向一个 Probe 下发它的线路任务集(Probe 连上时由 先发一份)。 + public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); + + /// Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。 + public async ValueTask DisconnectAsync(string probeId) + { + if (_sessions.Get(probeId)?.Rpc is not { } rpc) + { + return false; + } + + await rpc.CloseAsync("disconnected", CancellationToken.None); + return true; + } + + /// 名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。 + public void CloseNotIn(IReadOnlySet allowed) + { + foreach (ProbeSession probe in _sessions.All) + { + if (!allowed.Contains(probe.ProbeId) && probe.Rpc is { } rpc) + { + _log.Info("probe is no longer allowed, closing its session", ("probe", probe.ProbeId)); + _ = rpc.CloseAsync("revoked", CancellationToken.None).AsTask(); + } + } + } + + /// Backend 的 Agent 列表变化时转交给所有 Probe(Probe 据此调整它连接的 Agent)。 + public async ValueTask RelayAgentsAsync(ProbeAgentsResult list) + { + foreach (ProbeSession probe in _sessions.All) + { + if (probe.Rpc is not { } rpc) + { + continue; + } + + try + { + await rpc.NotifyAsync(MonitorMethods.ProbeAgentsChanged, list, MonitorJsonContext.Default.ProbeAgentsResult, CancellationToken.None); + } + catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException) + { + // 会话刚好结束 + } + } + } + + private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct) + { + if (probe.Rpc is null) + { + return; + } + + var parameters = new StateSyncParams + { + Type = LineTaskSet.StateType, + Version = set.Version, + Doc = JsonSerializer.SerializeToElement(set, MonitorJsonContext.Default.LineTaskSet), + }; + try + { + Result result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct); + if (!result.IsSuccess) + { + _log.Warn("probe rejected the line task set", ("err", result.Error.Code)); + } + } + catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or OperationCanceledException) + { + // 会话已经结束:下一个会话连上时补发 + } + } + + private static RpcRouter BuildRouter(BackendUplink uplink) => + new RpcRouter() + .On( + MonitorMethods.ProbeAgents, + MonitorJsonContext.Default.ProbeAgentsParams, + MonitorJsonContext.Default.ProbeAgentsResult, + (_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct)) + .On( + MonitorMethods.Clock, + MonitorJsonContext.Default.ClockParams, + MonitorJsonContext.Default.ClockResult, + (context, _, _) => ValueTask.FromResult(Result.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() }))); +} diff --git a/backend/agent/src/Monitor.Agent/Program.cs b/backend/agent/src/Monitor.Agent/Program.cs new file mode 100644 index 0000000..5a69731 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Program.cs @@ -0,0 +1,42 @@ +using Fleet.Core; +using Fleet.Hosting; +using Monitor.Agent; + +TimeProvider time = TimeProvider.System; +INodeLog log = new ConsoleNodeLog(time); + +if (PlatformCheck.VerifyCrypto() is { } problem) +{ + log.Error("startup failed", ("err", problem)); + return 1; +} + +string configPath = "/etc/monitor/agent.json"; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--config") + { + configPath = args[i + 1]; + } +} + +AgentConfig config; +LoadedNodeIdentity identity; +try +{ + config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig); + identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir); +} +catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException) +{ + log.Error("startup failed", ("err", ex.Message)); + return 1; +} + +using (identity) +{ + using var app = new AgentApp(config, identity, time, log); + await ServiceShell.RunAsync(app.RunAsync, log); +} + +return 0; diff --git a/backend/agent/src/Monitor.Agent/Tasks/TargetRunners.cs b/backend/agent/src/Monitor.Agent/Tasks/TargetRunners.cs new file mode 100644 index 0000000..667cab7 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Tasks/TargetRunners.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text.Json; +using Fleet.Core; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// +/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木( 里的拨号方,例如 icmp_echo、connect), +/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 allowed_target_cidrs 内 +/// (由 在接受任务之前检查)。 +/// +public sealed class TargetRunners +{ + private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); + + private readonly Dictionary _running = []; + private readonly object _gate = new(); + private readonly ChunkAssembler _chunks; + private readonly NodeId _agent; + private readonly MonoClock _clock; + private readonly TimeProvider _time; + private readonly INodeLog _log; + + public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log) + { + _chunks = chunks; + _agent = agent; + _clock = clock; + _time = time; + _log = log; + } + + /// 让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。 + public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired) + { + lock (_gate) + { + var wanted = desired.ToDictionary(d => d.Spec.Id.Value, d => d); + foreach ((string id, Running running) in _running.ToList()) + { + if (!wanted.TryGetValue(id, out (TaskSpec Spec, TaskDecision Decision) entry) || Signature(entry.Spec) != running.Signature) + { + running.Cancel.Cancel(); + _running.Remove(id); + } + } + + foreach ((string id, (TaskSpec spec, TaskDecision decision)) in wanted) + { + if (_running.ContainsKey(id)) + { + continue; + } + + var cts = new CancellationTokenSource(); + _running[id] = new Running(Signature(spec), cts); + _ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None); + } + } + } + + private static string Signature(TaskSpec spec) => + spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText())); + + private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct) + { + try + { + if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port)) + { + _log.Warn("target is not an address", ("task", spec.Id.Value)); + return; + } + + var env = new BlockEnv + { + Clock = _clock, + Log = _log, + Session = () => null, + PeerAddress = address, + Fragments = new LocalSink(this, spec), + }; + IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null))); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task run = block.RunAsync(cts.Token); + try + { + using var timer = new PeriodicTimer(s_chunkSpan, _time); + while (await timer.WaitForNextTickAsync(ct)) + { + block.Flush(FlushMode.Settled); + } + } + finally + { + await cts.CancelAsync(); + await run; + block.Flush(FlushMode.All); + } + } + catch (OperationCanceledException) + { + // 任务被撤销 + } + catch (Exception ex) when (ex is System.Net.Sockets.SocketException or InvalidOperationException or BlockFaultException) + { + _log.Warn("target task stopped", ("task", spec.Id.Value), ("err", ex.Message)); + } + } + + private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port) + { + port = kind == "connect" ? (int)values.GetInt("port") : 0; + if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint)) + { + address = endpoint.Address; + if (endpoint.Port != 0) + { + port = endpoint.Port; + } + + return true; + } + + return IPAddress.TryParse(target, out address!); + } + + /// 目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。 + private sealed class LocalSink : IFragmentSink + { + private readonly TargetRunners _owner; + private readonly TaskSpec _spec; + + public LocalSink(TargetRunners owner, TaskSpec spec) + { + _owner = owner; + _spec = spec; + } + + public void Ship(string kind, FragmentScope scope, IReadOnlyList streams, IReadOnlyDictionary? parameters, long startUtcMs, int durationMs, int port) + { + ClockAnchor anchor = _owner._clock.Anchor(); + var header = new RawChunkHeader + { + ChunkId = ChunkId.New(_owner._time), + Kind = kind, + TaskId = scope.Task, + AgentId = _owner._agent, + Target = _spec.Target, + Params = parameters is null ? [] : new Dictionary(parameters), + Port = port, + StartUtcMs = startUtcMs, + DurationMs = Math.Clamp(durationMs, 1, ChunkFinalizer.MaxDurationMs), + Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = 0 }, + }; + _owner._chunks.Emit(new RawChunk(header, streams)); + } + } + + private sealed record Running(string Signature, CancellationTokenSource Cancel); +} diff --git a/backend/agent/src/Monitor.Agent/Tasks/TaskManager.cs b/backend/agent/src/Monitor.Agent/Tasks/TaskManager.cs new file mode 100644 index 0000000..070ed17 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Tasks/TaskManager.cs @@ -0,0 +1,266 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// +/// 期望任务集(task_set)的调和器:每个任务先经过 (参数校验 + 本地硬上限)。 +/// 通过的线路任务为每个 Probe 会话各登记一组流(流编号和监听方积木都属于会话,互不干扰),并汇总成 line_task_set 下发给这个 Probe; +/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(rejected[])回报给 Backend,其余任务照常运行。 +/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 PeerListenerOptions.MaxSessions 限制。 +/// +public sealed class TaskManager +{ + // 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind + private static readonly HashSet s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)]; + private static readonly HashSet s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)]; + + private readonly object _gate = new(); + private readonly Func _guardFactory; + private readonly StreamRegistry _streams; + private readonly BlockEnv _env; + private readonly TargetRunners _targets; + private readonly Func> _udpPorts; + private readonly Func> _tcpPorts; + private readonly Dictionary> _sessionLines = []; + private volatile Dictionary _peers = new(StringComparer.Ordinal); + private Dictionary _lineSpecs = []; + private long _version; + private BudgetGuard? _guard; + + public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts) + { + _guardFactory = guardFactory; + _streams = streams; + _env = env; + _targets = targets; + _udpPorts = udpPorts; + _tcpPorts = tcpPorts; + } + + public BudgetGuard Guard => _guard ??= _guardFactory(); + + /// 某个 Probe 的线路任务集变化(版本递增):由 推送给这个 Probe。 + public Action? OnLineTasksChanged { get; set; } + + /// 允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 关闭。 + public Action>? OnPeersChanged { get; set; } + + /// 点对点握手时查找 Probe 的公钥(SPKI)。 + public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null; + + /// 一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。 + public LineTaskSet OpenSession(ProbeSession probe) + { + lock (_gate) + { + var entries = new Dictionary(); + foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs) + { + LineEntry entry = BuildLineEntry(task, decision, probe); + Start(entry); + entries[id] = entry; + } + + _sessionLines[probe] = entries; + return SetFor(entries); + } + } + + /// Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。 + public async Task CloseSessionAsync(ProbeSession probe) + { + List entries; + lock (_gate) + { + if (!_sessionLines.Remove(probe, out Dictionary? lines)) + { + return; + } + + entries = [.. lines.Values]; + } + + foreach (LineEntry entry in entries) + { + await Retire(entry); + } + } + + public async ValueTask ApplyAsync(long version, TaskSet set, CancellationToken ct) + { + _ = ct; + var result = new StateSyncResult { AppliedVersion = version }; + + var peers = new Dictionary(StringComparer.Ordinal); + foreach (PeerSpec peer in set.Peers) + { + try + { + peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey); + } + catch (FormatException) + { + result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams }); + } + } + + _peers = peers; + + BudgetGuard guard = Guard; + double usedMbps = 0; + long usedPps = 0; + var line = new Dictionary(); + var targets = new List<(TaskSpec Spec, TaskDecision Decision)>(); + + foreach (TaskSpec task in set.Tasks) + { + if (!task.Enabled) + { + continue; + } + + bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind); + TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported); + if (!decision.Accepted) + { + result.Rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = decision.Reason! }); + continue; + } + + if (task.Scope == "target") + { + targets.Add((task, decision)); + } + else + { + line[task.Id.Value] = (task, decision); + } + } + + _targets.Reconcile(targets); + + // 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断 + var retired = new List(); + var changes = new List<(ProbeSession Probe, LineTaskSet Set)>(); + lock (_gate) + { + _lineSpecs = line; + _version = version; + foreach ((ProbeSession probe, Dictionary current) in _sessionLines) + { + var next = new Dictionary(); + foreach ((string id, (TaskSpec task, TaskDecision decision)) in line) + { + LineEntry candidate = BuildLineEntry(task, decision, probe); + if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature) + { + next[id] = existing; + } + else + { + Start(candidate); + next[id] = candidate; + } + } + + foreach ((string id, LineEntry entry) in current) + { + if (!next.TryGetValue(id, out LineEntry? kept) || !ReferenceEquals(kept, entry)) + { + retired.Add(entry); + } + } + + current.Clear(); + foreach ((string id, LineEntry entry) in next) + { + current[id] = entry; + } + + changes.Add((probe, SetFor(current))); + } + } + + foreach (LineEntry entry in retired) + { + await Retire(entry); + } + + OnPeersChanged?.Invoke(new HashSet(peers.Keys, StringComparer.Ordinal)); + foreach ((ProbeSession probe, LineTaskSet next) in changes) + { + OnLineTasksChanged?.Invoke(probe, next); + } + + return result; + } + + /// 停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。 + public async Task StopAsync() + { + _targets.Reconcile([]); + List entries; + lock (_gate) + { + entries = [.. _sessionLines.Values.SelectMany(l => l.Values)]; + _sessionLines.Clear(); + } + + foreach (LineEntry entry in entries) + { + await Retire(entry); + } + } + + private LineTaskSet SetFor(Dictionary entries) => + new() + { + Version = _version, + Tasks = [.. entries.Values.Select(e => e.Task)], + UdpPorts = [.. _udpPorts()], + TcpPorts = [.. _tcpPorts()], + }; + + private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, ProbeSession probe) + { + var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = probe }; + var lineTask = new LineTask + { + Id = task.Id, + Kind = task.Kind, + Params = new Dictionary(decision.Resolved!.Values), + StreamId = info.Id, + Port = info.Port, + }; + var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null)); + return new LineEntry(lineTask, info, spec, _env.WithSession(probe)); + } + + /// 在数据面登记这个流并启动它的监听方积木。 + private void Start(LineEntry entry) + { + IBlock block = BlockCatalog.Find(entry.Stream.Kind)!.Listener!(entry.Env, entry.Spec); + entry.Stream.Running = RunningBlock.Start(block, entry.Signature); + _streams.Add(entry.Stream); + } + + private async Task Retire(LineEntry entry) + { + _streams.Remove(entry.Stream.Id); + if (entry.Stream.Running is { } running) + { + await running.StopAsync(); + } + + entry.Stream.Owner?.ForgetStream(entry.Stream.Id); + } + + private sealed record LineEntry(LineTask Task, StreamInfo Stream, BlockSpec Spec, BlockEnv Env) + { + public string Signature => Spec.ConfigSignature; + } +} diff --git a/backend/agent/src/Monitor.Agent/Tests/TestRunner.cs b/backend/agent/src/Monitor.Agent/Tests/TestRunner.cs new file mode 100644 index 0000000..cfb7017 --- /dev/null +++ b/backend/agent/src/Monitor.Agent/Tests/TestRunner.cs @@ -0,0 +1,292 @@ +using System.Collections.Concurrent; +using Fleet.Core; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Agent; + +/// +/// 按需测试的编排(monitor.test.run):Agent 是时间的主人。每个阶段: +/// 登记数据面的流 → monitor.test.phase(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包) +/// → monitor.test.phase_end(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → phase_done。 +/// 同一时间只能运行 max_concurrent_tests 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。 +/// +public sealed class TestRunner +{ + private static readonly TimeSpan s_phaseSlack = TimeSpan.FromSeconds(15); + private static readonly TimeSpan s_fragmentWait = TimeSpan.FromSeconds(10); + + private readonly ConcurrentDictionary _running = new(); + private readonly TaskManager _tasks; + private readonly SessionRegistry _sessions; + private readonly StreamRegistry _streams; + private readonly BlockEnv _env; + private readonly BackendUplink _uplink; + private readonly int _maxConcurrent; + private readonly Func> _ports; + private readonly TimeProvider _time; + private readonly INodeLog _log; + + public TestRunner(TaskManager tasks, SessionRegistry sessions, StreamRegistry streams, BlockEnv env, BackendUplink uplink, int maxConcurrent, Func> ports, TimeProvider time, INodeLog log) + { + _tasks = tasks; + _sessions = sessions; + _streams = streams; + _env = env; + _uplink = uplink; + _maxConcurrent = Math.Max(1, maxConcurrent); + _ports = ports; + _time = time; + _log = log; + } + + public int Running => _running.Count; + + /// 校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 monitor.test.status 回报。 + public Result Start(TestRunParams run) + { + if (_sessions.Get(run.ProbeId.Value) is not { } probe) + { + return Result.Fail(MonitorErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent"); + } + + if (run.Phases.Count == 0) + { + return Result.Fail(MonitorErrors.ValidationFailed, "a test needs at least one phase"); + } + + var resolved = new List<(TestPhase Phase, TaskDecision Decision)>(); + foreach (TestPhase phase in run.Phases) + { + WithDefaultPort(phase); + TaskDecision decision = _tasks.Guard.CheckPhase(phase.Kind, phase.Params); + if (!decision.Accepted) + { + return Result.Fail( + decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MonitorErrors.BudgetExceeded, + $"phase {phase.Kind} refused: {decision.Reason}"); + } + + string? direction = decision.Resolved!.Values.ContainsKey("direction") ? decision.Resolved.GetString("direction") : null; + if (BlockCatalog.Find(phase.Kind) is not { Listener: not null } pair || !pair.Supports(direction)) + { + return Result.Fail(FleetErrors.Unsupported, $"{phase.Kind} does not support direction={direction}"); + } + } + + foreach (TestPhase phase in run.Phases) + { + resolved.Add((phase, _tasks.Guard.CheckPhase(phase.Kind, phase.Params))); + } + + var running = new RunningTest(probe); + if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running)) + { + running.Dispose(); + return Result.Fail(MonitorErrors.TestBusy, "this agent is already running a test"); + } + + _ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None); + return Result.Ok(new TestRunResult { Accepted = true }); + } + + /// 阶段没有指定端口时用本 Agent 自己监听的第一个端口(Backend 的模板不知道每个 Agent 的实际端口)。 + private void WithDefaultPort(TestPhase phase) + { + KindSpec? kind = Kinds.Find(phase.Kind); + if (kind is null || !kind.Params.Any(p => p.Name == "port")) + { + return; + } + + // 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改, + // 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。 + IReadOnlyList ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp"); + bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port); + if (!listening) + { + phase.Params["port"] = System.Text.Json.JsonDocument.Parse(ports[0].ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement; + } + } + + public bool Cancel(TestId test) + { + if (_running.TryGetValue(test.Value, out RunningTest? running)) + { + running.Cancel.Cancel(); + return true; + } + + return false; + } + + /// 某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。 + public void ProbeLost(ProbeSession probe) + { + foreach (RunningTest running in _running.Values) + { + if (ReferenceEquals(running.Probe, probe)) + { + running.Lost = true; + running.Cancel.Cancel(); + } + } + } + + private async Task RunAsync(TestId test, List<(TestPhase Phase, TaskDecision Decision)> phases, RunningTest running) + { + CancellationToken ct = running.Cancel.Token; + string finalStatus = TestStatuses.Succeeded; + string? errorCode = null; + int? lastPhase = null; + try + { + ProbeSession probe = running.Probe; + for (int index = 0; index < phases.Count; index++) + { + lastPhase = index; + await _uplink.SendTestStatusAsync(test, TestStatuses.Running, index); + await RunPhaseAsync(probe, test, index, phases[index].Phase, phases[index].Decision, ct); + await _uplink.SendTestStatusAsync(test, TestStatuses.PhaseDone, index); + } + } + catch (OperationCanceledException) + { + finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted; + errorCode = running.Lost ? MonitorErrors.ProbeOffline.Value : null; + await TryAbortProbeAsync(running.Probe, test); + } + catch (TestFailure failure) + { + finalStatus = TestStatuses.Failed; + errorCode = failure.Code; + await TryAbortProbeAsync(running.Probe, test); + } + finally + { + _running.TryRemove(test.Value, out _); + running.Dispose(); + } + + _log.Info("test finished", ("test", test.Value), ("status", finalStatus)); + await _uplink.SendTestStatusAsync(test, finalStatus, lastPhase, errorCode); + } + + private async Task RunPhaseAsync(ProbeSession probe, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct) + { + ParamValues values = decision.Resolved!; + var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = probe }; + int durationSeconds = phase.DurationSeconds > 0 ? phase.DurationSeconds : (int)(values.Values.ContainsKey("duration_s") ? values.GetInt("duration_s") : 10); + var blockSpec = new BlockSpec(decision.Kind!, values, stream.Id, stream.Port, new FragmentScope(null, test, index)); + stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(probe), blockSpec), blockSpec.Signature); + _streams.Add(stream); + try + { + RpcSession rpc = probe.Rpc ?? throw new TestFailure(MonitorErrors.ProbeOffline.Value); + var start = new TestPhaseStartParams + { + TestId = test, + Phase = index, + Kind = phase.Kind, + Params = new Dictionary(values.Values), + StreamId = stream.Id, + DurationSeconds = durationSeconds, + Port = stream.Port, + }; + Result accepted = await rpc.RequestAsync(MonitorMethods.TestPhase, start, MonitorJsonContext.Default.TestPhaseStartParams, MonitorJsonContext.Default.TestPhaseStartResult, ct); + if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted) + { + throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value); + } + + // 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可 + if (phase.Kind == "udp_train") + { + try + { + await stream.Running.Completion.WaitAsync(ct); + } + catch (BlockFaultException) + { + throw new TestFailure(MonitorErrors.ProbeOffline.Value); + } + } + else + { + await Task.Delay(TimeSpan.FromSeconds(durationSeconds), _time, ct); + } + + long sent = stream.Running.Block.Produced; + var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent }; + Result ended = await rpc.RequestAsync(MonitorMethods.TestPhaseEnd, end, MonitorJsonContext.Default.TestPhaseEndParams, MonitorJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack); + if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError)) + { + throw new TestFailure(endError.Code.Value); + } + + // 列块是经 Probe 的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续) + DateTimeOffset deadline = _time.GetUtcNow() + s_fragmentWait; + while (stream.FragmentsDone < finished.Fragments && _time.GetUtcNow() < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(20), _time, ct); + } + + if (stream.FragmentsDone < finished.Fragments) + { + _log.Warn("fragments missing", ("test", test.Value), ("phase", index), ("expected", finished.Fragments), ("got", stream.FragmentsDone)); + } + } + finally + { + _streams.Remove(stream.Id); + await stream.Running.StopAsync(); + probe.ForgetStream(stream.Id); + } + } + + private async Task TryAbortProbeAsync(ProbeSession probe, TestId test) + { + if (probe.Rpc is not { } rpc) + { + return; + } + + try + { + await rpc.NotifyAsync(MonitorMethods.TestAbort, new TestAbortParams { TestId = test }, MonitorJsonContext.Default.TestAbortParams, CancellationToken.None); + } + catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException) + { + // 会话已经结束 + } + } + + /// 一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。 + private sealed class RunningTest : IDisposable + { + public RunningTest(ProbeSession probe) + { + Probe = probe; + } + + public ProbeSession Probe { get; } + + public CancellationTokenSource Cancel { get; } = new(); + + public volatile bool Lost; + + public void Dispose() => Cancel.Dispose(); + } + + private sealed class TestFailure : Exception + { + public TestFailure(string code) + : base(code) + { + Code = code; + } + + public string Code { get; } + } +} diff --git a/backend/fleet/Directory.Build.props b/backend/fleet/Directory.Build.props new file mode 100644 index 0000000..12d9ad0 --- /dev/null +++ b/backend/fleet/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/backend/fleet/src/Fleet.Core/ErrorCode.cs b/backend/fleet/src/Fleet.Core/ErrorCode.cs new file mode 100644 index 0000000..8f2cf03 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/ErrorCode.cs @@ -0,0 +1,28 @@ +namespace Fleet.Core; + +/// +/// 错误码:snake_case 字符串。框架的错误码以 fleet_ 开头,产品可以扩展自己的错误码。 +/// +public readonly record struct ErrorCode(string Value) +{ + public override string ToString() => Value; +} + +public static class FleetErrors +{ + public static readonly ErrorCode Unauthorized = new("fleet_unauthorized"); + public static readonly ErrorCode Forbidden = new("fleet_forbidden"); + public static readonly ErrorCode Unsupported = new("fleet_unsupported"); + public static readonly ErrorCode InvalidParams = new("fleet_invalid_params"); + public static readonly ErrorCode NotFound = new("fleet_not_found"); + public static readonly ErrorCode Internal = new("fleet_internal"); + public static readonly ErrorCode Timeout = new("fleet_timeout"); + public static readonly ErrorCode NodeOffline = new("fleet_node_offline"); + public static readonly ErrorCode NodePending = new("fleet_node_pending"); + public static readonly ErrorCode NodeDisabled = new("fleet_node_disabled"); + public static readonly ErrorCode NameConflict = new("fleet_name_conflict"); + public static readonly ErrorCode JoinTokenInvalid = new("fleet_join_token_invalid"); + public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired"); + public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid"); + public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large"); +} diff --git a/backend/fleet/src/Fleet.Core/Fleet.Core.csproj b/backend/fleet/src/Fleet.Core/Fleet.Core.csproj new file mode 100644 index 0000000..4f1e9ee --- /dev/null +++ b/backend/fleet/src/Fleet.Core/Fleet.Core.csproj @@ -0,0 +1,9 @@ + + + true + Fleet.Core + + + + + diff --git a/backend/fleet/src/Fleet.Core/NodeSupport.cs b/backend/fleet/src/Fleet.Core/NodeSupport.cs new file mode 100644 index 0000000..e5a1ab5 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/NodeSupport.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; + +namespace Fleet.Core; + +public enum NodeLogLevel +{ + Info, + Warn, + Error, +} + +/// 节点日志:字段名统一为 logfmt(ts level msg agent probe session task result err)。逐包事件不记录,只累加计数。 +public interface INodeLog +{ + void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields); +} + +public sealed class NullNodeLog : INodeLog +{ + public static NullNodeLog Instance { get; } = new(); + + public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) + { + } +} + +public static class NodeLogExtensions +{ + public static void Info(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Info, message, fields); + + public static void Warn(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Warn, message, fields); + + public static void Error(this INodeLog log, string message, params (string Key, object? Value)[] fields) => log.Write(NodeLogLevel.Error, message, fields); +} + +/// 指数退避(带随机抖动):1 s → 20 s(对端恢复之后最多等 20 秒;节点数量不大,重连风暴不是问题)。连接成功之后调用 。 +public sealed class Backoff +{ + private readonly TimeSpan _initial; + private readonly TimeSpan _max; + private int _attempt; + + public Backoff(TimeSpan? initial = null, TimeSpan? max = null) + { + _initial = initial ?? TimeSpan.FromSeconds(1); + _max = max ?? TimeSpan.FromSeconds(20); + } + + public TimeSpan Next() + { + double baseMs = Math.Min(_max.TotalMilliseconds, _initial.TotalMilliseconds * Math.Pow(2, Math.Min(_attempt, 20))); + _attempt++; + // 抖动:0.5 ~ 1.0 倍,避免所有节点同时重连 + double jitter = 0.5 + (RandomNumberGenerator.GetInt32(0, 1000) / 2000.0); + return TimeSpan.FromMilliseconds(baseMs * jitter); + } + + public void Reset() => _attempt = 0; +} + +/// +/// 序号重放窗口(滑动 1024 位):同一个序号只接受一次;比窗口还旧的序号视为重放。 +/// 用于数据面的 UDP 包,每个 (session, stream) 一个。 +/// +public sealed class ReplayWindow +{ + private const int Size = 1024; + private readonly ulong[] _bits = new ulong[Size / 64]; + private long _highest = -1; + + public bool TryAccept(uint seq) + { + if (_highest < 0) + { + _highest = seq; + Set(seq); + return true; + } + + if (seq > _highest) + { + long shift = seq - _highest; + if (shift >= Size) + { + Array.Clear(_bits); + } + else + { + for (long s = _highest + 1; s <= seq; s++) + { + Clear((uint)s); + } + } + + _highest = seq; + Set(seq); + return true; + } + + if (_highest - seq >= Size) + { + return false; // 太旧 + } + + if (IsSet(seq)) + { + return false; // 重复 + } + + Set(seq); + return true; + } + + private void Set(uint seq) => _bits[(int)(seq % Size) / 64] |= 1UL << (int)(seq % 64); + + private void Clear(uint seq) => _bits[(int)(seq % Size) / 64] &= ~(1UL << (int)(seq % 64)); + + private bool IsSet(uint seq) => (_bits[(int)(seq % Size) / 64] & (1UL << (int)(seq % 64))) != 0; +} diff --git a/backend/fleet/src/Fleet.Core/Result.cs b/backend/fleet/src/Fleet.Core/Result.cs new file mode 100644 index 0000000..ce495f5 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/Result.cs @@ -0,0 +1,50 @@ +namespace Fleet.Core; + +public record FleetError(ErrorCode Code, string Message) +{ + public override string ToString() => $"{Code}: {Message}"; +} + +/// 预期内的失败用结果表示,不抛异常。 +public readonly struct Result +{ + private readonly T? _value; + private readonly FleetError? _error; + + private Result(T? value, FleetError? error) + { + _value = value; + _error = error; + } + + public bool IsSuccess => _error is null; + + public T Value => _error is null ? _value! : throw new InvalidOperationException(_error.ToString()); + + public FleetError Error => _error ?? throw new InvalidOperationException("Result is successful."); + + public static Result Ok(T value) => new(value, null); + + public static Result Fail(FleetError error) => new(default, error); + + public static Result Fail(ErrorCode code, string message) => new(default, new FleetError(code, message)); + + public bool TryGet(out T value, out FleetError error) + { + value = _value!; + error = _error!; + return _error is null; + } + + public Result Map(Func map) => + _error is null ? Result.Ok(map(_value!)) : Result.Fail(_error); + + public static implicit operator Result(FleetError error) => Fail(error); +} + +public static class Result +{ + public static Result Ok(T value) => Result.Ok(value); + + public static Result Fail(ErrorCode code, string message) => Result.Fail(code, message); +} diff --git a/backend/fleet/src/Fleet.Core/StringId.cs b/backend/fleet/src/Fleet.Core/StringId.cs new file mode 100644 index 0000000..87f4058 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/StringId.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Fleet.Core; + +/// 节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。 +[JsonConverter(typeof(NodeIdJsonConverter))] +public readonly record struct NodeId(string Value) +{ + public override string ToString() => Value; +} + +/// 强类型字符串 ID 的 JSON 转换器基类(源生成友好,不使用反射)。 +public abstract class StringIdJsonConverter : JsonConverter +{ + protected abstract TId Create(string value); + + protected abstract string GetValue(TId id); + + public sealed override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + Create(reader.GetString() ?? throw new JsonException("id must be a string")); + + public sealed override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WriteStringValue(GetValue(value)); + + public sealed override TId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + Create(reader.GetString() ?? throw new JsonException("id must be a string")); + + public sealed override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WritePropertyName(GetValue(value)); +} + +public sealed class NodeIdJsonConverter : StringIdJsonConverter +{ + protected override NodeId Create(string value) => new(value); + + protected override string GetValue(NodeId id) => id.Value; +} diff --git a/backend/fleet/src/Fleet.Core/TokenHash.cs b/backend/fleet/src/Fleet.Core/TokenHash.cs new file mode 100644 index 0000000..08eebc7 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/TokenHash.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Core; + +/// 令牌的生成与哈希:服务端只保存 SHA-256,明文只在创建时返回一次。 +public static class TokenHash +{ + public static byte[] Sha256(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token)); + + /// 生成高熵令牌:{prefix}_{43 个 base64url 字符}。 + public static string NewToken(string prefix) + { + Span random = stackalloc byte[32]; + RandomNumberGenerator.Fill(random); + return prefix + "_" + Base64Url.Encode(random); + } + + public static bool FixedTimeEquals(ReadOnlySpan a, ReadOnlySpan b) => + CryptographicOperations.FixedTimeEquals(a, b); +} + +public static class Base64Url +{ + public static string Encode(ReadOnlySpan data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static bool TryDecode(string text, out byte[] data) + { + string s = text.Replace('-', '+').Replace('_', '/'); + s = s.PadRight(s.Length + ((4 - (s.Length % 4)) % 4), '='); + try + { + data = Convert.FromBase64String(s); + return true; + } + catch (FormatException) + { + data = []; + return false; + } + } +} diff --git a/backend/fleet/src/Fleet.Core/Ulid.cs b/backend/fleet/src/Fleet.Core/Ulid.cs new file mode 100644 index 0000000..c0b6574 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/Ulid.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; + +namespace Fleet.Core; + +/// 26 个字符的 ULID(Crockford base32,前 48 位为毫秒时间戳),按时间可排序。 +public static class Ulid +{ + private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + public static string New(TimeProvider time) + { + long ms = time.GetUtcNow().ToUnixTimeMilliseconds(); + Span random = stackalloc byte[10]; + RandomNumberGenerator.Fill(random); + + Span chars = stackalloc char[26]; + for (int i = 9; i >= 0; i--) + { + chars[i] = Alphabet[(int)(ms & 31)]; + ms >>= 5; + } + + // 80 位随机数 → 16 个 base32 字符 + UInt128 r = 0; + foreach (byte b in random) + { + r = (r << 8) | b; + } + + for (int i = 25; i >= 10; i--) + { + chars[i] = Alphabet[(int)(r & 31)]; + r >>= 5; + } + + return new string(chars); + } + + /// 带类型前缀的 ID,例如 agt_01J…。 + public static string NewPrefixed(string prefix, TimeProvider time) => prefix + "_" + New(time); +} diff --git a/backend/fleet/src/Fleet.Core/VarInt.cs b/backend/fleet/src/Fleet.Core/VarInt.cs new file mode 100644 index 0000000..1b9654b --- /dev/null +++ b/backend/fleet/src/Fleet.Core/VarInt.cs @@ -0,0 +1,66 @@ +using System.Buffers.Binary; + +namespace Fleet.Core; + +/// LEB128 变长整数(无符号),以及 zigzag 有符号映射。 +public static class VarInt +{ + public const int MaxBytes = 10; + + public static ulong ZigZag(long value) => (ulong)((value << 1) ^ (value >> 63)); + + public static long UnZigZag(ulong value) => (long)(value >> 1) ^ -(long)(value & 1); + + public static int Write(Span destination, ulong value) + { + int i = 0; + while (value >= 0x80) + { + destination[i++] = (byte)(value | 0x80); + value >>= 7; + } + + destination[i++] = (byte)value; + return i; + } + + public static void Write(Stream destination, ulong value) + { + Span buffer = stackalloc byte[MaxBytes]; + int n = Write(buffer, value); + destination.Write(buffer[..n]); + } + + /// 读取失败(数据被截断或超过 10 字节)时返回 false。 + public static bool TryRead(ReadOnlySpan source, out ulong value, out int consumed) + { + value = 0; + consumed = 0; + int shift = 0; + while (consumed < source.Length && consumed < MaxBytes) + { + byte b = source[consumed++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) + { + return true; + } + + shift += 7; + } + + return false; + } + + public static bool TryReadUInt32LittleEndian(ReadOnlySpan source, out uint value) + { + if (source.Length < 4) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt32LittleEndian(source); + return true; + } +} diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs new file mode 100644 index 0000000..e73bb55 --- /dev/null +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Hosting; +using Fleet.Identity; + +namespace Fleet.Enrollment.Client; + +public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory); + +/// +/// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具): +/// 生成身份密钥和 TLS 证书 → 用身份私钥签名持有证明 → 调用注册端点 → 写入身份目录。 +/// 注册逻辑只存在于这里,常驻的业务进程不包含注册代码,也不使用 HttpClient。 +/// +public static class EnrollmentClient +{ + public static async Task> EnrollAsync( + HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct) + { + string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); + if (File.Exists(nodeFile)) + { + return Result.Fail(FleetErrors.NameConflict, $"{nodeFile} already exists: this node is already enrolled (remove the directory to enroll again)"); + } + + using NodeIdentity identity = NodeIdentity.Generate(); + byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time); + byte[] spki; + using (var certificate = NodeCertificate.LoadPfx(pfx)) + { + spki = NodeCertificate.SpkiFingerprint(certificate); + } + + string pubkey = Convert.ToBase64String(identity.PublicKeySpki); + string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken))); + var body = new JsonObject + { + ["join_token"] = joinToken, + ["name"] = name, + ["pubkey"] = pubkey, + ["tls_spki"] = Convert.ToBase64String(spki), + ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), + ["node_version"] = nodeVersion, + ["meta"] = productMeta, + }; + + using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll"), content, ct); + string text = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + string code = "fleet_internal"; + string detail = text; + try + { + JsonNode? problem = JsonNode.Parse(text); + code = problem?["code"]?.GetValue() ?? code; + detail = problem?["detail"]?.GetValue() ?? detail; + } + catch (JsonException) + { + // 不是 problem+json:原样返回 + } + + return Result.Fail(new ErrorCode(code), $"{(int)response.StatusCode}: {detail}"); + } + + JsonNode result = JsonNode.Parse(text)!; + string nodeId = result["node_id"]!.GetValue(); + var store = new ConfigStore(identityDirectory); + store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); + store.Write(LoadedNodeIdentity.TlsFileName, pfx); + // node.json 最后写:它存在就表示注册完整完成 + store.Write( + LoadedNodeIdentity.NodeFileName, + JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); + return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory)); + } +} diff --git a/backend/fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj b/backend/fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj new file mode 100644 index 0000000..ebae197 --- /dev/null +++ b/backend/fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj @@ -0,0 +1,10 @@ + + + true + + + + + + + diff --git a/backend/fleet/src/Fleet.Hosting/Fleet.Hosting.csproj b/backend/fleet/src/Fleet.Hosting/Fleet.Hosting.csproj new file mode 100644 index 0000000..b6a9399 --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/Fleet.Hosting.csproj @@ -0,0 +1,10 @@ + + + true + + + + + + + diff --git a/backend/fleet/src/Fleet.Hosting/Hosting.cs b/backend/fleet/src/Fleet.Hosting/Hosting.cs new file mode 100644 index 0000000..dc4b33a --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/Hosting.cs @@ -0,0 +1,317 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; +using Fleet.Identity; + +namespace Fleet.Hosting; + +/// logfmt 日志:ts=… level=info msg="…" key=value。令牌、会话密钥和 MAC 不能出现在日志里。 +public sealed class ConsoleNodeLog : INodeLog +{ + private readonly TimeProvider _time; + private readonly TextWriter _writer; + private readonly object _gate = new(); + + public ConsoleNodeLog(TimeProvider time, TextWriter? writer = null) + { + _time = time; + _writer = writer ?? Console.Error; + } + + public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) + { + var line = new StringBuilder(); + line.Append("ts=").Append(_time.GetUtcNow().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture)); + line.Append(" level=").Append(level.ToString().ToLowerInvariant()); + line.Append(" msg=").Append(Quote(message)); + foreach ((string key, object? value) in fields) + { + line.Append(' ').Append(key).Append('=').Append(Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? "")); + } + + lock (_gate) + { + _writer.WriteLine(line.ToString()); + } + } + + private static string Quote(string text) => + text.Length > 0 && text.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or ':' or '/') ? text : "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; +} + +/// 可以独立运行的功能模块(连接、数据面、切块……)。 +public interface IModule +{ + string Name { get; } + + Task RunAsync(CancellationToken ct); +} + +/// +/// 模块宿主:同时运行所有模块,隔离故障(一个模块崩溃只重启它自己,指数退避),收到取消之后等所有模块退出。 +/// 模块之间通过构造函数传入的接口或 Channel<T> 通信,不使用静态全局状态。 +/// +public sealed class ModuleHost +{ + private readonly List _modules = []; + private readonly INodeLog _log; + private readonly TimeProvider _time; + + public ModuleHost(INodeLog log, TimeProvider time) + { + _log = log; + _time = time; + } + + public ModuleHost Add(IModule module) + { + _modules.Add(module); + return this; + } + + public Task RunAsync(CancellationToken ct) => Task.WhenAll(_modules.Select(m => RunModuleAsync(m, ct))); + + private async Task RunModuleAsync(IModule module, CancellationToken ct) + { + var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + while (!ct.IsCancellationRequested) + { + try + { + await module.RunAsync(ct); + return; // 模块正常结束 + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // 模块边界:一个模块的故障不能带倒整个进程 + TimeSpan delay = backoff.Next(); + _log.Error("module crashed", ("module", module.Name), ("err", ex.GetType().Name + ": " + ex.Message), ("retry_ms", (long)delay.TotalMilliseconds)); + await Task.Delay(delay, _time, ct); + } + } + } +} + +/// +/// 启动自检:Linux 上 .NET 的加密和 TLS 依赖系统的 OpenSSL(libssl.so.3)。OpenWrt 默认用 mbedTLS/wolfSSL,需要另外安装 libopenssl3。 +/// 缺少时在启动的第一步明确报错,而不是在第一次握手时抛出一个难以理解的异常。 +/// +public static class PlatformCheck +{ + /// 返回 null 表示正常,否则是给人看的错误说明。 + public static string? VerifyCrypto() + { + try + { + using var key = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); + _ = key.SignData("check"u8, System.Security.Cryptography.HashAlgorithmName.SHA256); + return null; + } + catch (Exception ex) when (ex is DllNotFoundException or TypeInitializationException or System.Security.Cryptography.CryptographicException or PlatformNotSupportedException or InvalidOperationException) + { + return $"cannot use the system cryptography library ({ex.GetType().Name}: {ex.Message}). Install OpenSSL 3: Debian/Ubuntu `apt install libssl3`, Alpine `apk add libssl3`, OpenWrt `opkg install libopenssl3`"; + } + } +} + +/// 处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。 +public static class ServiceShell +{ + public static async Task RunAsync(Func run, INodeLog log) + { + using var cts = new CancellationTokenSource(); + using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + context.Cancel = true; + log.Info("stopping", ("result", "SIGTERM")); + cts.Cancel(); + }); + using PosixSignalRegistration interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => + { + context.Cancel = true; + log.Info("stopping", ("result", "SIGINT")); + cts.Cancel(); + }); + // Windows 上 Ctrl+Break 对应 SIGQUIT(没有控制台窗口的后台运行方式用它来请求有序退出) + using PosixSignalRegistration quit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, context => + { + context.Cancel = true; + log.Info("stopping", ("result", "SIGQUIT")); + cts.Cancel(); + }); + // Windows 上关闭控制台窗口 / taskkill(不带 /F)对应 SIGHUP + using PosixSignalRegistration hangup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context => + { + context.Cancel = true; + log.Info("stopping", ("result", "SIGHUP")); + cts.Cancel(); + }); + Task trimmer = TrimHeapAsync(cts.Token); + try + { + await run(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + // 正常退出 + } + finally + { + await cts.CancelAsync(); + await trimmer; + } + } + + /// + /// 节点进程的托管堆只有几百 KB,但工作站 GC 的第 0 代预算很大,按默认设置要攒很多垃圾才回收一次, + /// 在 1 GB 内存的小机器上表现为常驻内存缓慢爬升(不是泄漏,强制回收后曲线是平的)。 + /// 每 30 秒回收一次:堆这么小,一次完整回收不到 1 ms。 + /// + private static async Task TrimHeapAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); + try + { + while (await timer.WaitForNextTickAsync(ct)) + { + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: false); + } + } + catch (OperationCanceledException) + { + // 退出 + } + } +} + +/// 读取 JSON 配置(源生成,不使用反射)。解析失败时给出明确的错误,而不是带着默认值悄悄运行。 +public static class NodeConfig +{ + public static T Load(string path, JsonTypeInfo typeInfo) + where T : class + { + try + { + if (!File.Exists(path)) + { + throw new InvalidDataException($"config file {path} does not exist"); + } + + byte[] bytes = File.ReadAllBytes(path); + ReadOnlySpan json = bytes; + if (json.StartsWith(""u8)) // Windows 编辑器常带 UTF-8 BOM,System.Text.Json 不接受 + { + json = json[3..]; + } + + return JsonSerializer.Deserialize(json, typeInfo) ?? throw new InvalidDataException($"{path} is empty"); + } + catch (JsonException ex) + { + throw new InvalidDataException($"{path} is not valid: {ex.Message}", ex); + } + } +} + +/// +/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。 +/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。 +/// +public sealed class ConfigStore +{ + private readonly string _directory; + + public ConfigStore(string directory) + { + _directory = Path.GetFullPath(directory); + } + + public void Write(string fileName, byte[] content) + { + string path = Path.GetFullPath(Path.Combine(_directory, fileName)); + if (!path.StartsWith(_directory, StringComparison.Ordinal)) + { + throw new InvalidOperationException("config files must stay inside the config directory"); + } + + Directory.CreateDirectory(_directory); + string temp = path + ".tmp"; +#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换 + File.WriteAllBytes(temp, content); +#pragma warning restore RS0030 + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + File.Move(temp, path, overwrite: true); + } +} + +/// node.json:fleet-enroll 写入,节点只读。 +public sealed class NodeFile +{ + [JsonPropertyName("node_id")] + public string NodeId { get; set; } = ""; + + [JsonPropertyName("server")] + public string Server { get; set; } = ""; + + /// 本节点 TLS 证书的 SPKI 指纹(base64),注册时上报。 + [JsonPropertyName("tls_spki")] + public string TlsSpki { get; set; } = ""; +} + +[JsonSerializable(typeof(NodeFile))] +public sealed partial class NodeFileContext : JsonSerializerContext; + +/// 身份目录里的文件(fleet-enroll 生成):node.json、node.key(PKCS#8)、tls.pfx。 +public sealed class LoadedNodeIdentity : IDisposable +{ + public const string NodeFileName = "node.json"; + public const string KeyFileName = "node.key"; + public const string TlsFileName = "tls.pfx"; + + public LoadedNodeIdentity(NodeFile file, NodeIdentity identity, X509Certificate2 certificate) + { + File = file; + Identity = identity; + Certificate = certificate; + } + + public NodeFile File { get; } + + public NodeId NodeId => new(File.NodeId); + + public NodeIdentity Identity { get; } + + public X509Certificate2 Certificate { get; } + + public static LoadedNodeIdentity Load(string directory) + { + if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName))) + { + throw new InvalidDataException($"no node identity in {directory}: run fleet-enroll first"); + } + + NodeFile file = NodeConfig.Load(Path.Combine(directory, NodeFileName), NodeFileContext.Default.NodeFile); + NodeIdentity identity = NodeIdentity.FromPkcs8(System.IO.File.ReadAllBytes(Path.Combine(directory, KeyFileName))); + X509Certificate2 certificate = NodeCertificate.LoadPfx(System.IO.File.ReadAllBytes(Path.Combine(directory, TlsFileName))); + return new LoadedNodeIdentity(file, identity, certificate); + } + + public void Dispose() + { + Identity.Dispose(); + Certificate.Dispose(); + } +} diff --git a/backend/fleet/src/Fleet.Identity/Fleet.Identity.csproj b/backend/fleet/src/Fleet.Identity/Fleet.Identity.csproj new file mode 100644 index 0000000..138fa6d --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/Fleet.Identity.csproj @@ -0,0 +1,9 @@ + + + true + + + + + + diff --git a/backend/fleet/src/Fleet.Identity/NodeCertificate.cs b/backend/fleet/src/Fleet.Identity/NodeCertificate.cs new file mode 100644 index 0000000..dae7288 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/NodeCertificate.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Fleet.Identity; + +/// +/// 点对点会话的 TLS 证书:自签名的 ECDSA P-256(和身份密钥是两把不同的密钥)。 +/// 对端不按证书链校验,而是按 SPKI 的 SHA-256 指纹校验(指纹由服务端经 Agent 列表下发)。 +/// +public static class NodeCertificate +{ + /// 生成自签名证书,返回无口令的 PFX 字节(文件权限由写入方设为 0600)。 + public static byte[] CreateSelfSignedPfx(string commonName, TimeSpan validity, TimeProvider time) + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=" + commonName, key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2")], critical: false)); + DateTimeOffset now = time.GetUtcNow(); + using X509Certificate2 certificate = request.CreateSelfSigned(now.AddDays(-1), now.Add(validity)); + return certificate.Export(X509ContentType.Pfx); + } + + public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null); + + /// 证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。 + public static byte[] SpkiFingerprint(X509Certificate2 certificate) => + SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo()); +} diff --git a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs new file mode 100644 index 0000000..5e12341 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs @@ -0,0 +1,81 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// 签名用途前缀:同一份签名不能在不同场景下复用。产品可以追加自己的用途,但不能占用 fleet 保留的这些。 +public static class SignaturePurpose +{ + public const string Auth = "auth"; + public const string Peer = "peer"; + public const string Enroll = "enroll"; +} + +public static class SignatureString +{ + public static string Build(string purpose, params string[] fields) => + "fleet|v1|" + purpose + "|" + string.Join('|', fields); +} + +/// 节点身份:ECDSA P-256 密钥对。服务端只保存公钥(SPKI)。 +public sealed class NodeIdentity : IDisposable +{ + private readonly ECDsa _key; + + private NodeIdentity(ECDsa key) + { + _key = key; + PublicKeySpki = key.ExportSubjectPublicKeyInfo(); + } + + public byte[] PublicKeySpki { get; } + + public static NodeIdentity Generate() => new(ECDsa.Create(ECCurve.NamedCurves.nistP256)); + + public static NodeIdentity FromPkcs8(ReadOnlySpan pkcs8) + { + ECDsa key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(pkcs8, out _); + return new NodeIdentity(key); + } + + public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey(); + + public byte[] Sign(string purpose, params string[] fields) => + _key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + /// 验证签名;公钥或签名格式不合法时返回 false,不抛异常。 + public static bool Verify(ReadOnlySpan publicKeySpki, ReadOnlySpan signature, string purpose, params string[] fields) + { + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKeySpki, out _); + return key.VerifyData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), signature, + HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + return false; + } + } + + public static bool IsValidPublicKey(ReadOnlySpan publicKeySpki) + { + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKeySpki, out _); + return key.KeySize == 256; + } + catch (CryptographicException) + { + return false; + } + } + + public static byte[] Fingerprint(ReadOnlySpan spki) => SHA256.HashData(spki); + + public void Dispose() => _key.Dispose(); +} diff --git a/backend/fleet/src/Fleet.Identity/SessionKeys.cs b/backend/fleet/src/Fleet.Identity/SessionKeys.cs new file mode 100644 index 0000000..2f4eba1 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/SessionKeys.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// 点对点会话派生出的数据面凭据。SessionId 8 字节,Key 32 字节(HMAC-SHA256 的密钥)。 +public sealed record SessionCredentials(byte[] SessionId, byte[] Key) +{ + public long SessionIdValue => BitConverter.ToInt64(SessionId, 0); +} + +/// 临时 ECDH 密钥:每个会话各生成一个,用完即弃,所以会话密钥具有前向保密。 +public sealed class EphemeralKey : IDisposable +{ + private readonly ECDiffieHellman _key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + + public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo(); + + /// + /// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。 + /// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。 + /// + public SessionCredentials Derive(ReadOnlySpan remotePublicKeySpki, ReadOnlySpan nonce, ReadOnlySpan clientPublic, ReadOnlySpan serverPublic) + { + using ECDiffieHellman remote = ECDiffieHellman.Create(); + remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _); + byte[] secret = _key.DeriveRawSecretAgreement(remote.PublicKey); + byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|session|"), .. SHA256.HashData(clientPublic), .. SHA256.HashData(serverPublic)]; + byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 40, nonce.ToArray(), info); + CryptographicOperations.ZeroMemory(secret); + return new SessionCredentials(material[..8], material[8..]); + } + + public void Dispose() => _key.Dispose(); +} + +/// 数据面的消息认证:HMAC-SHA256 截断到 16 字节。 +public static class DataPlaneMac +{ + public const int Bytes = 16; + + public static void Compute(ReadOnlySpan key, ReadOnlySpan data, Span destination) + { + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, data, full); + full[..Bytes].CopyTo(destination); + } + + public static bool Verify(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan mac) + { + Span expected = stackalloc byte[Bytes]; + Compute(key, data, expected); + return CryptographicOperations.FixedTimeEquals(expected, mac); + } +} diff --git a/backend/fleet/src/Fleet.Link/Fleet.Link.csproj b/backend/fleet/src/Fleet.Link/Fleet.Link.csproj new file mode 100644 index 0000000..1325705 --- /dev/null +++ b/backend/fleet/src/Fleet.Link/Fleet.Link.csproj @@ -0,0 +1,11 @@ + + + true + + + + + + + + diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs new file mode 100644 index 0000000..0fdcc5f --- /dev/null +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -0,0 +1,317 @@ +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Link; + +public sealed record LinkContext(FleetLink Link); + +/// 期望状态的处理者:应用完整快照,返回已应用的版本和被拒绝的条目(带结构化原因)。 +public interface IStateHandler +{ + string Type { get; } + + ValueTask ApplyAsync(long version, JsonElement document, CancellationToken ct); +} + +public sealed class StateHandler : IStateHandler +{ + private readonly JsonTypeInfo _info; + private readonly Func> _apply; + + public StateHandler(string type, JsonTypeInfo info, Func> apply) + { + Type = type; + _info = info; + _apply = apply; + } + + public string Type { get; } + + public async ValueTask ApplyAsync(long version, JsonElement document, CancellationToken ct) + { + TDoc? doc = document.Deserialize(_info); + return doc is null ? new StateSyncResult { AppliedVersion = 0 } : await _apply(version, doc, ct); + } +} + +public sealed class FleetLinkOptions +{ + /// 面板的地址(http 或 https),连接路径固定为 /api/agent/v1/rpc。 + public required Uri Server { get; init; } + + public required NodeIdentity Identity { get; init; } + + public required NodeId NodeId { get; init; } + + public string NodeVersion { get; init; } = "0.0.0"; + + /// 框架之外的能力,例如 monitor.v1 与 monitor.kinds:udp_echo,tcp_echo。 + public IReadOnlyList Capabilities { get; init; } = []; + + /// 附加到 fleet.hello 的产品元数据。 + public Func? HelloMeta { get; init; } + + /// 附加到 fleet.heartbeat 的进程内计数(不能包含主机信息)。 + public Func? HeartbeatExtra { get; init; } + + public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10); + + public int MaxFrameBytes { get; init; } = 16 << 20; + + /// 发件箱在途字节上限(服务端慢时不会把内存撑爆)。 + public long MaxInflightBytes { get; init; } = 4 << 20; + + public Backoff Backoff { get; init; } = new(); +} + +/// +/// 节点到服务端的连接:挑战 → 签名认证 → hello → 状态调和 / 可靠发件箱 / 心跳,断线之后指数退避重连。 +/// 被服务端撤销(fleet.revoked)或禁用时停止重试。 +/// +public sealed class FleetLink +{ + private const string RpcPath = "/api/agent/v1/rpc"; + + private readonly FleetLinkOptions _options; + private readonly RpcRouter _router; + private readonly Dictionary _handlers; + private readonly TimeProvider _time; + private readonly INodeLog _log; + private volatile RpcSession? _session; + private volatile bool _stopped; + + public FleetLink(FleetLinkOptions options, RpcRouter router, IEnumerable handlers, Outbox outbox, TimeProvider time, INodeLog log) + { + _options = options; + _router = router; + _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); + Outbox = outbox; + _time = time; + _log = log; + + _router + .On(FleetMethods.StateSync, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, HandleStateSyncAsync) + .OnNotify(FleetMethods.OutboxAck, FleetRpcContext.Default.OutboxAckParams, HandleAckAsync) + .OnNotify(FleetMethods.Revoked, FleetRpcContext.Default.RevokedParams, HandleRevokedAsync); + } + + public Outbox Outbox { get; } + + public bool IsConnected => _session is not null; + + public RpcSession? CurrentSession => _session; + + /// 被服务端撤销或禁用:不会再重连。 + public bool Revoked => _stopped; + + /// 每次连接并完成 hello 之后调用。 + public Func, CancellationToken, ValueTask>? OnConnected { get; set; } + + public Action? OnDisconnected { get; set; } + + public async Task RunAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && !_stopped) + { + string reason; + try + { + reason = await RunOnceAsync(ct); + _options.Backoff.Reset(); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException) + { + reason = ex.Message; + } + + OnDisconnected?.Invoke(reason); + if (ct.IsCancellationRequested) + { + return; + } + + if (_stopped) + { + _log.Warn("link stopped", ("err", reason)); + return; + } + + TimeSpan delay = _options.Backoff.Next(); + _log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds)); + await Task.Delay(delay, _time, ct); + } + } + + private async Task RunOnceAsync(CancellationToken ct) + { + using var socket = new ClientWebSocket(); + socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); + socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死 + var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath }; + await socket.ConnectAsync(builder.Uri, ct); + await using var transport = new WsTransport(socket, _options.MaxFrameBytes); + + string? failure = await AuthenticateAsync(transport, ct); + if (failure is not null) + { + return failure; + } + + var session = new RpcSession(transport, _router, new LinkContext(this), _time); + using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task run = session.RunAsync(sessionCts.Token); + try + { + var hello = new HelloParams + { + NodeVersion = _options.NodeVersion, + Capabilities = [.. _options.Capabilities], + NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), + Meta = _options.HelloMeta?.Invoke(), + }; + Result helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token); + if (!helloResult.IsSuccess) + { + return "hello failed: " + helloResult.Error.Code; + } + + Outbox.ResetSent(); + _session = session; + _log.Info("link up", ("result", "connected")); + Task pump = PumpAsync(session, sessionCts.Token); + Task heartbeat = HeartbeatAsync(session, sessionCts.Token); + if (OnConnected is not null) + { + await OnConnected(session, sessionCts.Token); + } + + Task finished = await Task.WhenAny(run, pump, heartbeat); + await sessionCts.CancelAsync(); + await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)); + return finished == run ? "closed by server" : "link task failed"; + } + finally + { + _session = null; + await sessionCts.CancelAsync(); + await Quiet(run); + await session.DisposeAsync(); + } + } + + /// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。 + private async Task AuthenticateAsync(WsTransport transport, CancellationToken ct) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + RpcMessage? challenge = await ReceiveMessageAsync(transport, linked.Token); + if (challenge?.Method != FleetMethods.Challenge || challenge.Params is null) + { + return "no challenge from the server"; + } + + ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; + string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)); + RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + + while (true) + { + RpcMessage? reply = await ReceiveMessageAsync(transport, linked.Token); + if (reply is null) + { + return "connection closed during authentication"; + } + + if (reply.Method == FleetMethods.Revoked) + { + _stopped = true; + return "revoked"; + } + + if (!reply.IsResponse) + { + continue; + } + + if (reply.Error is { } error) + { + if (error.Data?.Code is "fleet_node_disabled") + { + _stopped = true; + } + + return "authentication failed: " + error.Data?.Code; + } + + return null; + } + } + + private static async Task ReceiveMessageAsync(WsTransport transport, CancellationToken ct) + { + RpcFrame? frame = await transport.ReceiveAsync(ct); + return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; + } + + private async Task PumpAsync(RpcSession session, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + byte[] frame = await Outbox.TakeNextAsync(_options.MaxInflightBytes, ct); + await session.SendBinaryAsync(frame, ct); + } + } + + private async Task HeartbeatAsync(RpcSession session, CancellationToken ct) + { + using var timer = new PeriodicTimer(_options.HeartbeatInterval, _time); + while (await timer.WaitForNextTickAsync(ct)) + { + await session.NotifyAsync(FleetMethods.Heartbeat, new HeartbeatParams { Extra = _options.HeartbeatExtra?.Invoke() }, FleetRpcContext.Default.HeartbeatParams, ct); + } + } + + private async ValueTask> HandleStateSyncAsync(LinkContext context, StateSyncParams parameters, CancellationToken ct) + { + if (!_handlers.TryGetValue(parameters.Type, out IStateHandler? handler)) + { + return Result.Fail(FleetErrors.Unsupported, $"unknown state type {parameters.Type}"); + } + + return Result.Ok(await handler.ApplyAsync(parameters.Version, parameters.Doc, ct)); + } + + private ValueTask HandleAckAsync(LinkContext context, OutboxAckParams parameters, CancellationToken ct) + { + Outbox.Acknowledge(parameters.Ids.Concat(parameters.Duplicates).Select(id => Guid.TryParse(id, out Guid g) ? g : Guid.Empty)); + return ValueTask.CompletedTask; + } + + private ValueTask HandleRevokedAsync(LinkContext context, RevokedParams parameters, CancellationToken ct) + { + _stopped = true; + _log.Warn("revoked by the server", ("err", parameters.Reason)); + return ValueTask.CompletedTask; + } + + private static async Task Quiet(Task task) + { + try + { + await task; + } + catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException) + { + // 会话结束时的正常异常 + } + } +} diff --git a/backend/fleet/src/Fleet.Link/Outbox.cs b/backend/fleet/src/Fleet.Link/Outbox.cs new file mode 100644 index 0000000..3208717 --- /dev/null +++ b/backend/fleet/src/Fleet.Link/Outbox.cs @@ -0,0 +1,182 @@ +using Fleet.Rpc; + +namespace Fleet.Link; + +/// +/// 可靠发件箱:至少一次投递。消息带 msg_id(UUIDv7),服务端按它去重,持久化之后才回复 ACK。 +/// 有界(按字节和条数):满了丢弃最旧的,并累计缺口数通知产品。默认只在内存里,节点重启会丢失未上传的数据。 +/// 重新连接之后,所有还没确认的消息按原顺序重发。 +/// +public sealed class Outbox +{ + private readonly object _gate = new(); + private readonly LinkedList _entries = []; + private readonly Dictionary> _byId = []; + private readonly long _maxBytes; + private readonly int _maxCount; + private TaskCompletionSource _signal = NewSignal(); + private long _bytes; + private long _inflightBytes; + private long _dropped; + + public Outbox(long maxBytes, int maxCount = int.MaxValue) + { + _maxBytes = maxBytes; + _maxCount = maxCount; + } + + /// 因队列已满而丢弃的消息数(缺口)。 + public long Dropped => Interlocked.Read(ref _dropped); + + public long PendingBytes + { + get + { + lock (_gate) + { + return _bytes; + } + } + } + + public int PendingCount + { + get + { + lock (_gate) + { + return _entries.Count; + } + } + } + + /// 每丢弃一批消息调用一次(参数是这一批的条数)。 + public Action? OnGap { get; set; } + + public void Enqueue(byte channel, Guid msgId, ReadOnlySpan payload) + { + byte[] frame = OutboxFrame.Build(channel, msgId, payload); + int dropped = 0; + lock (_gate) + { + if (_byId.ContainsKey(msgId)) + { + return; + } + + while (_entries.Count > 0 && (_bytes + frame.Length > _maxBytes || _entries.Count >= _maxCount)) + { + Remove(_entries.First!); + dropped++; + } + + _byId[msgId] = _entries.AddLast(new Entry(msgId, frame)); + _bytes += frame.Length; + Wake(); + } + + if (dropped > 0) + { + Interlocked.Add(ref _dropped, dropped); + OnGap?.Invoke(dropped); + } + } + + /// 连接断开之后:所有消息都视为没有发出,重连时重发。 + public void ResetSent() + { + lock (_gate) + { + foreach (Entry entry in _entries) + { + entry.Sent = false; + } + + _inflightBytes = 0; + Wake(); + } + } + + public void Acknowledge(IEnumerable ids) + { + lock (_gate) + { + foreach (Guid id in ids) + { + if (_byId.TryGetValue(id, out LinkedListNode? node)) + { + Remove(node); + } + } + + Wake(); + } + } + + /// + /// 取下一条还没发出的消息;在途字节达到 时等待 ACK(服务端慢时不会把内存撑爆)。 + /// + public async ValueTask TakeNextAsync(long maxInflightBytes, CancellationToken ct) + { + while (true) + { + Task wait; + lock (_gate) + { + foreach (Entry entry in _entries) + { + if (!entry.Sent) + { + // 一条消息比在途上限还大时也要放行,否则会永远卡住 + if (_inflightBytes == 0 || _inflightBytes + entry.Frame.Length <= maxInflightBytes) + { + entry.Sent = true; + _inflightBytes += entry.Frame.Length; + return entry.Frame; + } + + break; + } + } + + wait = _signal.Task; + } + + await wait.WaitAsync(ct); + } + } + + private void Remove(LinkedListNode node) + { + _entries.Remove(node); + _byId.Remove(node.Value.Id); + _bytes -= node.Value.Frame.Length; + if (node.Value.Sent) + { + _inflightBytes = Math.Max(0, _inflightBytes - node.Value.Frame.Length); + } + } + + private void Wake() + { + _signal.TrySetResult(); + _signal = NewSignal(); + } + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class Entry + { + public Entry(Guid id, byte[] frame) + { + Id = id; + Frame = frame; + } + + public Guid Id { get; } + + public byte[] Frame { get; } + + public bool Sent { get; set; } + } +} diff --git a/backend/fleet/src/Fleet.Link/Peer.cs b/backend/fleet/src/Fleet.Link/Peer.cs new file mode 100644 index 0000000..e26dbcb --- /dev/null +++ b/backend/fleet/src/Fleet.Link/Peer.cs @@ -0,0 +1,278 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Link; + +/// 一个已经认证的点对点会话:对端是谁、数据面使用的会话凭据、对端地址。 +public sealed record PeerSessionInfo(string PeerId, SessionCredentials Credentials, EndPoint? Remote, string? Version = null); + +public sealed class PeerListenerOptions +{ + public required IPEndPoint Listen { get; init; } + + public required X509Certificate2 Certificate { get; init; } + + /// 按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。 + public required Func LookupPeerKey { get; init; } + + public int MaxFrameBytes { get; init; } = 8 << 20; + + public int MaxSessions { get; init; } = 8; + + public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10); +} + +/// +/// 监听点对点会话(Agent 一侧):TLS(自签名证书,对端按 SPKI 指纹固定)→ 挑战 → 对端用身份私钥签名(purpose = peer) +/// → 临时 ECDH 派生数据面凭据。认证通过之后把会话交给产品代码,由它挂上自己的路由。 +/// +public sealed class PeerListener +{ + private readonly PeerListenerOptions _options; + private readonly Func _onSession; + private readonly INodeLog _log; + private readonly TimeProvider _time; + private int _sessions; + + public PeerListener(PeerListenerOptions options, Func onSession, INodeLog log, TimeProvider time) + { + _options = options; + _onSession = onSession; + _log = log; + _time = time; + } + + /// 实际监听的端口(配置为 0 时由系统分配); 开始监听之后有效。 + public int Port { get; private set; } + + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task RunAsync(CancellationToken ct) + { + var listener = new TcpListener(_options.Listen); + listener.Start(); + Port = ((IPEndPoint)listener.LocalEndpoint).Port; + Started.TrySetResult(); + try + { + while (!ct.IsCancellationRequested) + { + TcpClient client = await listener.AcceptTcpClientAsync(ct); + if (Interlocked.Increment(ref _sessions) > _options.MaxSessions) + { + Interlocked.Decrement(ref _sessions); + client.Dispose(); + continue; + } + + _ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None); + } + } + catch (OperationCanceledException) + { + // 停止监听 + } + finally + { + listener.Stop(); + } + } + + private async Task HandleAsync(TcpClient client, CancellationToken ct) + { + try + { + client.NoDelay = true; + PeerSockets.EnableKeepAlive(client.Client); + using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync( + new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }, + linked.Token); + var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes); + PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token); + if (info is null) + { + await transport.DisposeAsync(); + return; + } + + _log.Info("peer session", ("probe", info.PeerId), ("result", "established")); + await using (transport) + { + await _onSession(info, transport, ct); + } + } + catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException) + { + // 握手失败或连接断开:这个会话结束,监听继续 + } + finally + { + client.Dispose(); + Interlocked.Decrement(ref _sessions); + } + } + + private async Task HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct) + { + using var ephemeral = new EphemeralKey(); + byte[] nonceBytes = RandomNumberGenerator.GetBytes(32); + string nonce = Convert.ToBase64String(nonceBytes); + string serverPub = Convert.ToBase64String(ephemeral.PublicKeySpki); + await SendAsync(transport, RpcMessage.Notification(PeerMethods.Challenge, new PeerChallengeParams { Nonce = nonce, EcdhPublicKey = serverPub }, PeerRpcContext.Default.PeerChallengeParams), ct); + + RpcFrame? frame = await transport.ReceiveAsync(ct); + RpcMessage? message = frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; + if (message?.Method != PeerMethods.Auth || message.Id is null || message.Params is null) + { + return null; + } + + PeerAuthParams? auth = null; + try + { + auth = message.Params.Value.Deserialize(PeerRpcContext.Default.PeerAuthParams); + } + catch (JsonException) + { + // 按缺失处理 + } + + byte[]? key = auth is null ? null : _options.LookupPeerKey(auth.PeerId); + byte[] clientPublic = []; + bool ok = key is not null + && TryDecode(auth!.Signature, out byte[] signature) + && TryDecode(auth.EcdhPublicKey, out clientPublic) + && NodeIdentity.Verify(key, signature, SignaturePurpose.Peer, auth.PeerId, nonce, auth.EcdhPublicKey, serverPub); + if (!ok) + { + // 对端不在允许列表里和签名错误返回同样的错误 + await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unauthorized, "peer authentication failed")), ct); + _log.Warn("peer rejected", ("result", "unauthorized")); + return null; + } + + SessionCredentials credentials; + try + { + credentials = ephemeral.Derive(clientPublic, nonceBytes, clientPublic, ephemeral.PublicKeySpki); + } + catch (CryptographicException) + { + await SendAsync(transport, RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid ecdh key")), ct); + return null; + } + + await SendAsync(transport, RpcMessage.Success(message.Id.Value, new PeerAuthResult { SessionId = Convert.ToHexStringLower(credentials.SessionId) }, PeerRpcContext.Default.PeerAuthResult), ct); + return new PeerSessionInfo(auth!.PeerId, credentials, remote, auth.Version is { Length: <= 64 } version ? version : null); + } + + private static ValueTask SendAsync(TlsFrameTransport transport, RpcMessage message, CancellationToken ct) => + transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct); + + private static bool TryDecode(string text, out byte[] data) + { + try + { + data = Convert.FromBase64String(text); + return data.Length > 0; + } + catch (FormatException) + { + data = []; + return false; + } + } +} + +/// 发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。 +/// 点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。 +internal static class PeerSockets +{ + public static void EnableKeepAlive(Socket socket) + { + try + { + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3); + } + catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) + { + // 个别平台不支持其中某个选项:退回系统默认 + } + } +} + +public static class PeerConnector +{ + public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( + string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) + { + var client = new TcpClient { NoDelay = true }; + PeerSockets.EnableKeepAlive(client.Client); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + await client.ConnectAsync(host, port, linked.Token); + var tls = new SslStream( + client.GetStream(), leaveInnerStreamOpen: false, + (_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256)); + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token); + var transport = new TlsFrameTransport(tls, maxFrameBytes); + + RpcFrame? first = await transport.ReceiveAsync(linked.Token); + RpcMessage? challengeMessage = first is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; + if (challengeMessage?.Method != PeerMethods.Challenge || challengeMessage.Params is null) + { + throw new InvalidOperationException("the agent did not send a challenge"); + } + + PeerChallengeParams challenge = challengeMessage.Params.Value.Deserialize(PeerRpcContext.Default.PeerChallengeParams)!; + using var ephemeral = new EphemeralKey(); + string clientPub = Convert.ToBase64String(ephemeral.PublicKeySpki); + string signature = Convert.ToBase64String(identity.Sign(SignaturePurpose.Peer, peerId, challenge.Nonce, clientPub, challenge.EcdhPublicKey)); + RpcMessage request = RpcMessage.Request(1, PeerMethods.Auth, new PeerAuthParams { PeerId = peerId, EcdhPublicKey = clientPub, Signature = signature, Version = version }, PeerRpcContext.Default.PeerAuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + + RpcFrame? replyFrame = await transport.ReceiveAsync(linked.Token); + RpcMessage? reply = replyFrame is { Kind: FrameKind.Text } r ? JsonSerializer.Deserialize(r.Data.Span, FleetRpcContext.Default.RpcMessage) : null; + if (reply is null || reply.Error is not null) + { + throw new InvalidOperationException("peer authentication failed: " + reply?.Error?.Data?.Code); + } + + SessionCredentials credentials = ephemeral.Derive(Convert.FromBase64String(challenge.EcdhPublicKey), Convert.FromBase64String(challenge.Nonce), ephemeral.PublicKeySpki, Convert.FromBase64String(challenge.EcdhPublicKey)); + string announced = reply.Result!.Value.Deserialize(PeerRpcContext.Default.PeerAuthResult)!.SessionId; + if (announced != Convert.ToHexStringLower(credentials.SessionId)) + { + throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials"); + } + + return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport); + } + catch + { + client.Dispose(); + throw; + } + } + + /// 固定证书:只看 SPKI 的 SHA-256 指纹,不看证书链和主机名(自签名证书)。 + private static bool SpkiMatches(X509Certificate certificate, byte[] expected) + { + X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData()); + return CryptographicOperations.FixedTimeEquals(NodeCertificate.SpkiFingerprint(full), expected); + } +} diff --git a/backend/fleet/src/Fleet.Link/Transports.cs b/backend/fleet/src/Fleet.Link/Transports.cs new file mode 100644 index 0000000..6b712c1 --- /dev/null +++ b/backend/fleet/src/Fleet.Link/Transports.cs @@ -0,0 +1,207 @@ +using System.Buffers.Binary; +using System.Net.WebSockets; +using Fleet.Rpc; + +namespace Fleet.Link; + +/// WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。 +public sealed class WsTransport : IRpcTransport +{ + private readonly WebSocket _socket; + private readonly int _maxFrameBytes; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + public WsTransport(WebSocket socket, int maxFrameBytes) + { + _socket = socket; + _maxFrameBytes = maxFrameBytes; + } + + public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct) + { + await _sendLock.WaitAsync(ct); + try + { + await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct); + } + finally + { + _sendLock.Release(); + } + } + + public async ValueTask ReceiveAsync(CancellationToken ct) + { + var message = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer.AsMemory(), ct); + if (result.MessageType == WebSocketMessageType.Close) + { + if (_socket.State == WebSocketState.CloseReceived) + { + // 按协议确认对端发起的关闭 + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None); + } + catch (WebSocketException) + { + // 对端已经断开 + } + } + + return null; + } + + message.Write(buffer, 0, result.Count); + if (message.Length > _maxFrameBytes) + { + await CloseAsync("frame too large", CancellationToken.None); + return null; + } + + if (result.EndOfMessage) + { + return new RpcFrame(result.MessageType == WebSocketMessageType.Text ? FrameKind.Text : FrameKind.Binary, message.ToArray()); + } + } + } + + public async ValueTask CloseAsync(string reason, CancellationToken ct) + { + if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, ct); + } + catch (WebSocketException) + { + // 对端已经断开 + } + } + } + + public ValueTask DisposeAsync() + { + _socket.Dispose(); + _sendLock.Dispose(); + return ValueTask.CompletedTask; + } +} + +/// +/// TLS 帧传输(Probe → Agent):u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文。 +/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。 +/// +public sealed class TlsFrameTransport : IRpcTransport +{ + private const int HeaderBytes = 5; + + private readonly Stream _stream; + private readonly int _maxFrameBytes; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + public TlsFrameTransport(Stream stream, int maxFrameBytes) + { + _stream = stream; + _maxFrameBytes = maxFrameBytes; + } + + public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct) + { + if (frame.Data.Length > _maxFrameBytes) + { + throw new InvalidOperationException("frame exceeds the maximum size"); + } + + byte[] header = new byte[HeaderBytes]; + BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length); + header[4] = (byte)frame.Kind; + await _sendLock.WaitAsync(ct); + try + { + await _stream.WriteAsync(header, ct); + await _stream.WriteAsync(frame.Data, ct); + await _stream.FlushAsync(ct); + } + finally + { + _sendLock.Release(); + } + } + + public async ValueTask ReceiveAsync(CancellationToken ct) + { + byte[] header = new byte[HeaderBytes]; + try + { + if (!await ReadExactlyOrEofAsync(header, ct)) + { + return null; + } + + uint length = BinaryPrimitives.ReadUInt32LittleEndian(header); + if (length > _maxFrameBytes || header[4] > (byte)FrameKind.Binary) + { + await _stream.DisposeAsync(); // 非法帧:直接断开 + return null; + } + + byte[] body = new byte[length]; + if (!await ReadExactlyOrEofAsync(body, ct)) + { + return null; + } + + return new RpcFrame((FrameKind)header[4], body); + } + catch (IOException) + { + return null; + } + catch (ObjectDisposedException) + { + return null; + } + } + + public async ValueTask CloseAsync(string reason, CancellationToken ct) + { + try + { + await _stream.FlushAsync(ct); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // 对端已经断开 + } + + await _stream.DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + await _stream.DisposeAsync(); + _sendLock.Dispose(); + } + + private async ValueTask ReadExactlyOrEofAsync(byte[] buffer, CancellationToken ct) + { + int read = 0; + while (read < buffer.Length) + { + int n = await _stream.ReadAsync(buffer.AsMemory(read), ct); + if (n == 0) + { + return false; + } + + read += n; + } + + return true; + } +} diff --git a/backend/fleet/src/Fleet.Rpc/Fleet.Rpc.csproj b/backend/fleet/src/Fleet.Rpc/Fleet.Rpc.csproj new file mode 100644 index 0000000..138fa6d --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/Fleet.Rpc.csproj @@ -0,0 +1,9 @@ + + + true + + + + + + diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs new file mode 100644 index 0000000..9053db9 --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Fleet.Rpc; + +/// 框架保留的方法名。fleet.* 只属于框架,产品使用自己的前缀。 +public static class FleetMethods +{ + public const string Challenge = "fleet.challenge"; + public const string Auth = "fleet.auth"; + public const string Hello = "fleet.hello"; + public const string Heartbeat = "fleet.heartbeat"; + public const string StateSync = "fleet.state.sync"; + public const string OutboxAck = "fleet.outbox.ack"; + public const string Revoked = "fleet.revoked"; +} + +/// 二进制通道:0–15 保留给框架,产品从 16 开始。 +public static class FleetChannels +{ + public const byte FirstProductChannel = 16; +} + +public sealed class ChallengeParams +{ + [JsonPropertyName("nonce")] + public string Nonce { get; set; } = ""; + + [JsonPropertyName("expires_at_ms")] + public long ExpiresAtMs { get; set; } +} + +public sealed class AuthParams +{ + [JsonPropertyName("node_id")] + public string NodeId { get; set; } = ""; + + /// 对 fleet|v1|auth|{node_id}|{nonce} 的签名(base64)。 + [JsonPropertyName("signature")] + public string Signature { get; set; } = ""; +} + +public sealed class AuthResult +{ + /// pending | active + [JsonPropertyName("status")] + public string Status { get; set; } = ""; +} + +public sealed class HelloParams +{ + [JsonPropertyName("node_version")] + public string NodeVersion { get; set; } = ""; + + [JsonPropertyName("capabilities")] + public List Capabilities { get; set; } = []; + + [JsonPropertyName("now_utc_ms")] + public long NowUtcMs { get; set; } + + /// 产品自定义的元数据(例如 Monitor 的 tls_spki、probe_ports、local_limits 摘要)。 + [JsonPropertyName("meta")] + public JsonElement? Meta { get; set; } +} + +public sealed class HelloResult +{ + [JsonPropertyName("server_time_ms")] + public long ServerTimeMs { get; set; } + + /// 节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。 + [JsonPropertyName("clock_offset_ms")] + public long ClockOffsetMs { get; set; } +} + +public sealed class HeartbeatParams +{ + [JsonPropertyName("extra")] + public JsonElement? Extra { get; set; } +} + +public sealed class StateSyncParams +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("doc")] + public JsonElement Doc { get; set; } +} + +public sealed class StateSyncResult +{ + [JsonPropertyName("applied_version")] + public long AppliedVersion { get; set; } + + [JsonPropertyName("rejected")] + public List Rejected { get; set; } = []; +} + +public sealed class RejectedItem +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// unsupported | budget_exceeded | policy_denied | … + [JsonPropertyName("reason")] + public string Reason { get; set; } = ""; +} + +public sealed class OutboxAckParams +{ + [JsonPropertyName("ids")] + public List Ids { get; set; } = []; + + [JsonPropertyName("duplicates")] + public List Duplicates { get; set; } = []; +} + +public sealed class RevokedParams +{ + [JsonPropertyName("reason")] + public string Reason { get; set; } = ""; +} + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(RpcMessage))] +[JsonSerializable(typeof(ChallengeParams))] +[JsonSerializable(typeof(AuthParams))] +[JsonSerializable(typeof(AuthResult))] +[JsonSerializable(typeof(HelloParams))] +[JsonSerializable(typeof(HelloResult))] +[JsonSerializable(typeof(HeartbeatParams))] +[JsonSerializable(typeof(StateSyncParams))] +[JsonSerializable(typeof(StateSyncResult))] +[JsonSerializable(typeof(OutboxAckParams))] +[JsonSerializable(typeof(RevokedParams))] +public sealed partial class FleetRpcContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Rpc/OutboxFrame.cs b/backend/fleet/src/Fleet.Rpc/OutboxFrame.cs new file mode 100644 index 0000000..f7794f9 --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/OutboxFrame.cs @@ -0,0 +1,35 @@ +namespace Fleet.Rpc; + +/// +/// 发件箱二进制帧:u8 channel | 16 字节 msg_id (UUIDv7) | 正文。 +/// 服务端按 msg_id 去重,持久化之后才回复 fleet.outbox.ack。 +/// +public static class OutboxFrame +{ + public const int HeaderBytes = 1 + 16; + + public static bool TryParse(ReadOnlyMemory frame, out byte channel, out Guid msgId, out ReadOnlyMemory payload) + { + if (frame.Length < HeaderBytes) + { + channel = 0; + msgId = Guid.Empty; + payload = default; + return false; + } + + channel = frame.Span[0]; + msgId = new Guid(frame.Span.Slice(1, 16), bigEndian: true); + payload = frame[HeaderBytes..]; + return true; + } + + public static byte[] Build(byte channel, Guid msgId, ReadOnlySpan payload) + { + byte[] frame = new byte[HeaderBytes + payload.Length]; + frame[0] = channel; + msgId.TryWriteBytes(frame.AsSpan(1, 16), bigEndian: true, out _); + payload.CopyTo(frame.AsSpan(HeaderBytes)); + return frame; + } +} diff --git a/backend/fleet/src/Fleet.Rpc/PeerMethods.cs b/backend/fleet/src/Fleet.Rpc/PeerMethods.cs new file mode 100644 index 0000000..948f6d5 --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/PeerMethods.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; + +namespace Fleet.Rpc; + +/// +/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥, +/// 经过 HKDF 派生出数据面使用的 session_id 与 session_key(所以不需要单独的“下发密钥”方法)。 +/// +public static class PeerMethods +{ + public const string Challenge = "fleet.peer.challenge"; + public const string Auth = "fleet.peer.auth"; +} + +public sealed class PeerChallengeParams +{ + [JsonPropertyName("nonce")] + public string Nonce { get; set; } = ""; + + /// 服务端(监听方)的临时 ECDH 公钥(SPKI,base64)。 + [JsonPropertyName("ecdh_pub")] + public string EcdhPublicKey { get; set; } = ""; +} + +public sealed class PeerAuthParams +{ + [JsonPropertyName("peer_id")] + public string PeerId { get; set; } = ""; + + /// 发起方的临时 ECDH 公钥(SPKI,base64)。 + [JsonPropertyName("ecdh_pub")] + public string EcdhPublicKey { get; set; } = ""; + + /// 对 fleet|v1|peer|{peer_id}|{nonce}|{client_ecdh_pub}|{server_ecdh_pub} 的签名(base64)。 + [JsonPropertyName("signature")] + public string Signature { get; set; } = ""; + + /// 发起方的程序版本(可选,只用于展示)。 + [JsonPropertyName("version")] + public string? Version { get; set; } +} + +public sealed class PeerAuthResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; set; } = ""; +} + +[JsonSerializable(typeof(PeerChallengeParams))] +[JsonSerializable(typeof(PeerAuthParams))] +[JsonSerializable(typeof(PeerAuthResult))] +public sealed partial class PeerRpcContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Rpc/RpcMessage.cs b/backend/fleet/src/Fleet.Rpc/RpcMessage.cs new file mode 100644 index 0000000..5a34651 --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/RpcMessage.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; + +namespace Fleet.Rpc; + +/// JSON-RPC 2.0 信封。请求、响应、通知共用一个类型,缺失的字段不序列化。 +public sealed class RpcMessage +{ + [JsonPropertyName("jsonrpc")] + public string Jsonrpc { get; set; } = "2.0"; + + [JsonPropertyName("id")] + public long? Id { get; set; } + + [JsonPropertyName("method")] + public string? Method { get; set; } + + [JsonPropertyName("params")] + public JsonElement? Params { get; set; } + + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + + [JsonPropertyName("error")] + public RpcError? Error { get; set; } + + [JsonIgnore] + public bool IsRequest => Method is not null && Id is not null; + + [JsonIgnore] + public bool IsNotification => Method is not null && Id is null; + + [JsonIgnore] + public bool IsResponse => Method is null && Id is not null; + + public static RpcMessage Request(long id, string method, T value, JsonTypeInfo typeInfo) => + new() { Id = id, Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) }; + + public static RpcMessage Notification(string method, T value, JsonTypeInfo typeInfo) => + new() { Method = method, Params = JsonSerializer.SerializeToElement(value, typeInfo) }; + + public static RpcMessage Success(long id, T value, JsonTypeInfo typeInfo) => + new() { Id = id, Result = JsonSerializer.SerializeToElement(value, typeInfo) }; + + public static RpcMessage Failure(long? id, FleetError error) => + new() { Id = id, Error = RpcError.From(error) }; +} + +public sealed class RpcError +{ + public const int MethodNotFound = -32601; + public const int InvalidParams = -32602; + public const int InternalError = -32603; + public const int ApplicationError = -32000; + + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = ""; + + [JsonPropertyName("data")] + public RpcErrorData? Data { get; set; } + + public static RpcError From(FleetError error) + { + int code = error.Code == FleetErrors.Unsupported ? MethodNotFound + : error.Code == FleetErrors.InvalidParams ? InvalidParams + : error.Code == FleetErrors.Internal ? InternalError + : ApplicationError; + return new RpcError { Code = code, Message = error.Message, Data = new RpcErrorData { Code = error.Code.Value } }; + } + + public FleetError ToFleetError() => + new(new ErrorCode(Data?.Code ?? "fleet_internal"), Message); +} + +public sealed class RpcErrorData +{ + [JsonPropertyName("code")] + public string Code { get; set; } = ""; + + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} diff --git a/backend/fleet/src/Fleet.Rpc/RpcRouter.cs b/backend/fleet/src/Fleet.Rpc/RpcRouter.cs new file mode 100644 index 0000000..eb362ac --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/RpcRouter.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; + +namespace Fleet.Rpc; + +/// 方法路由,与传输方式无关。 由传输层提供(例如调用方节点)。 +public sealed class RpcRouter +{ + private delegate ValueTask> Route(TContext context, JsonElement? parameters, CancellationToken ct); + + private static readonly JsonElement s_emptyObject = JsonDocument.Parse("{}").RootElement.Clone(); + + private readonly Dictionary _routes = new(StringComparer.Ordinal); + private readonly Action? _onUnhandled; + + public RpcRouter(Action? onUnhandled = null) + { + _onUnhandled = onUnhandled; + } + + public bool Has(string method) => _routes.ContainsKey(method); + + public IReadOnlyCollection Methods => _routes.Keys; + + public RpcRouter On( + string method, + JsonTypeInfo paramsInfo, + JsonTypeInfo resultInfo, + Func>> handler) + { + _routes[method] = async (context, raw, ct) => + { + if (!TryDeserialize(raw, paramsInfo, out TParams? p)) + { + return Result.Fail(FleetErrors.InvalidParams, "params could not be parsed"); + } + + Result r = await handler(context, p, ct); + return r.TryGet(out TResult? value, out FleetError? error) + ? Result.Ok(JsonSerializer.SerializeToElement(value, resultInfo)) + : Result.Fail(error); + }; + return this; + } + + public RpcRouter OnNotify( + string method, + JsonTypeInfo paramsInfo, + Func handler) + { + _routes[method] = async (context, raw, ct) => + { + if (!TryDeserialize(raw, paramsInfo, out TParams? p)) + { + return Result.Fail(FleetErrors.InvalidParams, "params could not be parsed"); + } + + await handler(context, p, ct); + return Result.Ok(null); + }; + return this; + } + + /// + /// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。 + /// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 onUnhandled 记录。 + /// + public async ValueTask DispatchAsync(TContext context, RpcMessage message, CancellationToken ct) + { + string method = message.Method ?? ""; + if (!_routes.TryGetValue(method, out Route? route)) + { + return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}")); + } + + Result result; + try + { + result = await route(context, message.Params, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _onUnhandled?.Invoke(method, ex); + result = Result.Fail(FleetErrors.Internal, "internal error"); + } + + if (message.Id is null) + { + return null; + } + + return result.TryGet(out JsonElement? value, out FleetError? error) + ? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject } + : RpcMessage.Failure(message.Id, error); + } + + private static bool TryDeserialize(JsonElement? raw, JsonTypeInfo info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value) + { + value = default; + try + { + value = raw is null ? JsonSerializer.Deserialize("{}", info) : raw.Value.Deserialize(info); + return value is not null; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/backend/fleet/src/Fleet.Rpc/RpcSession.cs b/backend/fleet/src/Fleet.Rpc/RpcSession.cs new file mode 100644 index 0000000..8ca127c --- /dev/null +++ b/backend/fleet/src/Fleet.Rpc/RpcSession.cs @@ -0,0 +1,232 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Channels; +using Fleet.Core; + +namespace Fleet.Rpc; + +public enum FrameKind : byte +{ + Text = 0, + Binary = 1, +} + +public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory Data); + +/// +/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。 +/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。 +/// +public interface IRpcTransport : IAsyncDisposable +{ + ValueTask SendAsync(RpcFrame frame, CancellationToken ct); + + /// 对端关闭时返回 null。 + ValueTask ReceiveAsync(CancellationToken ct); + + ValueTask CloseAsync(string reason, CancellationToken ct); +} + +public sealed class RpcSessionOptions +{ + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15); + + /// 待处理的入站请求上限;超过时接收循环等待(背压)。 + public int InboundQueue { get; set; } = 256; +} + +/// +/// 传输无关的会话:请求与响应的关联、方法路由、二进制帧。 +/// 入站的请求和通知由独立的任务按顺序处理,接收循环只负责读帧和完成响应, +/// 所以处理函数里可以等待向对端发起的请求而不会死锁。 +/// +public sealed class RpcSession : IAsyncDisposable +{ + private readonly IRpcTransport _transport; + private readonly RpcRouter _router; + private readonly TContext _context; + private readonly TimeProvider _time; + private readonly RpcSessionOptions _options; + private readonly ConcurrentDictionary> _pending = new(); + private readonly Channel _inbound; + private readonly CancellationTokenSource _stop = new(); + private long _nextId; + private long _lastReceive; + + public RpcSession(IRpcTransport transport, RpcRouter router, TContext context, TimeProvider time, RpcSessionOptions? options = null) + { + _transport = transport; + _router = router; + _context = context; + _time = time; + _lastReceive = time.GetTimestamp(); + _options = options ?? new RpcSessionOptions(); + _inbound = Channel.CreateBounded(new BoundedChannelOptions(_options.InboundQueue) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + } + + /// 收到二进制帧:u8 channel | 正文(发件箱帧再往里解析)。 + public Func, CancellationToken, ValueTask>? OnBinary { get; set; } + + /// 对端上一次发来任何帧到现在的时间。静默死亡(断电、进程被冻结)的对端不会关闭连接,调用方据此设置空闲超时。 + public TimeSpan IdleFor => _time.GetElapsedTime(Volatile.Read(ref _lastReceive)); + + /// 读帧并分发,直到连接关闭或被取消。 + public async Task RunAsync(CancellationToken ct) + { + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _stop.Token); + Task dispatcher = Task.Run(() => DispatchAsync(linked.Token), CancellationToken.None); + try + { + while (!linked.IsCancellationRequested) + { + RpcFrame? frame = await _transport.ReceiveAsync(linked.Token); + if (frame is null) + { + return; + } + + Volatile.Write(ref _lastReceive, _time.GetTimestamp()); + if (frame.Value.Kind == FrameKind.Binary) + { + if (OnBinary is not null) + { + await OnBinary(frame.Value.Data, linked.Token); + } + + continue; + } + + RpcMessage? message = Parse(frame.Value.Data); + if (message is null) + { + continue; // 格式不对的帧忽略,连接保持可用 + } + + if (message.IsResponse) + { + if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource? waiter)) + { + waiter.TrySetResult(message); + } + } + else + { + await _inbound.Writer.WriteAsync(message, linked.Token); + } + } + } + finally + { + _inbound.Writer.TryComplete(); + foreach (TaskCompletionSource waiter in _pending.Values) + { + waiter.TrySetCanceled(CancellationToken.None); + } + + await linked.CancelAsync(); + try + { + await dispatcher; + } + catch (OperationCanceledException) + { + // 正常结束 + } + } + } + + public async ValueTask> RequestAsync(string method, JsonElement? parameters, CancellationToken ct, TimeSpan? timeout = null) + { + long id = Interlocked.Increment(ref _nextId); + var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = waiter; + try + { + await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters }, ct); + using var timer = new CancellationTokenSource(timeout ?? _options.RequestTimeout, _time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timer.Token); + RpcMessage reply = await waiter.Task.WaitAsync(linked.Token); + return reply.Error is not null + ? Result.Fail(reply.Error.ToFleetError()) + : Result.Ok(reply.Result ?? default); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return Result.Fail(FleetErrors.Timeout, $"{method} timed out or the session closed"); + } + finally + { + _pending.TryRemove(id, out _); + } + } + + public async ValueTask> RequestAsync( + string method, TParams parameters, JsonTypeInfo paramsInfo, JsonTypeInfo resultInfo, CancellationToken ct, TimeSpan? timeout = null) + { + Result raw = await RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct, timeout); + if (!raw.TryGet(out JsonElement value, out FleetError? error)) + { + return Result.Fail(error); + } + + try + { + TResult? result = value.Deserialize(resultInfo); + return result is null ? Result.Fail(FleetErrors.Internal, "empty result") : Result.Ok(result); + } + catch (JsonException) + { + return Result.Fail(FleetErrors.Internal, "result could not be parsed"); + } + } + + public ValueTask NotifyAsync(string method, TParams parameters, JsonTypeInfo paramsInfo, CancellationToken ct) => + SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct); + + public ValueTask SendAsync(RpcMessage message, CancellationToken ct) => + _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct); + + public ValueTask SendBinaryAsync(ReadOnlyMemory frame, CancellationToken ct) => + _transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct); + + public ValueTask CloseAsync(string reason, CancellationToken ct) => _transport.CloseAsync(reason, ct); + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + await _transport.DisposeAsync(); + _stop.Dispose(); + } + + private async Task DispatchAsync(CancellationToken ct) + { + try + { + await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct)) + { + RpcMessage? response = await _router.DispatchAsync(_context, message, ct); + if (response is not null) + { + await SendAsync(response, ct); + } + } + } + catch (OperationCanceledException) + { + // 会话结束 + } + } + + private static RpcMessage? Parse(ReadOnlyMemory data) + { + try + { + return JsonSerializer.Deserialize(data.Span, FleetRpcContext.Default.RpcMessage); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj b/backend/fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj new file mode 100644 index 0000000..0db53b3 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj @@ -0,0 +1,9 @@ + + + true + + + + + + diff --git a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs new file mode 100644 index 0000000..fd99ac7 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs @@ -0,0 +1,15 @@ +namespace Fleet.Server; + +public sealed class FleetServerOptions +{ + /// 节点 ID 的类型前缀,Monitor 使用 agt。 + public string NodeIdPrefix { get; set; } = "node"; + + public int MaxFrameBytes { get; set; } = 16 << 20; + + public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45); + + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15); +} diff --git a/backend/fleet/src/Fleet.Server.Abstractions/IFleetStore.cs b/backend/fleet/src/Fleet.Server.Abstractions/IFleetStore.cs new file mode 100644 index 0000000..ed00273 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Abstractions/IFleetStore.cs @@ -0,0 +1,21 @@ +using Fleet.Core; + +namespace Fleet.Server; + +/// Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。 +public interface IFleetStore +{ + ValueTask FindJoinTokenAsync(ReadOnlyMemory tokenSha256, CancellationToken ct); + + /// 原子地把令牌使用次数加一。令牌已吊销、过期或用完时返回 false。 + ValueTask TryConsumeJoinTokenAsync(string tokenId, long nowMs, CancellationToken ct); + + ValueTask> CreateNodeAsync(NewNode node, CancellationToken ct); + + ValueTask GetNodeAsync(NodeId id, CancellationToken ct); + + /// 按名称查找状态不是 pending 的节点(用于判断注册时的名称冲突)。 + ValueTask FindEstablishedNodeByNameAsync(string name, CancellationToken ct); + + ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct); +} diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs new file mode 100644 index 0000000..e8d8427 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -0,0 +1,48 @@ +using Fleet.Core; + +namespace Fleet.Server; + +/// pending 能连接,但不下发状态、不接收上报;disabled 拒绝连接。 +public enum NodeStatus +{ + Pending, + Active, + Disabled, +} + +public sealed record NodeRecord( + NodeId Id, + string Name, + NodeStatus Status, + byte[] PublicKey, + byte[] TlsSpki, + IReadOnlyList Labels, + string? MetaJson, + string? NodeVersion, + long CreatedAtMs, + long? LastSeenAtMs); + +public sealed record NewNode( + NodeId Id, + string Name, + NodeStatus Status, + byte[] PublicKey, + byte[] TlsSpki, + IReadOnlyList Labels, + string? MetaJson, + string? NodeVersion, + long NowMs); + +public sealed record JoinTokenRecord( + string Id, + byte[] TokenSha256, + string? Note, + IReadOnlyList Labels, + bool AutoApprove, + int MaxUses, + int Uses, + long ExpiresAtMs, + long? RevokedAtMs, + long CreatedAtMs); + +public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs new file mode 100644 index 0000000..72c567a --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Rpc; + +namespace Fleet.Server; + +public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); + +/// 产品挂钩:节点注册成功之后调用。 +public interface IEnrollmentHook +{ + ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct); +} + +public enum NodeEventKind +{ + Online, + Offline, + Hello, + Heartbeat, +} + +public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null); + +public interface INodeEventSink +{ + ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct); +} + +/// 期望状态提供者:为每个节点计算期望状态的完整快照(天然幂等)。 +public interface IStateProvider +{ + /// 状态文档类型,例如 task_set。 + string Type { get; } + + ValueTask GetAsync(NodeRecord node, CancellationToken ct); + + /// 节点应用之后回调,rejected[] 带结构化原因。 + ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct); +} + +public sealed record StateDocument(long Version, JsonElement Doc); + +public enum InboxOutcome +{ + Stored, + Duplicate, + Rejected, +} + +/// 收件箱:持久化之后才返回 Stored,框架随后才回复 ACK。 +public interface IInbox +{ + byte Channel { get; } + + ValueTask HandleAsync(NodeRecord node, Guid msgId, ReadOnlyMemory payload, CancellationToken ct); +} + +/// 通知 Fleet:某个节点的期望状态变了,需要重新计算并推送。 +public interface IStateNotifier +{ + void NotifyChanged(NodeId node); + + void NotifyAllChanged(); +} diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs new file mode 100644 index 0000000..c079eea --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Fleet.Server; + +public sealed class EnrollRequest +{ + [JsonPropertyName("join_token")] + public string JoinToken { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// 身份公钥(SPKI,base64)。 + [JsonPropertyName("pubkey")] + public string PublicKey { get; set; } = ""; + + /// 面向对端节点的 TLS 证书指纹(SHA-256,base64)。 + [JsonPropertyName("tls_spki")] + public string TlsSpki { get; set; } = ""; + + /// 持有证明:对 fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey} 的签名(base64)。 + [JsonPropertyName("proof")] + public string Proof { get; set; } = ""; + + [JsonPropertyName("node_version")] + public string NodeVersion { get; set; } = ""; + + [JsonPropertyName("meta")] + public JsonElement? Meta { get; set; } +} + +public sealed class EnrollResponse +{ + [JsonPropertyName("node_id")] + public string NodeId { get; set; } = ""; + + /// pending | active + [JsonPropertyName("status")] + public string Status { get; set; } = ""; +} + +/// RFC 9457 problem+json,带 code 扩展字段。 +public sealed class FleetProblem +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "about:blank"; + + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("code")] + public string Code { get; set; } = ""; + + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(EnrollRequest))] +[JsonSerializable(typeof(EnrollResponse))] +[JsonSerializable(typeof(FleetProblem))] +internal sealed partial class EnrollmentJsonContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj b/backend/fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj new file mode 100644 index 0000000..718e65e --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj @@ -0,0 +1,11 @@ + + + Library + true + true + + + + + + diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs new file mode 100644 index 0000000..730cf82 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Text.Json; +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Fleet.Server; + +public static class FleetEnrollmentEndpoints +{ + private const int MaxBodyBytes = 64 * 1024; + + /// 注册端点:POST {path},使用加入令牌认证。需要在 DI 中注册 。 + public static IEndpointRouteBuilder MapFleetEnrollment(this IEndpointRouteBuilder app, string path) + { + app.MapPost(path, HandleAsync) + .WithName("fleet_enroll") + .WithTags("agent") + .WithSummary("Enroll a node with a join token") + .DisableAntiforgery(); + return app; + } + + private static async Task HandleAsync(HttpContext http, FleetEnrollmentService service) + { + EnrollRequest? request; + try + { + request = await ReadBodyAsync(http, http.RequestAborted); + } + catch (JsonException) + { + request = null; + } + + if (request is null) + { + return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid"); + } + + Result result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted); + if (result.TryGet(out EnrollResponse? response, out FleetError? error)) + { + return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created); + } + + int status = error.Code == FleetErrors.JoinTokenInvalid ? StatusCodes.Status401Unauthorized + : error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone + : error.Code == FleetErrors.ProofInvalid ? StatusCodes.Status403Forbidden + : error.Code == FleetErrors.NameConflict ? StatusCodes.Status409Conflict + : error.Code == FleetErrors.InvalidParams ? StatusCodes.Status400BadRequest + : StatusCodes.Status500InternalServerError; + return Problem(status, error.Code, error.Message); + } + + private static async Task ReadBodyAsync(HttpContext http, CancellationToken ct) + { + if (http.Request.ContentLength > MaxBodyBytes) + { + return null; + } + + using var buffer = new MemoryStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = await http.Request.Body.ReadAsync(chunk, ct)) > 0) + { + buffer.Write(chunk, 0, read); + if (buffer.Length > MaxBodyBytes) + { + return null; + } + } + + return JsonSerializer.Deserialize(buffer.ToArray(), EnrollmentJsonContext.Default.EnrollRequest); + } + + private static IResult Problem(int status, ErrorCode code, string detail) => + Results.Json( + new FleetProblem { Title = ((HttpStatusCode)status).ToString(), Status = status, Code = code.Value, Detail = detail }, + EnrollmentJsonContext.Default.FleetProblem, + contentType: "application/problem+json", + statusCode: status); +} + +public static class FleetEnrollmentServiceCollectionExtensions +{ + public static IServiceCollection AddFleetEnrollment(this IServiceCollection services) => + services.AddSingleton(); +} diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs new file mode 100644 index 0000000..ca59cc4 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; +using Fleet.Core; +using Fleet.Identity; + +namespace Fleet.Server; + +/// 注册流程:校验令牌 → 校验持有证明 → 创建节点 → 触发产品挂钩。与 HTTP 无关,便于测试。 +public sealed partial class FleetEnrollmentService +{ + private readonly IFleetStore _store; + private readonly IEnumerable _hooks; + private readonly FleetServerOptions _options; + private readonly TimeProvider _time; + + public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, FleetServerOptions options, TimeProvider time) + { + _store = store; + _hooks = hooks; + _options = options; + _time = time; + } + + public async ValueTask> EnrollAsync(EnrollRequest request, string? remoteIp, CancellationToken ct) + { + if (string.IsNullOrEmpty(request.JoinToken) || !NamePattern().IsMatch(request.Name)) + { + return Result.Fail(FleetErrors.InvalidParams, "join_token and a valid name are required"); + } + + if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey) + || !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32 + || !TryDecode(request.Proof, out byte[] proof)) + { + return Result.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed"); + } + + byte[] tokenHash = TokenHash.Sha256(request.JoinToken); + JoinTokenRecord? token = await _store.FindJoinTokenAsync(tokenHash, ct); + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); + } + + if (token.ExpiresAtMs <= nowMs) + { + return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); + } + + if (!NodeIdentity.Verify(publicKey, proof, SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), request.PublicKey)) + { + return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify"); + } + + if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct)) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); + } + + // 同名节点已存在时进入 pending,由管理员选择“接管”或“作为新节点”。 + bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null; + NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending; + + var node = new NewNode( + new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)), + request.Name, + status, + publicKey, + tlsSpki, + token.Labels, + request.Meta?.GetRawText(), + request.NodeVersion, + nowMs); + + Result created = await _store.CreateNodeAsync(node, ct); + if (!created.TryGet(out NodeRecord? record, out FleetError? error)) + { + return Result.Fail(error); + } + + var context = new EnrollmentContext(remoteIp, request.Meta); + foreach (IEnrollmentHook hook in _hooks) + { + await hook.OnEnrolledAsync(record, context, ct); + } + + return Result.Ok(new EnrollResponse + { + NodeId = record.Id.Value, + Status = record.Status == NodeStatus.Active ? "active" : "pending", + }); + } + + private static bool TryDecode(string text, out byte[] data) + { + try + { + data = Convert.FromBase64String(text); + return data.Length > 0; + } + catch (FormatException) + { + data = []; + return false; + } + } + + [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] + private static partial Regex NamePattern(); +} diff --git a/backend/fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj b/backend/fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj new file mode 100644 index 0000000..718e65e --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj @@ -0,0 +1,11 @@ + + + Library + true + true + + + + + + diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs new file mode 100644 index 0000000..d2bf663 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -0,0 +1,170 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; +using Fleet.Rpc; +using Microsoft.Extensions.Logging; + +namespace Fleet.Server; + +/// +/// 服务端连接宿主:在线表 + 服务端发起的调用 + 状态推送通知。每个 WebSocket 对应一个 。 +/// +public sealed class FleetLinkHost : IFleetConnections, IStateNotifier +{ + private readonly ConcurrentDictionary _connections = new(); + + internal FleetLinkHost( + IFleetStore store, + FleetServerOptions options, + TimeProvider time, + ILogger logger, + IEnumerable stateProviders, + IEnumerable inboxes, + IEnumerable sinks, + FleetRouter router) + { + Store = store; + Options = options; + Time = time; + Logger = logger; + StateProviders = [.. stateProviders]; + Inboxes = inboxes.ToDictionary(i => i.Channel); + Sinks = [.. sinks]; + Router = router.Inner; + } + + internal IFleetStore Store { get; } + + internal FleetServerOptions Options { get; } + + internal TimeProvider Time { get; } + + internal ILogger Logger { get; } + + internal IReadOnlyList StateProviders { get; } + + internal IReadOnlyDictionary Inboxes { get; } + + internal IReadOnlyList Sinks { get; } + + internal RpcRouter Router { get; } + + public IReadOnlyCollection OnlineNodes => [.. _connections.Keys]; + + public bool IsOnline(NodeId node) => _connections.ContainsKey(node); + + public NodeSessionInfo? GetSession(NodeId node) => + _connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null; + + /// 处理一条 WebSocket 连接,直到连接结束。 + public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct) + { + using var connection = new NodeConnection(this, socket); + await connection.RunAsync(ct); + } + + /// 返回被替换掉的旧连接(如果有)。 + internal NodeConnection? Register(NodeConnection connection) + { + NodeConnection? replaced = null; + _connections.AddOrUpdate( + connection.NodeId, + connection, + (_, existing) => + { + replaced = existing; + return connection; + }); + return replaced; + } + + internal void Unregister(NodeConnection connection) => + _connections.TryRemove(new KeyValuePair(connection.NodeId, connection)); + + public async ValueTask> RequestAsync( + NodeId node, + string method, + TParams parameters, + JsonTypeInfo paramsInfo, + JsonTypeInfo resultInfo, + CancellationToken ct) + { + if (!_connections.TryGetValue(node, out NodeConnection? connection)) + { + return Result.Fail(FleetErrors.NodeOffline, "node is not connected"); + } + + Result raw = await connection.RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct); + if (!raw.TryGet(out JsonElement value, out FleetError? error)) + { + return Result.Fail(error); + } + + try + { + TResult? result = value.Deserialize(resultInfo); + return result is null + ? Result.Fail(FleetErrors.Internal, "empty result") + : Result.Ok(result); + } + catch (JsonException) + { + return Result.Fail(FleetErrors.Internal, "result could not be parsed"); + } + } + + public async ValueTask NotifyAsync(NodeId node, string method, TParams parameters, JsonTypeInfo paramsInfo, CancellationToken ct) + { + if (!_connections.TryGetValue(node, out NodeConnection? connection)) + { + return false; + } + + return await connection.SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct); + } + + public async ValueTask CloseAllAsync(string reason) + { + foreach (NodeConnection connection in _connections.Values.ToArray()) + { + await connection.CloseAsync(reason); + } + } + + public async ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct) + { + if (_connections.TryGetValue(node, out NodeConnection? connection)) + { + await connection.RevokeAsync(reason, ct); + } + } + + public void NotifyChanged(NodeId node) + { + if (_connections.TryGetValue(node, out NodeConnection? connection)) + { + connection.SignalStateChanged(); + } + } + + public void NotifyAllChanged() + { + foreach (NodeConnection connection in _connections.Values) + { + connection.SignalStateChanged(); + } + } +} + +/// 产品注册的 RPC 方法路由。 +public sealed class FleetRouter +{ + public FleetRouter(RpcRouter inner) + { + Inner = inner; + } + + internal RpcRouter Inner { get; } +} diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLog.cs b/backend/fleet/src/Fleet.Server.Link/FleetLog.cs new file mode 100644 index 0000000..3c80dd2 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/FleetLog.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Logging; + +namespace Fleet.Server; + +internal static partial class FleetLog +{ + [LoggerMessage(Level = LogLevel.Information, Message = "node connected node={Node} status={Status}")] + public static partial void Connected(ILogger logger, string node, string status); + + [LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")] + public static partial void Disconnected(ILogger logger, string node, string reason); + + [LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")] + public static partial void AuthRejected(ILogger logger, string err); + + [LoggerMessage(Level = LogLevel.Warning, Message = "inbox failed node={Node} channel={Channel}")] + public static partial void InboxFailed(ILogger logger, Exception exception, string node, byte channel); + + [LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")] + public static partial void StatePushFailed(ILogger logger, string node, string type, string err); + + [LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")] + public static partial void HandlerFailed(ILogger logger, Exception exception, string method); + + [LoggerMessage(Level = LogLevel.Error, Message = "event sink failed kind={Kind} node={Node}")] + public static partial void SinkFailed(ILogger logger, Exception exception, string kind, string node); +} diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs new file mode 100644 index 0000000..7141df9 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -0,0 +1,112 @@ +using System.Diagnostics.CodeAnalysis; +using Fleet.Rpc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Fleet.Server; + +/// Fleet 服务端的接入点:产品在这里挂上自己的状态、收件箱、事件和 RPC 方法。 +public sealed class FleetServerBuilder +{ + private readonly List, IServiceProvider>> _routeConfigurators = []; + + internal FleetServerBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + internal IReadOnlyList, IServiceProvider>> RouteConfigurators => _routeConfigurators; + + public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>() + where TProvider : class, IStateProvider + { + Services.AddSingleton(); + return this; + } + + public FleetServerBuilder AddInbox<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TInbox>() + where TInbox : class, IInbox + { + Services.AddSingleton(); + return this; + } + + public FleetServerBuilder OnNodeEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSink>() + where TSink : class, INodeEventSink + { + Services.AddSingleton(); + return this; + } + + public FleetServerBuilder OnEnrolled<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THook>() + where THook : class, IEnrollmentHook + { + Services.AddSingleton(); + return this; + } + + /// 注册产品的 RPC 方法(monitor.* 等)。处理函数通过 在调用时解析服务,避免构造期的循环依赖。 + public FleetServerBuilder MapMethods(Action, IServiceProvider> configure) + { + _routeConfigurators.Add(configure); + return this; + } +} + +public static class FleetServerExtensions +{ + /// 需要事先在 DI 中注册 。 + public static FleetServerBuilder AddFleetServer(this IServiceCollection services, Action? configure = null) + { + var options = new FleetServerOptions(); + configure?.Invoke(options); + services.AddSingleton(options); + + var builder = new FleetServerBuilder(services); + services.AddSingleton(provider => + { + ILogger logger = provider.GetRequiredService().CreateLogger("Fleet.Rpc"); + var router = new RpcRouter((method, ex) => FleetLog.HandlerFailed(logger, ex, method)); + foreach (Action, IServiceProvider> configurator in builder.RouteConfigurators) + { + configurator(router, provider); + } + + return new FleetRouter(router); + }); + services.AddSingleton(provider => new FleetLinkHost( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetServices(), + provider.GetServices(), + provider.GetServices(), + provider.GetRequiredService())); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + return builder; + } + + /// 映射 WebSocket 端点。调用方需要先 app.UseWebSockets()。 + public static IEndpointRouteBuilder MapFleetLink(this IEndpointRouteBuilder app, string path) + { + app.Map(path, async (HttpContext http) => + { + if (!http.WebSockets.IsWebSocketRequest) + { + http.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync(); + await http.RequestServices.GetRequiredService().RunConnectionAsync(socket, http.RequestAborted); + }); + return app; + } +} diff --git a/backend/fleet/src/Fleet.Server.Link/IFleetConnections.cs b/backend/fleet/src/Fleet.Server.Link/IFleetConnections.cs new file mode 100644 index 0000000..6dbb333 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/IFleetConnections.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; + +namespace Fleet.Server; + +/// 节点的会话信息(来自 fleet.hello)。 +public sealed record NodeSessionInfo( + NodeId NodeId, + long ConnectedAtMs, + string? NodeVersion, + IReadOnlyList Capabilities, + string? HelloMetaJson); + +/// 在线表,以及服务端向节点发起的调用。 +public interface IFleetConnections +{ + IReadOnlyCollection OnlineNodes { get; } + + bool IsOnline(NodeId node); + + NodeSessionInfo? GetSession(NodeId node); + + /// 向节点发起请求。节点不在线时返回 fleet_node_offline;超时返回 fleet_timeout。 + ValueTask> RequestAsync( + NodeId node, + string method, + TParams parameters, + JsonTypeInfo paramsInfo, + JsonTypeInfo resultInfo, + CancellationToken ct); + + ValueTask NotifyAsync(NodeId node, string method, TParams parameters, JsonTypeInfo paramsInfo, CancellationToken ct); + + /// 断开节点的连接(先发送 fleet.revoked)。 + ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct); + + /// + /// 服务端要停止了:对所有在线节点做 Close 握手,不发送 fleet.revoked(节点会按退避重连,等服务端回来)。 + /// 长连接不主动关闭的话,宿主的有序停止要等满超时(30 秒)才会退出。 + /// + ValueTask CloseAllAsync(string reason); +} + +/// 调用方信息,传给产品注册的 RPC 处理函数。 +public sealed record NodeCallContext(NodeRecord Node, NodeSessionInfo Session, IFleetConnections Connections); diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs new file mode 100644 index 0000000..fca7c46 --- /dev/null +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs @@ -0,0 +1,729 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Threading.Channels; +using System.Text.Json; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Server; + +/// +/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。 +/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。 +/// +internal sealed class NodeConnection : IDisposable +{ + private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60); + private static readonly TimeSpan s_statePushRetry = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2); + + private readonly FleetLinkHost _host; + private readonly WebSocket _socket; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ConcurrentDictionary> _pending = new(); + private readonly Channel _stateSignal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + private readonly Dictionary _lastPushedVersion = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _lifetime = new(); + private long _nextRequestId; + private long _lastPresenceTicks; + private NodeRecord? _node; + private NodeSessionInfo? _session; + private string? _closeReason; + private bool _helloDone; + + public NodeConnection(FleetLinkHost host, WebSocket socket) + { + _host = host; + _socket = socket; + } + + public NodeId NodeId => _node!.Id; + + public NodeSessionInfo Session => _session!; + + public async Task RunAsync(CancellationToken ct) + { + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token); + CancellationToken token = linked.Token; + bool registered = false; + Task? pusher = null; + try + { + if (!await AuthenticateAsync(token)) + { + return; + } + + NodeConnection? old = _host.Register(this); + registered = true; + if (old is not null) + { + await old.CloseAsync("replaced"); + } + + string status = StatusName(_node!.Status); + FleetLog.Connected(_host.Logger, _node.Id.Value, status); + await UpdatePresenceAsync(online: true, token); + await FireAsync(NodeEventKind.Online, token); + + pusher = Task.Run(() => StatePushLoopAsync(token), CancellationToken.None); + await ReceiveLoopAsync(token); + } + catch (OperationCanceledException) + { + // 关闭原因由触发取消的一方设置 + } + catch (WebSocketException) + { + _closeReason = "connection lost"; + } + finally + { + await _lifetime.CancelAsync(); + if (pusher is not null) + { + try + { + await pusher; + } + catch (OperationCanceledException) + { + // 正常结束 + } + } + + foreach (TaskCompletionSource waiter in _pending.Values) + { + waiter.TrySetCanceled(CancellationToken.None); + } + + if (registered) + { + _host.Unregister(this); + FleetLog.Disconnected(_host.Logger, _node!.Id.Value, _closeReason ?? "closed"); + + // 被新连接替换时,在线状态属于新连接。 + if (!_host.IsOnline(_node.Id)) + { + await UpdatePresenceAsync(online: false, CancellationToken.None); + await FireAsync(NodeEventKind.Offline, CancellationToken.None); + } + } + + if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + // 完整的关闭握手:发送 Close 并等待对端确认(有上限),这样对端能收到关闭原因 + try + { + using var closeTimeout = new CancellationTokenSource(s_closeTimeout, _host.Time); + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, _closeReason ?? "closed", closeTimeout.Token); + } + catch (WebSocketException) + { + // 对端已经断开 + } + catch (OperationCanceledException) + { + // 对端没有在时限内确认:不再等待 + } + } + + } + } + + public void Dispose() + { + _lifetime.Dispose(); + _sendLock.Dispose(); + } + + public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true); + + /// + /// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。 + /// 注意不能直接取消接收的令牌——取消 ReceiveAsync 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。 + /// + public async ValueTask CloseAsync(string reason) + { + _closeReason ??= reason; + try + { + await _sendLock.WaitAsync(CancellationToken.None); + } + catch (ObjectDisposedException) + { + return; + } + + try + { + if (_socket.State == WebSocketState.Open) + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None); + } + } + catch (WebSocketException) + { + // 对端已经断开 + } + finally + { + _sendLock.Release(); + } + + _lifetime.CancelAfter(s_closeTimeout); + } + + public async ValueTask RevokeAsync(string reason, CancellationToken ct) + { + await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct); + await CloseAsync(reason); + } + + public async ValueTask SendAsync(RpcMessage message, CancellationToken ct) + { + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage); + try + { + await _sendLock.WaitAsync(ct); + } + catch (ObjectDisposedException) + { + return false; + } + + try + { + if (_socket.State != WebSocketState.Open) + { + return false; + } + + await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + return true; + } + catch (WebSocketException) + { + return false; + } + finally + { + _sendLock.Release(); + } + } + + public async ValueTask> RequestAsync(string method, JsonElement parameters, CancellationToken ct) + { + long id = Interlocked.Increment(ref _nextRequestId); + var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = waiter; + try + { + var request = new RpcMessage { Id = id, Method = method, Params = parameters }; + if (!await SendAsync(request, ct)) + { + return Result.Fail(FleetErrors.NodeOffline, "node is not connected"); + } + + using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + RpcMessage response = await waiter.Task.WaitAsync(linked.Token); + return response.Error is not null + ? Result.Fail(response.Error.ToFleetError()) + : Result.Ok(response.Result ?? default); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return Result.Fail(FleetErrors.Timeout, $"{method} timed out"); + } + finally + { + _pending.TryRemove(id, out _); + } + } + + // ───────────────────────── 认证 ───────────────────────── + + private async Task AuthenticateAsync(CancellationToken ct) + { + string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds(); + await SendAsync( + RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams), + ct); + + using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + Frame? frame; + try + { + frame = await ReceiveFrameAsync(linked.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + await RejectAsync(null, "auth timeout", ct); + return false; + } + + if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null) + { + await RejectAsync(null, "expected fleet.auth", ct); + return false; + } + + AuthParams? auth = null; + try + { + auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams); + } + catch (JsonException) + { + // 按缺失处理 + } + + NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct); + bool signatureOk = node is not null + && _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs + && TryDecode(auth!.Signature, out byte[] signature) + && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce); + if (!signatureOk) + { + // 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。 + await RejectAsync(message.Id, "authentication failed", ct); + return false; + } + + _node = node; + if (node!.Status == NodeStatus.Disabled) + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct); + await RevokeAsync("disabled", ct); + return false; + } + + _session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null); + await SendAsync( + RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult), + ct); + return true; + } + + private async Task RejectAsync(long? id, string reason, CancellationToken ct) + { + FleetLog.AuthRejected(_host.Logger, reason); + _closeReason ??= reason; + if (id is not null) + { + await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct); + } + + if (_socket.State == WebSocketState.Open) + { + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct); + } + catch (WebSocketException) + { + // 对端已经断开 + } + } + } + + // ───────────────────────── 接收循环 ───────────────────────── + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token); + Frame? frame; + try + { + frame = await ReceiveFrameAsync(linked.Token); + } + catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested) + { + _closeReason ??= "heartbeat timeout"; + return; + } + + if (frame is null) + { + _closeReason ??= "closed by peer"; + return; + } + + if (frame.IsText) + { + if (TryParse(frame.Data, out RpcMessage? message)) + { + await HandleMessageAsync(message, ct); + } + } + else + { + await HandleBinaryAsync(frame.Data, ct); + } + } + } + + private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct) + { + if (message.IsResponse) + { + if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource? waiter)) + { + waiter.TrySetResult(message); + } + + return; + } + + switch (message.Method) + { + case FleetMethods.Hello: + await HandleHelloAsync(message, ct); + return; + case FleetMethods.Heartbeat: + await HandleHeartbeatAsync(message, ct); + return; + } + + if (_node!.Status != NodeStatus.Active) + { + if (message.Id is not null) + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct); + } + + return; + } + + var context = new NodeCallContext(_node, _session!, _host); + RpcMessage? response; + try + { + response = await _host.Router.DispatchAsync(context, message, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + + if (response is not null) + { + await SendAsync(response, ct); + } + } + + private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct) + { + HelloParams? hello = null; + try + { + hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams); + } + catch (JsonException) + { + // 按缺失处理 + } + + if (hello is null) + { + if (message.Id is not null) + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct); + } + + return; + } + + long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds(); + _session = _session! with + { + NodeVersion = hello.NodeVersion, + Capabilities = hello.Capabilities, + HelloMetaJson = hello.Meta?.GetRawText(), + }; + _node = _node! with { NodeVersion = hello.NodeVersion }; + _helloDone = true; + + await UpdatePresenceAsync(online: true, ct); + await FireAsync(NodeEventKind.Hello, ct, hello); + + if (message.Id is not null) + { + await SendAsync( + RpcMessage.Success( + message.Id.Value, + new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs }, + FleetRpcContext.Default.HelloResult), + ct); + } + + SignalStateChanged(); + } + + private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct) + { + JsonElement? extra = null; + try + { + extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra; + } + catch (JsonException) + { + // 忽略格式不正确的附加字段 + } + + long now = _host.Time.GetTimestamp(); + if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh) + { + await UpdatePresenceAsync(online: true, ct); + } + + await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra); + if (message.Id is not null) + { + await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct); + } + } + + private async Task HandleBinaryAsync(byte[] data, CancellationToken ct) + { + if (_node!.Status != NodeStatus.Active + || !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory payload) + || !_host.Inboxes.TryGetValue(channel, out IInbox? inbox)) + { + return; + } + + InboxOutcome outcome; + try + { + outcome = await inbox.HandleAsync(_node, msgId, payload, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // 没有持久化就不确认,节点会重发。 + FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel); + return; + } + + if (outcome == InboxOutcome.Rejected) + { + return; + } + + var ack = new OutboxAckParams(); + string id = msgId.ToString(); + if (outcome == InboxOutcome.Duplicate) + { + ack.Duplicates.Add(id); + } + else + { + ack.Ids.Add(id); + } + + await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct); + } + + // ───────────────────────── 状态推送 ───────────────────────── + + private async Task StatePushLoopAsync(CancellationToken ct) + { + while (await _stateSignal.Reader.WaitToReadAsync(ct)) + { + _stateSignal.Reader.TryRead(out _); + bool complete = await PushStateAsync(ct); + if (!complete) + { + await Task.Delay(s_statePushRetry, _host.Time, ct); + SignalStateChanged(); + } + } + } + + private async Task PushStateAsync(CancellationToken ct) + { + NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct); + if (fresh is null) + { + return true; + } + + _node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion }; + if (fresh.Status == NodeStatus.Disabled) + { + await RevokeAsync("disabled", ct); + return true; + } + + if (fresh.Status != NodeStatus.Active || !_helloDone) + { + // pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。 + return true; + } + + bool complete = true; + foreach (IStateProvider provider in _host.StateProviders) + { + StateDocument? document = await provider.GetAsync(_node, ct); + if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version)) + { + continue; + } + + Result raw = await RequestAsync( + FleetMethods.StateSync, + JsonSerializer.SerializeToElement( + new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc }, + FleetRpcContext.Default.StateSyncParams), + ct); + if (!raw.TryGet(out JsonElement value, out FleetError? error)) + { + FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value); + complete = false; + continue; + } + + StateSyncResult? result = null; + try + { + result = value.Deserialize(FleetRpcContext.Default.StateSyncResult); + } + catch (JsonException) + { + // 按缺失处理 + } + + if (result is null) + { + complete = false; + continue; + } + + _lastPushedVersion[provider.Type] = document.Version; + await provider.OnAppliedAsync(_node, document.Version, result, ct); + } + + return complete; + } + + // ───────────────────────── 帮助方法 ───────────────────────── + + private async Task UpdatePresenceAsync(bool online, CancellationToken ct) + { + Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp()); + await _host.Store.UpdatePresenceAsync( + _node!.Id, + new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson), + ct); + } + + private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null) + { + var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra); + foreach (INodeEventSink sink in _host.Sinks) + { + try + { + await sink.OnEventAsync(nodeEvent, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // 产品的事件处理失败不能影响连接。 + FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value); + } + } + } + + private async ValueTask ReceiveFrameAsync(CancellationToken ct) + { + var writer = new ArrayBufferWriter(1024); + while (true) + { + Memory buffer = writer.GetMemory(4096); + ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + return null; + } + + writer.Advance(result.Count); + if (writer.WrittenCount > _host.Options.MaxFrameBytes) + { + _closeReason ??= "frame too large"; + await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None); + return null; + } + + if (result.EndOfMessage) + { + return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray()); + } + } + } + + private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message) + { + try + { + message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage); + return message is not null; + } + catch (JsonException) + { + message = null; + return false; + } + } + + private static bool TryDecode(string text, out byte[] data) + { + try + { + data = Convert.FromBase64String(text); + return true; + } + catch (FormatException) + { + data = []; + return false; + } + } + + private static string StatusName(NodeStatus status) => status switch + { + NodeStatus.Active => "active", + NodeStatus.Pending => "pending", + _ => "disabled", + }; + + private sealed record Frame(bool IsText, byte[] Data); +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))] +internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext; + +internal sealed class EmptyObject; + +internal static class FleetEmptyObject +{ + public static System.Text.Json.Serialization.Metadata.JsonTypeInfo Info => FleetEmptyObjectContext.Default.EmptyObject; +} diff --git a/backend/fleet/tests/Fleet.Tests/CoreTests.cs b/backend/fleet/tests/Fleet.Tests/CoreTests.cs new file mode 100644 index 0000000..5cfe9d9 --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/CoreTests.cs @@ -0,0 +1,123 @@ +using Fleet.Core; + +namespace Fleet.Tests; + +public sealed class CoreTests +{ + [Theory] + [InlineData(0UL)] + [InlineData(1UL)] + [InlineData(127UL)] + [InlineData(128UL)] + [InlineData(300UL)] + [InlineData(ulong.MaxValue)] + public void VarInt_RoundTrips(ulong value) + { + Span buffer = stackalloc byte[VarInt.MaxBytes]; + int written = VarInt.Write(buffer, value); + Assert.True(VarInt.TryRead(buffer[..written], out ulong read, out int consumed)); + Assert.Equal(value, read); + Assert.Equal(written, consumed); + } + + [Fact] + public void VarInt_RejectsTruncatedAndOverlongInput() + { + Assert.False(VarInt.TryRead([0x80], out _, out _)); + Assert.False(VarInt.TryRead([], out _, out _)); + byte[] overlong = [.. Enumerable.Repeat((byte)0x80, 11), 0x01]; + Assert.False(VarInt.TryRead(overlong, out _, out _)); + } + + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData(1L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + public void ZigZag_RoundTrips_AndKeepsSmallMagnitudesSmall(long value) + { + Assert.Equal(value, VarInt.UnZigZag(VarInt.ZigZag(value))); + if (value is >= -63 and <= 63) + { + Assert.True(VarInt.ZigZag(value) < 128); + } + } + + [Fact] + public void Ulid_IsTwentySixCharacters_SortsByTime_AndDoesNotRepeat() + { + var time = new FakeClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + string a = Ulid.New(time); + time.Advance(TimeSpan.FromMilliseconds(5)); + string b = Ulid.New(time); + Assert.Equal(26, a.Length); + Assert.True(string.CompareOrdinal(a, b) < 0); + Assert.Equal(1000, Enumerable.Range(0, 1000).Select(_ => Ulid.New(time)).Distinct().Count()); + Assert.StartsWith("agt_", Ulid.NewPrefixed("agt", time)); + } + + [Fact] + public void Tokens_AreHighEntropy_AndOnlyTheHashIsComparable() + { + string a = TokenHash.NewToken("jt"); + string b = TokenHash.NewToken("jt"); + Assert.NotEqual(a, b); + Assert.StartsWith("jt_", a); + Assert.Equal(3 + 43, a.Length); + Assert.True(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(a))); + Assert.False(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(b))); + } + + [Fact] + public void Base64Url_RoundTrips_AndRejectsGarbage() + { + byte[] data = [0xFB, 0xFF, 0xFE, 0x00, 0x01]; + string text = Base64Url.Encode(data); + Assert.DoesNotContain('+', text); + Assert.DoesNotContain('/', text); + Assert.DoesNotContain('=', text); + Assert.True(Base64Url.TryDecode(text, out byte[] back)); + Assert.Equal(data, back); + Assert.False(Base64Url.TryDecode("***", out _)); + } + + [Fact] + public void Result_CarriesEitherAValueOrAnError_AndRefusesTheWrongAccess() + { + Result ok = Result.Ok(5); + Result failed = Result.Fail(FleetErrors.NotFound, "missing"); + Assert.True(ok.IsSuccess); + Assert.Equal(10, ok.Map(v => v * 2).Value); + Assert.False(failed.IsSuccess); + Assert.Equal(FleetErrors.NotFound, failed.Error.Code); + Assert.Throws(() => failed.Value); + Assert.Throws(() => ok.Error); + Assert.Equal("fleet_not_found", failed.Map(v => v).Error.Code.Value); + } + + [Fact] + public void FrameworkErrorCodes_ArePrefixedAndSnakeCase() + { + foreach (System.Reflection.FieldInfo field in typeof(FleetErrors).GetFields()) + { + string value = ((ErrorCode)field.GetValue(null)!).Value; + Assert.StartsWith("fleet_", value); + Assert.Matches("^[a-z]+(_[a-z]+)*$", value); + } + } + + private sealed class FakeClock : TimeProvider + { + private DateTimeOffset _now; + + public FakeClock(DateTimeOffset now) + { + _now = now; + } + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/backend/fleet/tests/Fleet.Tests/Fleet.Tests.csproj b/backend/fleet/tests/Fleet.Tests/Fleet.Tests.csproj new file mode 100644 index 0000000..146ccda --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/Fleet.Tests.csproj @@ -0,0 +1,16 @@ + + + Exe + false + + + + + + + + + + + + diff --git a/backend/fleet/tests/Fleet.Tests/IdentityAndRpcTests.cs b/backend/fleet/tests/Fleet.Tests/IdentityAndRpcTests.cs new file mode 100644 index 0000000..d70b033 --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/IdentityAndRpcTests.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Tests; + +public sealed class IdentityAndRpcTests +{ + [Fact] + public void Signatures_VerifyOnlyForTheSameKeyPurposeAndFields() + { + using NodeIdentity node = NodeIdentity.Generate(); + using NodeIdentity other = NodeIdentity.Generate(); + byte[] signature = node.Sign(SignaturePurpose.Auth, "node-1", "nonce-1"); + + Assert.True(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1")); + Assert.False(NodeIdentity.Verify(other.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"), "another key"); + Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Peer, "node-1", "nonce-1"), "another purpose: a signature must not be reusable across scenarios"); + Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-2", "nonce-1"), "another node id"); + Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-2"), "another nonce"); + } + + [Fact] + public void Verify_NeverThrowsOnGarbage() + { + using NodeIdentity node = NodeIdentity.Generate(); + Assert.False(NodeIdentity.Verify([1, 2, 3], [4, 5, 6], SignaturePurpose.Auth, "x")); + Assert.False(NodeIdentity.Verify(node.PublicKeySpki, [], SignaturePurpose.Auth, "x")); + Assert.False(NodeIdentity.Verify(node.PublicKeySpki, new byte[64], SignaturePurpose.Auth, "x")); + Assert.False(NodeIdentity.IsValidPublicKey([])); + Assert.False(NodeIdentity.IsValidPublicKey(new byte[91])); + Assert.True(NodeIdentity.IsValidPublicKey(node.PublicKeySpki)); + } + + [Fact] + public void Identity_SurvivesAnExportImportRoundTrip() + { + using NodeIdentity original = NodeIdentity.Generate(); + using NodeIdentity restored = NodeIdentity.FromPkcs8(original.ExportPkcs8()); + Assert.Equal(original.PublicKeySpki, restored.PublicKeySpki); + Assert.True(NodeIdentity.Verify(original.PublicKeySpki, restored.Sign("t", "a"), "t", "a")); + Assert.Equal(32, NodeIdentity.Fingerprint(original.PublicKeySpki).Length); + } + + [Fact] + public void SignatureString_IsPrefixedWithPurpose() + { + Assert.Equal("fleet|v1|auth|a|b", SignatureString.Build("auth", "a", "b")); + } + + [Fact] + public void OutboxFrame_RoundTrips_AndRejectsShortFrames() + { + var id = Guid.CreateVersion7(); + byte[] frame = OutboxFrame.Build(16, id, [1, 2, 3]); + Assert.True(OutboxFrame.TryParse(frame, out byte channel, out Guid parsed, out ReadOnlyMemory payload)); + Assert.Equal((byte)16, channel); + Assert.Equal(id, parsed); + Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray()); + Assert.False(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes - 1], out _, out _, out _)); + Assert.True(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes], out _, out _, out ReadOnlyMemory empty)); + Assert.Equal(0, empty.Length); + } + + [Fact] + public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors() + { + var router = new RpcRouter() + .On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult, + (caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result.Fail(FleetErrors.Forbidden, "negative") : Result.Ok(new AddResult { Sum = p.A + p.B, Caller = caller }))) + .On("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail")); + + RpcMessage ok = (await router.DispatchAsync("alice", Request(1, "test.add", """{"a":2,"b":3}"""), CancellationToken.None))!; + Assert.Equal(5, ok.Result!.Value.Deserialize(EchoContext.Default.AddResult)!.Sum); + Assert.Equal("alice", ok.Result.Value.Deserialize(EchoContext.Default.AddResult)!.Caller); + + RpcMessage denied = (await router.DispatchAsync("alice", Request(2, "test.add", """{"a":-1,"b":3}"""), CancellationToken.None))!; + Assert.Equal("fleet_forbidden", denied.Error!.Data!.Code); + Assert.Equal(RpcError.ApplicationError, denied.Error.Code); + + RpcMessage invalid = (await router.DispatchAsync("alice", Request(3, "test.add", """{"a":"x"}"""), CancellationToken.None))!; + Assert.Equal(RpcError.InvalidParams, invalid.Error!.Code); + + RpcMessage unknown = (await router.DispatchAsync("alice", Request(4, "test.nope", "{}"), CancellationToken.None))!; + Assert.Equal(RpcError.MethodNotFound, unknown.Error!.Code); + Assert.Equal("fleet_unsupported", unknown.Error.Data!.Code); + } + + [Fact] + public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails() + { + string? logged = null; + var router = new RpcRouter((method, ex) => logged = method + ":" + ex.Message) + .On("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail")); + + RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!; + Assert.Equal("fleet_internal", reply.Error!.Data!.Code); + Assert.DoesNotContain("secret", reply.Error.Message); + Assert.Equal("test.boom:secret detail", logged); + } + + [Fact] + public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure() + { + int calls = 0; + var router = new RpcRouter().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) => + { + calls++; + return ValueTask.CompletedTask; + }); + + Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":1,"b":2}"""), CancellationToken.None)); + Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":"bad"}"""), CancellationToken.None)); + Assert.Null(await router.DispatchAsync("a", Notification("test.unknown", "{}"), CancellationToken.None)); + Assert.Equal(1, calls); + } + + [Fact] + public void Envelope_OmitsAbsentFields_AndRoundTrips() + { + string request = JsonSerializer.Serialize(Request(7, "x.y", """{"k":1}"""), FleetRpcContext.Default.RpcMessage); + Assert.Equal("""{"jsonrpc":"2.0","id":7,"method":"x.y","params":{"k":1}}""", request); + RpcMessage back = JsonSerializer.Deserialize(request, FleetRpcContext.Default.RpcMessage)!; + Assert.True(back.IsRequest); + Assert.False(back.IsResponse); + Assert.True(Notification("a.b", "{}").IsNotification); + + string failure = JsonSerializer.Serialize(RpcMessage.Failure(3, new FleetError(FleetErrors.Timeout, "slow")), FleetRpcContext.Default.RpcMessage); + Assert.Contains("\"code\":\"fleet_timeout\"", failure); + Assert.Equal(FleetErrors.Timeout, JsonSerializer.Deserialize(failure, FleetRpcContext.Default.RpcMessage)!.Error!.ToFleetError().Code); + } + + private static RpcMessage Request(long id, string method, string json) => + new() { Id = id, Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() }; + + private static RpcMessage Notification(string method, string json) => + new() { Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() }; +} + +public sealed class AddParams +{ + [JsonPropertyName("a")] + public int A { get; set; } + + [JsonPropertyName("b")] + public int B { get; set; } +} + +public sealed class AddResult +{ + [JsonPropertyName("sum")] + public int Sum { get; set; } + + [JsonPropertyName("caller")] + public string Caller { get; set; } = ""; +} + +[JsonSerializable(typeof(AddParams))] +[JsonSerializable(typeof(AddResult))] +internal sealed partial class EchoContext : JsonSerializerContext; diff --git a/backend/fleet/tests/Fleet.Tests/LinkTests.cs b/backend/fleet/tests/Fleet.Tests/LinkTests.cs new file mode 100644 index 0000000..588f40c --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/LinkTests.cs @@ -0,0 +1,431 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Link; +using Fleet.Rpc; + +namespace Fleet.Tests; + +public sealed class LinkTests +{ + // ───────────────────────── 基础件 ───────────────────────── + + [Fact] + public void ReplayWindow_AcceptsEachSequenceOnce_AndRejectsStaleOnes() + { + var window = new ReplayWindow(); + Assert.True(window.TryAccept(100)); + Assert.False(window.TryAccept(100), "duplicate"); + Assert.True(window.TryAccept(99), "late but inside the window"); + Assert.False(window.TryAccept(99)); + Assert.True(window.TryAccept(105)); + Assert.True(window.TryAccept(101)); + Assert.True(window.TryAccept(100 + 1024), "the window slides"); + Assert.False(window.TryAccept(100), "now older than the window"); + Assert.True(window.TryAccept(100 + 5000), "a big jump clears everything"); + Assert.False(window.TryAccept(100 + 5000)); + Assert.True(window.TryAccept(100 + 4999)); + } + + [Fact] + public void Backoff_GrowsExponentially_IsCappedAndJittered_AndResets() + { + var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60)); + TimeSpan previousCeiling = TimeSpan.Zero; + for (int i = 0; i < 12; i++) + { + TimeSpan delay = backoff.Next(); + double ceiling = Math.Min(60, Math.Pow(2, i)); + Assert.InRange(delay.TotalSeconds, ceiling * 0.5 - 1e-9, ceiling + 1e-9); + Assert.True(TimeSpan.FromSeconds(ceiling) >= previousCeiling); + previousCeiling = TimeSpan.FromSeconds(ceiling); + } + + backoff.Reset(); + Assert.InRange(backoff.Next().TotalSeconds, 0.5, 1.0); + } + + [Fact] + public async Task Outbox_DeliversInOrder_UntilAcknowledged_AndResendsAfterAReconnect() + { + var outbox = new Outbox(1 << 20); + Guid a = Guid.CreateVersion7(); + Guid b = Guid.CreateVersion7(); + outbox.Enqueue(16, a, [1]); + outbox.Enqueue(16, b, [2]); + outbox.Enqueue(16, a, [9]); // 同一个 msg_id 只保留一份 + + Assert.Equal(2, outbox.PendingCount); + Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid first, out _)); + Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid second, out _)); + Assert.Equal((a, b), (first, second)); + + // 连接断了:还没确认的全部重发,顺序不变 + outbox.ResetSent(); + Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid again, out _)); + Assert.Equal(a, again); + + outbox.Acknowledge([a, b, Guid.NewGuid()]); + Assert.Equal(0, outbox.PendingCount); + Assert.Equal(0, outbox.PendingBytes); + } + + [Fact] + public async Task Outbox_IsBounded_DropsTheOldest_AndReportsTheGap() + { + var outbox = new Outbox(maxBytes: 100); + int gap = 0; + outbox.OnGap = n => gap += n; + for (int i = 0; i < 10; i++) + { + outbox.Enqueue(16, Guid.CreateVersion7(), new byte[40]); // 每条 57 字节 + } + + Assert.Equal(1, outbox.PendingCount); + Assert.Equal(9, outbox.Dropped); + Assert.Equal(9, gap); + Assert.True(outbox.PendingBytes <= 100); + + var counted = new Outbox(1 << 20, maxCount: 3); + for (int i = 0; i < 5; i++) + { + counted.Enqueue(16, Guid.CreateVersion7(), [1]); + } + + Assert.Equal(3, counted.PendingCount); + Assert.Equal(2, counted.Dropped); + _ = await counted.TakeNextAsync(1 << 20, default); + } + + [Fact] + public async Task Outbox_LimitsBytesInFlight_UntilTheServerAcknowledges() + { + var outbox = new Outbox(1 << 20); + Guid first = Guid.CreateVersion7(); + outbox.Enqueue(16, first, new byte[1000]); + outbox.Enqueue(16, Guid.CreateVersion7(), new byte[1000]); + + await outbox.TakeNextAsync(1500, default); // 第一条放行 + using var cts = new CancellationTokenSource(200); + await Assert.ThrowsAnyAsync(async () => await outbox.TakeNextAsync(1500, cts.Token)); // 第二条超过在途上限,等 ACK + + outbox.Acknowledge([first]); + Assert.NotNull(await outbox.TakeNextAsync(1500, default)); + + // 单条消息比在途上限还大时也要放行,否则永远卡住 + var big = new Outbox(1 << 20); + big.Enqueue(16, Guid.CreateVersion7(), new byte[5000]); + Assert.NotNull(await big.TakeNextAsync(100, default)); + } + + [Fact] + public void SessionKeys_AgreeOnBothSides_AndBindTheHandshake() + { + using var client = new EphemeralKey(); + using var server = new EphemeralKey(); + byte[] nonce = RandomNumberGenerator.GetBytes(32); + + SessionCredentials c = client.Derive(server.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki); + SessionCredentials s = server.Derive(client.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki); + Assert.Equal(c.Key, s.Key); + Assert.Equal(c.SessionId, s.SessionId); + Assert.Equal(8, c.SessionId.Length); + Assert.Equal(32, c.Key.Length); + + SessionCredentials otherNonce = client.Derive(server.PublicKeySpki, RandomNumberGenerator.GetBytes(32), client.PublicKeySpki, server.PublicKeySpki); + Assert.NotEqual(c.Key, otherNonce.Key); + using var third = new EphemeralKey(); + Assert.NotEqual(c.Key, third.Derive(server.PublicKeySpki, nonce, third.PublicKeySpki, server.PublicKeySpki).Key); + } + + [Fact] + public void DataPlaneMac_DetectsAnyTampering() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + byte[] data = [1, 2, 3, 4, 5]; + byte[] mac = new byte[DataPlaneMac.Bytes]; + DataPlaneMac.Compute(key, data, mac); + Assert.True(DataPlaneMac.Verify(key, data, mac)); + data[2] ^= 1; + Assert.False(DataPlaneMac.Verify(key, data, mac)); + data[2] ^= 1; + mac[0] ^= 1; + Assert.False(DataPlaneMac.Verify(key, data, mac)); + mac[0] ^= 1; + Assert.False(DataPlaneMac.Verify(RandomNumberGenerator.GetBytes(32), data, mac)); + } + + // ───────────────────────── 传输与会话 ───────────────────────── + + private static async Task<(TlsFrameTransport Left, TlsFrameTransport Right)> PairAsync(int maxFrame = 1 << 20) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var client = new TcpClient(); + Task accept = listener.AcceptTcpClientAsync(); + await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port); + TcpClient server = await accept; + listener.Stop(); + return (new TlsFrameTransport(client.GetStream(), maxFrame), new TlsFrameTransport(server.GetStream(), maxFrame)); + } + + [Fact] + public async Task TlsFrames_RoundTripTextAndBinary_AndCloseCleanly() + { + (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); + await using (left) + await using (right) + { + await left.SendAsync(new RpcFrame(FrameKind.Text, "hello"u8.ToArray()), default); + await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[] { 1, 2, 3 }), default); + await left.SendAsync(new RpcFrame(FrameKind.Binary, ReadOnlyMemory.Empty), default); + + RpcFrame a = (await right.ReceiveAsync(default))!.Value; + RpcFrame b = (await right.ReceiveAsync(default))!.Value; + RpcFrame c = (await right.ReceiveAsync(default))!.Value; + Assert.Equal((FrameKind.Text, "hello"), (a.Kind, System.Text.Encoding.UTF8.GetString(a.Data.Span))); + Assert.Equal(new byte[] { 1, 2, 3 }, b.Data.ToArray()); + Assert.Equal(0, c.Data.Length); + + await left.CloseAsync("done", default); + Assert.Null(await right.ReceiveAsync(default)); + } + } + + [Fact] + public async Task TlsFrames_RefuseOversizedFrames_OnBothSides() + { + (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(maxFrame: 100); + await using (left) + await using (right) + { + await Assert.ThrowsAsync(async () => await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[101]), default)); + + // 对端绕过长度检查发来超长帧:接收方直接断开 + (TlsFrameTransport raw, TlsFrameTransport receiver) = await PairAsync(maxFrame: 100); + await using (raw) + await using (receiver) + { + var permissive = new TlsFrameTransport(await TakeStreamAsync(raw), 1 << 20); + await permissive.SendAsync(new RpcFrame(FrameKind.Binary, new byte[500]), default); + Assert.Null(await receiver.ReceiveAsync(default)); + } + } + } + + private static Task TakeStreamAsync(TlsFrameTransport transport) => + Task.FromResult((Stream)typeof(TlsFrameTransport).GetField("_stream", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(transport)!); + + [Fact] + public async Task Session_CorrelatesRequestsAndResponses_BothWays_WithoutDeadlockingInHandlers() + { + (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); + RpcSession? rightSession = null; + var leftRouter = new RpcRouter().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result.Ok(new Number { Value = p.Value * 2 }))); + var rightRouter = new RpcRouter().On( + "right.relay", LinkJson.Default.Number, LinkJson.Default.Number, + async (_, p, ct) => + { + // 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁 + Result inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct); + return Result.Ok(new Number { Value = inner.Value.Value + 1 }); + }); + + await using var leftSession = new RpcSession(left, leftRouter, "left", TimeProvider.System); + rightSession = new RpcSession(right, rightRouter, "right", TimeProvider.System); + await using (rightSession) + { + Task leftRun = leftSession.RunAsync(default); + Task rightRun = rightSession.RunAsync(default); + + // left 请求 right.relay;right 的处理函数回头请求 left.double:21 → 42 → +1 + Result result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default); + Assert.Equal(43, result.Value.Value); + + Result unknown = await leftSession.RequestAsync("nope", null, default); + Assert.Equal("fleet_unsupported", unknown.Error.Code.Value); + + await leftSession.CloseAsync("bye", default); + await rightRun.WaitAsync(TimeSpan.FromSeconds(5)); + await leftRun.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + [Fact] + public async Task Session_RequestsFailWhenThePeerNeverAnswers_OrTheSessionCloses() + { + (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); + await using var silent = right; // 对端不读也不回 + await using var session = new RpcSession(left, new RpcRouter(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) }); + Task run = session.RunAsync(default); + + Result timedOut = await session.RequestAsync("any", null, default); + Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code); + + Task> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask(); + await silent.CloseAsync("gone", default); + Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code); + await run.WaitAsync(TimeSpan.FromSeconds(5)); + } + + // ───────────────────────── 点对点握手 ───────────────────────── + + private sealed class PeerFixture : IAsyncDisposable + { + private readonly CancellationTokenSource _stop = new(); + + public PeerFixture(Func lookup, Func? onSession = null) + { + Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System)); + Listener = new PeerListener( + new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup }, + onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)), + NullNodeLog.Instance, TimeProvider.System); + Task = Listener.RunAsync(_stop.Token); + } + + public X509Certificate2 Certificate { get; } + + public PeerListener Listener { get; } + + public Task Task { get; } + + public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate); + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + await Task; + Certificate.Dispose(); + _stop.Dispose(); + } + + private static async Task KeepOpenAsync(IRpcTransport transport, CancellationToken ct) + { + while (await transport.ReceiveAsync(ct) is not null) + { + } + } + } + + [Fact] + public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials() + { + using NodeIdentity probe = NodeIdentity.Generate(); + var seen = new TaskCompletionSource(); + await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) => + { + seen.TrySetResult(info); + return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None); + }); + await agent.Listener.Started.Task; + + (PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + await using (transport) + { + PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("prb_1", server.PeerId); + Assert.Equal(client.Credentials.Key, server.Credentials.Key); + Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId); + } + } + + [Fact] + public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError() + { + using NodeIdentity probe = NodeIdentity.Generate(); + using NodeIdentity impostor = NodeIdentity.Generate(); + await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null); + await agent.Listener.Started.Task; + + InvalidOperationException unknown = await Assert.ThrowsAsync(async () => + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default)); + InvalidOperationException forged = await Assert.ThrowsAsync(async () => + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default)); + Assert.Equal(unknown.Message, forged.Message); + Assert.Contains("fleet_unauthorized", forged.Message); + + // 正主仍然可以连接 + (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + await ok.DisposeAsync(); + } + + [Fact] + public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint() + { + using NodeIdentity probe = NodeIdentity.Generate(); + await using var agent = new PeerFixture(_ => probe.PublicKeySpki); + await agent.Listener.Started.Task; + + byte[] wrong = SHA256.HashData("another certificate"u8); + await Assert.ThrowsAnyAsync(async () => + await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default)); + } + + [Fact] + public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake() + { + using NodeIdentity probe = NodeIdentity.Generate(); + var router = new RpcRouter().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result.Ok(new Number { Value = p.Value + 1 }))); + await using var agent = new PeerFixture( + _ => probe.PublicKeySpki, + async (info, transport, ct) => + { + await using var session = new RpcSession(transport, router, info.PeerId, TimeProvider.System); + await session.RunAsync(ct); + }); + await agent.Listener.Started.Task; + + (_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + await using var client = new RpcSession(transport, new RpcRouter(), "probe", TimeProvider.System); + Task run = client.RunAsync(default); + Result result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default); + Assert.Equal(42, result.Value.Value); + await client.CloseAsync("bye", default); + await run.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections() + { + using NodeIdentity probe = NodeIdentity.Generate(); + await using var agent = new PeerFixture(_ => probe.PublicKeySpki); + await agent.Listener.Started.Task; + + // 不说 TLS 的连接:握手失败,监听继续 + using (var garbage = new TcpClient()) + { + await garbage.ConnectAsync(IPAddress.Loopback, agent.Listener.Port); + await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray()); + } + + (_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); + await ok.DisposeAsync(); + } + + [Fact] + public void SelfSignedCertificate_IsEcdsaP256_AndHasAStableFingerprint() + { + byte[] pfx = NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System); + using X509Certificate2 certificate = NodeCertificate.LoadPfx(pfx); + Assert.True(certificate.HasPrivateKey); + Assert.Equal("1.2.840.10045.2.1", certificate.PublicKey.Oid.Value); // id-ecPublicKey + Assert.Equal(NodeCertificate.SpkiFingerprint(certificate), NodeCertificate.SpkiFingerprint(NodeCertificate.LoadPfx(pfx))); + Assert.Equal(32, NodeCertificate.SpkiFingerprint(certificate).Length); + } +} + +public sealed class Number +{ + [JsonPropertyName("value")] + public int Value { get; set; } +} + +[JsonSerializable(typeof(Number))] +internal sealed partial class LinkJson : JsonSerializerContext; diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs new file mode 100644 index 0000000..2ede74b --- /dev/null +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Enrollment.Client; + +// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0] +// 一次性运行:生成身份、注册、写入身份目录,然后退出。 +string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault(); + +string? server = Arg("--server"); +string? join = Arg("--join"); +string? name = Arg("--name"); +string? directory = Arg("--identity-dir"); +if (server is null || join is null || name is null || directory is null) +{ + Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ]"); + return 2; +} + +JsonNode? meta = null; +if (Arg("--meta") is { } metaText) +{ + try + { + meta = JsonNode.Parse(metaText); + } + catch (System.Text.Json.JsonException ex) + { + Console.Error.WriteLine("--meta is not valid JSON: " + ex.Message); + return 2; + } +} + +using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; +Result result = await EnrollmentClient.EnrollAsync( + http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None); +if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) +{ + Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); + return 1; +} + +Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}"); +return 0; diff --git a/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj new file mode 100644 index 0000000..96f2d53 --- /dev/null +++ b/backend/fleet/tools/fleet-enroll/fleet-enroll.csproj @@ -0,0 +1,11 @@ + + + Exe + fleet-enroll + FleetEnroll + true + + + + + diff --git a/backend/global.json b/backend/global.json new file mode 100644 index 0000000..a4e7375 --- /dev/null +++ b/backend/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.200", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json new file mode 100644 index 0000000..bcc200e --- /dev/null +++ b/backend/openapi/v1.json @@ -0,0 +1,6817 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "monitor-backend | v1", + "version": "1.0.0" + }, + "paths": { + "/api/public/overview": { + "get": { + "tags": [ + "public" + ], + "summary": "Ranking of all lines over a time range", + "operationId": "overview_get", + "parameters": [ + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "preset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "metrics", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OverviewDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/lines/{line_id}": { + "get": { + "tags": [ + "public" + ], + "summary": "One line", + "operationId": "lines_get", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LineDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/lines/{line_id}/series": { + "get": { + "tags": [ + "public" + ], + "summary": "Time series of one line (columnar)", + "operationId": "lines_series", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "step", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tz", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "preset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "metrics", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "anchor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/compare": { + "get": { + "tags": [ + "public" + ], + "summary": "One metric across several lines, aligned on one time axis", + "operationId": "lines_compare", + "parameters": [ + { + "name": "lines", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "metric", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "step", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/lines/{line_id}/distribution": { + "get": { + "tags": [ + "public" + ], + "summary": "Histogram and CDF of a histogram metric", + "operationId": "lines_distribution", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "metric", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DistributionDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/lines/{line_id}/heatmap": { + "get": { + "tags": [ + "public" + ], + "summary": "Time by latency heat map", + "operationId": "lines_heatmap", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "metric", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "step", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeatmapDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/tests/{test_id}": { + "get": { + "tags": [ + "public" + ], + "summary": "A test with its result", + "operationId": "tests_get", + "parameters": [ + { + "name": "test_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/live": { + "get": { + "tags": [ + "public" + ], + "summary": "Server-sent events: nodes, overview, series and test topics", + "operationId": "live_stream", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/hosts": { + "get": { + "tags": [ + "public" + ], + "summary": "Latest host resource usage of every visible agent (no hardware model, no addresses)", + "operationId": "hosts_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfHostItemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/agents/{agent_id}/host": { + "get": { + "tags": [ + "public" + ], + "summary": "Host resource usage history of one agent (columnar, averaged per step)", + "operationId": "hosts_history", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "range", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostSeriesDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/catalog/kinds": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Measurement kinds with their building blocks and parameter specs", + "operationId": "catalog_kinds", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfKindDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/catalog/plans": { + "get": { + "tags": [ + "catalog" + ], + "summary": "On-demand test plans", + "operationId": "catalog_plans", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfPlanDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/catalog/metrics": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Metric ids with unit, label, direction, thresholds and version", + "operationId": "catalog_metrics", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfMetricDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/catalog/presets": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Metric groups the front end can display", + "operationId": "catalog_presets", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfPresetDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/public/catalog/models": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Service models and the metrics they need", + "operationId": "catalog_models", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfModelDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/login": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Sign in with a username and password", + "operationId": "auth_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/logout": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Sign out and clear the session cookie", + "operationId": "auth_logout", + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/me": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The current identity", + "operationId": "auth_me", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents": { + "get": { + "tags": [ + "admin-agents" + ], + "summary": "All agents including pending and disabled ones", + "operationId": "agents_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfAgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}": { + "get": { + "tags": [ + "admin-agents" + ], + "summary": "One agent", + "operationId": "agents_get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "patch": { + "tags": [ + "admin-agents" + ], + "summary": "Update name, labels, public host or visibility (merge-patch, If-Match supported)", + "operationId": "agents_update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/AgentPatchRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "delete": { + "tags": [ + "admin-agents" + ], + "summary": "Delete an agent; history expires with the retention policy", + "operationId": "agents_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}/approve": { + "post": { + "tags": [ + "admin-agents" + ], + "summary": "Approve a pending agent", + "operationId": "agents_approve", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}/disable": { + "post": { + "tags": [ + "admin-agents" + ], + "summary": "Disable an agent and drop its connection", + "operationId": "agents_disable", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}/enable": { + "post": { + "tags": [ + "admin-agents" + ], + "summary": "Enable a disabled agent", + "operationId": "agents_enable", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}/takeover": { + "post": { + "tags": [ + "admin-agents" + ], + "summary": "Replace the existing node of the same name with this pending one, keeping history", + "operationId": "agents_takeover", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/join-tokens": { + "get": { + "tags": [ + "admin-join-tokens" + ], + "summary": "Join tokens", + "operationId": "join_tokens_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfJoinTokenDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-join-tokens" + ], + "summary": "Create a join token; the plaintext is returned only once", + "operationId": "join_tokens_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateJoinTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedJoinTokenDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/join-tokens/{id}": { + "delete": { + "tags": [ + "admin-join-tokens" + ], + "summary": "Revoke a join token", + "operationId": "join_tokens_revoke", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/probes": { + "get": { + "tags": [ + "admin-probe" + ], + "summary": "All probes, with the agents each one is connected to", + "operationId": "probes_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfProbeDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-probe" + ], + "summary": "Create a probe identity; the private key is returned only once", + "operationId": "probes_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CreateProbeRequest" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeIdentityDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/probes/{id}": { + "get": { + "tags": [ + "admin-probe" + ], + "summary": "One probe", + "operationId": "probes_get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "patch": { + "tags": [ + "admin-probe" + ], + "summary": "Rename a probe", + "operationId": "probes_update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchProbeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "delete": { + "tags": [ + "admin-probe" + ], + "summary": "Delete a probe and its lines; stored data expires with the retention policy", + "operationId": "probes_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/probes/{id}/rotate": { + "post": { + "tags": [ + "admin-probe" + ], + "summary": "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", + "operationId": "probes_rotate", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeIdentityDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/probes/{id}/revoke": { + "post": { + "tags": [ + "admin-probe" + ], + "summary": "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", + "operationId": "probes_revoke", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/agents/{id}/probes/{probe_id}/disconnect": { + "post": { + "tags": [ + "admin-agents" + ], + "summary": "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", + "operationId": "agents_probe_disconnect", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "probe_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/tasks": { + "get": { + "tags": [ + "admin-tasks" + ], + "summary": "Tasks: line templates, per-agent overrides and target tasks", + "operationId": "tasks_list", + "parameters": [ + { + "name": "agent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfTaskDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-tasks" + ], + "summary": "Create a task; params are validated against the kind", + "operationId": "tasks_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/tasks/{id}": { + "get": { + "tags": [ + "admin-tasks" + ], + "summary": "One task", + "operationId": "tasks_get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "patch": { + "tags": [ + "admin-tasks" + ], + "summary": "Change params, schedule, target or enabled (merge-patch, If-Match supported)", + "operationId": "tasks_update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/PatchTaskRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "delete": { + "tags": [ + "admin-tasks" + ], + "summary": "Delete a task", + "operationId": "tasks_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/lines/{line_id}/tests": { + "post": { + "tags": [ + "admin-tests" + ], + "summary": "Start an on-demand test on a line", + "operationId": "tests_start", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartTestRequestDto" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "get": { + "tags": [ + "admin-tests" + ], + "summary": "Recent tests of a line", + "operationId": "tests_list", + "parameters": [ + { + "name": "line_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfTestDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/tests/{id}/cancel": { + "post": { + "tags": [ + "admin-tests" + ], + "summary": "Cancel a running test", + "operationId": "tests_cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/raw/chunks": { + "get": { + "tags": [ + "admin-data" + ], + "summary": "Raw chunk metadata", + "operationId": "raw_chunks_list", + "parameters": [ + { + "name": "agent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "test_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfRawChunkMetaDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/raw/chunks/{chunk_id}": { + "get": { + "tags": [ + "admin-data" + ], + "summary": "Download a raw chunk (application/vnd.monitor.rawchunk)", + "operationId": "raw_chunks_get", + "parameters": [ + { + "name": "chunk_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/events": { + "get": { + "tags": [ + "admin-data" + ], + "summary": "Agent events", + "operationId": "events_list", + "parameters": [ + { + "name": "agent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "format": "date-time" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfEventDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/analysis/recompute": { + "get": { + "tags": [ + "admin-data" + ], + "summary": "Progress of the metric backfill", + "operationId": "analysis_recompute_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecomputeDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/api-tokens": { + "get": { + "tags": [ + "admin-api-tokens" + ], + "summary": "API tokens", + "operationId": "api_tokens_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfApiTokenDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-api-tokens" + ], + "summary": "Create an API token (scope read or admin); the plaintext is returned only once", + "operationId": "api_tokens_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedApiTokenDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/api-tokens/{id}": { + "delete": { + "tags": [ + "admin-api-tokens" + ], + "summary": "Revoke an API token", + "operationId": "api_tokens_revoke", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/settings": { + "get": { + "tags": [ + "admin-settings" + ], + "summary": "Site settings and retention periods", + "operationId": "settings_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfSettingDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "patch": { + "tags": [ + "admin-settings" + ], + "summary": "Change settings (merge-patch)", + "operationId": "settings_update", + "requestBody": { + "content": { + "application/merge-patch+json": { + "schema": { + "type": "object" + } + }, + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfSettingDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/users": { + "get": { + "tags": [ + "admin-users" + ], + "summary": "Panel users", + "operationId": "users_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfUserDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "admin-users" + ], + "summary": "Create a panel user", + "operationId": "users_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/users/{id}": { + "patch": { + "tags": [ + "admin-users" + ], + "summary": "Change a user's password", + "operationId": "users_update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/healthz": { + "get": { + "tags": [ + "ops" + ], + "summary": "SQLite and the raw data directory are both writable", + "operationId": "health_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthDto" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "tags": [ + "ops" + ], + "summary": "Prometheus metrics (admin token)", + "operationId": "metrics_get", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/install.sh": { + "get": { + "tags": [ + "ops" + ], + "summary": "Installer for Linux nodes", + "operationId": "install_sh", + "responses": { + "501": { + "description": "Not Implemented", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/install.ps1": { + "get": { + "tags": [ + "ops" + ], + "summary": "Installer for Windows nodes", + "operationId": "install_ps1", + "responses": { + "501": { + "description": "Not Implemented", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/agent/v1/enroll": { + "post": { + "tags": [ + "agent" + ], + "summary": "Enroll a node with a join token", + "operationId": "fleet_enroll", + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "AgentDto": { + "required": [ + "id", + "name", + "status", + "online", + "probe_connected", + "probes", + "host", + "public_host", + "probe_tcp_port", + "probe_udp_ports", + "labels", + "public_visible", + "kinds", + "agent_version", + "local_limits", + "task_version", + "applied_task_version", + "rejected", + "heartbeat", + "endpoint_changes", + "created_at", + "last_seen_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "probe_connected": { + "type": "boolean" + }, + "probes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectedProbeDto" + } + }, + "host": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/HostDto" + } + ] + }, + "public_host": { + "type": "string" + }, + "probe_tcp_port": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "probe_udp_ports": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "public_visible": { + "type": "boolean" + }, + "kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "agent_version": { + "type": [ + "null", + "string" + ] + }, + "local_limits": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LocalLimitsDto" + } + ] + }, + "task_version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "applied_task_version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "rejected": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RejectedDto" + } + }, + "heartbeat": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/HeartbeatDto" + } + ] + }, + "endpoint_changes": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "created_at": { + "format": "date-time" + }, + "last_seen_at": { + "format": "date-time" + } + } + }, + "AgentPatchRequest": { + "required": [ + "name", + "labels", + "public_host", + "public_visible" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "public_host": { + "type": [ + "null", + "string" + ] + }, + "public_visible": { + "type": [ + "null", + "boolean" + ] + } + } + }, + "ApiTokenDto": { + "required": [ + "id", + "name", + "scope", + "created_at", + "last_used_at", + "revoked_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "created_at": { + "format": "date-time" + }, + "last_used_at": { + "format": "date-time" + }, + "revoked_at": { + "format": "date-time" + } + } + }, + "BlocksDto": { + "required": [ + "transport", + "pacer", + "payload", + "behavior", + "samplers" + ], + "type": "object", + "properties": { + "transport": { + "type": "string" + }, + "pacer": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "behavior": { + "type": "string" + }, + "samplers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "BootstrapAgentDto": { + "required": [ + "agent_id", + "host", + "tcp_port", + "spki" + ], + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "host": { + "type": "string" + }, + "tcp_port": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "spki": { + "type": "string" + } + } + }, + "ChangePasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "ColumnDto": { + "required": [ + "name", + "type", + "encoding", + "nullable", + "role" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "encoding": { + "type": "string" + }, + "nullable": { + "type": "boolean" + }, + "role": { + "type": [ + "null", + "string" + ] + } + } + }, + "CompareDto": { + "required": [ + "metric", + "from", + "to", + "step", + "source", + "degraded", + "t", + "values", + "summary" + ], + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "step": { + "type": "string" + }, + "source": { + "type": "string" + }, + "degraded": { + "type": "boolean" + }, + "t": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + } + }, + "ConnectedProbeDto": { + "required": [ + "probe_id", + "version", + "remote", + "connected_since" + ], + "type": "object", + "properties": { + "probe_id": { + "type": "string" + }, + "version": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "connected_since": { + "format": "date-time" + } + } + }, + "CreateApiTokenRequest": { + "required": [ + "name", + "scope" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "CreateJoinTokenRequest": { + "required": [ + "note", + "labels", + "auto_approve", + "max_uses", + "expires_in_hours" + ], + "type": "object", + "properties": { + "note": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "auto_approve": { + "type": [ + "null", + "boolean" + ] + }, + "max_uses": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "expires_in_hours": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + } + } + }, + "CreateProbeRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreateTaskRequest": { + "required": [ + "scope", + "kind", + "target", + "params", + "schedule", + "enabled", + "agent_id", + "base_task_id" + ], + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "params": { + "type": [ + "null", + "object" + ] + }, + "schedule": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": [ + "null", + "boolean" + ] + }, + "agent_id": { + "type": [ + "null", + "string" + ] + }, + "base_task_id": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreateUserRequest": { + "required": [ + "username", + "password" + ], + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "CreatedApiTokenDto": { + "required": [ + "token", + "plaintext" + ], + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/ApiTokenDto" + }, + "plaintext": { + "type": "string" + } + } + }, + "CreatedJoinTokenDto": { + "required": [ + "token", + "plaintext", + "install_command" + ], + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/JoinTokenDto" + }, + "plaintext": { + "type": "string" + }, + "install_command": { + "type": "string" + } + } + }, + "DimensionDto": { + "required": [ + "id", + "label", + "weight", + "value", + "score" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "weight": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "value": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "score": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + }, + "DistributionBucketDto": { + "required": [ + "le", + "count" + ], + "type": "object", + "properties": { + "le": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "count": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + }, + "DistributionDto": { + "required": [ + "metric", + "unit", + "buckets", + "p50", + "p95", + "p99", + "count" + ], + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DistributionBucketDto" + } + }, + "p50": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "p95": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "p99": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "count": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + }, + "EdgePartialDto": { + "required": [ + "first", + "last" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean" + }, + "last": { + "type": "boolean" + } + } + }, + "EventDto": { + "required": [ + "id", + "agent_id", + "kind", + "at", + "detail" + ], + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "agent_id": { + "type": [ + "null", + "string" + ] + }, + "kind": { + "type": "string" + }, + "at": { + "format": "date-time" + }, + "detail": { + "type": [ + "null", + "string" + ] + } + } + }, + "GapDto": { + "required": [ + "from", + "to", + "reason" + ], + "type": "object", + "properties": { + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "reason": { + "type": "string" + } + } + }, + "HealthDto": { + "required": [ + "status", + "database", + "raw_directory" + ], + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "database": { + "type": "boolean" + }, + "raw_directory": { + "type": "boolean" + } + } + }, + "HeartbeatDto": { + "required": [ + "sessions", + "pps_in", + "pps_out", + "dropped_invalid", + "dropped_rate_limited", + "queue_bytes", + "at" + ], + "type": "object", + "properties": { + "sessions": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "pps_in": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "pps_out": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "dropped_invalid": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "dropped_rate_limited": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "queue_bytes": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "at": { + "format": "date-time" + } + } + }, + "HeatmapDto": { + "required": [ + "metric", + "step", + "source", + "degraded", + "t", + "edges", + "counts" + ], + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "step": { + "type": "string" + }, + "source": { + "type": "string" + }, + "degraded": { + "type": "boolean" + }, + "t": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + }, + "edges": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + }, + "counts": { + "type": "array", + "items": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + } + } + }, + "HostDto": { + "required": [ + "cpu_percent", + "mem_used", + "mem_total", + "disk_used", + "disk_total", + "net_rx_total", + "net_tx_total", + "net_rx_bps", + "net_tx_bps", + "uptime_seconds", + "at" + ], + "type": "object", + "properties": { + "cpu_percent": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "mem_used": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "mem_total": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "disk_used": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "disk_total": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "net_rx_total": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "net_tx_total": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "net_rx_bps": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "net_tx_bps": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "uptime_seconds": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "at": { + "format": "date-time" + } + } + }, + "HostItemDto": { + "required": [ + "agent_id", + "name", + "online", + "stale", + "last_seen_at", + "host" + ], + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "stale": { + "type": "boolean" + }, + "last_seen_at": { + "format": "date-time" + }, + "host": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/HostDto" + } + ] + } + } + }, + "HostSeriesDto": { + "required": [ + "agent_id", + "from", + "to", + "step", + "t", + "values" + ], + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "step": { + "type": "string" + }, + "t": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + } + } + }, + "JoinTokenDto": { + "required": [ + "id", + "note", + "labels", + "auto_approve", + "max_uses", + "uses", + "expires_at", + "revoked_at", + "created_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "note": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "auto_approve": { + "type": "boolean" + }, + "max_uses": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "uses": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "expires_at": { + "format": "date-time" + }, + "revoked_at": { + "format": "date-time" + }, + "created_at": { + "format": "date-time" + } + } + }, + "JsonElement": { }, + "KindDto": { + "required": [ + "id", + "title", + "description", + "scopes", + "blocks", + "params", + "streams" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocks": { + "$ref": "#/components/schemas/BlocksDto" + }, + "params": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParamDto" + } + }, + "streams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StreamDto" + } + } + } + }, + "LineDto": { + "required": [ + "id", + "agent_id", + "probe_id", + "probe_name", + "name", + "labels", + "online", + "probe_connected", + "data_since", + "kinds", + "last_test" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "probe_id": { + "type": "string" + }, + "probe_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "online": { + "type": "boolean" + }, + "probe_connected": { + "type": "boolean" + }, + "data_since": { + "format": "date-time" + }, + "kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_test": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestSummaryDto" + } + ] + } + } + }, + "LocalLimitsDto": { + "required": [ + "max_total_mbps", + "max_total_pps", + "allowed_target_cidrs", + "max_concurrent_tests" + ], + "type": "object", + "properties": { + "max_total_mbps": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "max_total_pps": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "allowed_target_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_concurrent_tests": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "LoginRequest": { + "required": [ + "username", + "password" + ], + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "LoginResponse": { + "required": [ + "user_id", + "username", + "csrf_token", + "expires_at" + ], + "type": "object", + "properties": { + "user_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "csrf_token": { + "type": "string" + }, + "expires_at": { + "format": "date-time" + } + } + }, + "MeDto": { + "required": [ + "kind", + "scope", + "user_id", + "username", + "csrf_token" + ], + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "user_id": { + "type": [ + "null", + "string" + ] + }, + "username": { + "type": [ + "null", + "string" + ] + }, + "csrf_token": { + "type": [ + "null", + "string" + ] + } + } + }, + "MetricDto": { + "required": [ + "id", + "definition_id", + "accumulator", + "stat", + "label", + "description", + "unit", + "better", + "good", + "poor", + "version", + "from" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "definition_id": { + "type": "string" + }, + "accumulator": { + "type": "string" + }, + "stat": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "better": { + "type": "string" + }, + "good": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "poor": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "version": { + "type": "string" + }, + "from": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ModelDto": { + "required": [ + "id", + "title", + "version", + "needs" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "OverviewDto": { + "required": [ + "range", + "from", + "to", + "step", + "source", + "degraded", + "lines" + ], + "type": "object", + "properties": { + "range": { + "type": "string" + }, + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "step": { + "type": "string" + }, + "source": { + "type": "string" + }, + "degraded": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OverviewLineDto" + } + } + } + }, + "OverviewLineDto": { + "required": [ + "id", + "agent_id", + "probe_id", + "probe_name", + "name", + "labels", + "online", + "probe_connected", + "endpoint_changes", + "summary", + "last_test" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "probe_id": { + "type": "string" + }, + "probe_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "online": { + "type": "boolean" + }, + "probe_connected": { + "type": "boolean" + }, + "endpoint_changes": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "summary": { + "$ref": "#/components/schemas/SummaryDto" + }, + "last_test": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestSummaryDto" + } + ] + } + } + }, + "PageDtoOfAgentDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfApiTokenDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiTokenDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfEventDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfHostItemDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HostItemDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfJoinTokenDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JoinTokenDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfKindDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KindDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfMetricDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfModelDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfPlanDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlanDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfPresetDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PresetDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfProbeDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProbeDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfRawChunkMetaDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RawChunkMetaDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfSettingDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfTaskDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfTestDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "PageDtoOfUserDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, + "ParamDto": { + "required": [ + "name", + "type", + "default", + "min", + "max", + "unit", + "values", + "description" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "default": { + "$ref": "#/components/schemas/JsonElement" + }, + "min": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "max": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "unit": { + "type": [ + "null", + "string" + ] + }, + "values": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + } + } + }, + "PatchProbeRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "PatchTaskRequest": { + "required": [ + "params", + "schedule", + "enabled", + "target" + ], + "type": "object", + "properties": { + "params": { + "type": [ + "null", + "object" + ] + }, + "schedule": { + "type": [ + "null", + "string" + ] + }, + "enabled": { + "type": [ + "null", + "boolean" + ] + }, + "target": { + "type": [ + "null", + "string" + ] + } + } + }, + "PhaseDto": { + "required": [ + "kind", + "duration_s", + "params" + ], + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "duration_s": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "params": { + "$ref": "#/components/schemas/JsonElement" + } + } + }, + "PlanDto": { + "required": [ + "id", + "title", + "description", + "phases" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "phases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PhaseDto" + } + } + } + }, + "PresetDto": { + "required": [ + "id", + "label", + "metrics" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "metrics": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ProbeConnectionDto": { + "required": [ + "agent_id", + "agent_name", + "agent_online", + "version", + "remote", + "connected_since" + ], + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "agent_online": { + "type": "boolean" + }, + "version": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "connected_since": { + "format": "date-time" + } + } + }, + "ProbeDto": { + "required": [ + "id", + "name", + "created_at", + "revoked_at", + "revoked", + "connections" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "format": "date-time" + }, + "revoked_at": { + "format": "date-time" + }, + "revoked": { + "type": "boolean" + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProbeConnectionDto" + } + } + } + }, + "ProbeIdentityDto": { + "required": [ + "probe_id", + "private_key", + "bootstrap", + "limits" + ], + "type": "object", + "properties": { + "probe_id": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "bootstrap": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BootstrapAgentDto" + } + }, + "limits": { + "$ref": "#/components/schemas/ProbeLimitsDto" + } + } + }, + "ProbeLimitsDto": { + "required": [ + "max_test_mbps", + "max_total_pps" + ], + "type": "object", + "properties": { + "max_test_mbps": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "max_total_pps": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + } + }, + "ProblemDto": { + "required": [ + "type", + "title", + "status", + "code", + "detail", + "trace_id", + "errors" + ], + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "code": { + "type": "string" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "trace_id": { + "type": [ + "null", + "string" + ] + }, + "errors": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "RawChunkMetaDto": { + "required": [ + "chunk_id", + "agent_id", + "series", + "kind", + "task_id", + "test_id", + "phase", + "start", + "duration_ms", + "partial", + "bytes" + ], + "type": "object", + "properties": { + "chunk_id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "series": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "task_id": { + "type": [ + "null", + "string" + ] + }, + "test_id": { + "type": [ + "null", + "string" + ] + }, + "phase": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "start": { + "format": "date-time" + }, + "duration_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "partial": { + "type": "boolean" + }, + "bytes": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "RecomputeDto": { + "required": [ + "state", + "metrics", + "windows_done", + "windows_total", + "from", + "to", + "queue_length" + ], + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "metrics": { + "type": "array", + "items": { + "type": "string" + } + }, + "windows_done": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "windows_total": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "queue_length": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "RejectedDto": { + "required": [ + "id", + "reason" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "ScoreDto": { + "required": [ + "score", + "version", + "estimated", + "dimensions", + "caveats" + ], + "type": "object", + "properties": { + "score": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "estimated": { + "type": "boolean" + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionDto" + } + }, + "caveats": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SeriesDto": { + "required": [ + "line_id", + "agent_id", + "probe_id", + "from", + "to", + "step", + "source", + "live", + "degraded", + "t", + "values", + "summary", + "edge_partial", + "gaps", + "events", + "versions" + ], + "type": "object", + "properties": { + "line_id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "probe_id": { + "type": "string" + }, + "from": { + "format": "date-time" + }, + "to": { + "format": "date-time" + }, + "step": { + "type": "string" + }, + "source": { + "type": "string" + }, + "live": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + }, + "t": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + }, + "summary": { + "$ref": "#/components/schemas/SummaryDto" + }, + "edge_partial": { + "$ref": "#/components/schemas/EdgePartialDto" + }, + "gaps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GapDto" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventDto" + } + }, + "versions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "SettingDto": { + "required": [ + "key", + "type", + "value", + "default", + "description" + ], + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "StartTestRequestDto": { + "required": [ + "plan", + "params" + ], + "type": "object", + "properties": { + "plan": { + "type": "string" + }, + "params": { + "type": [ + "null", + "object" + ] + } + } + }, + "StreamDto": { + "required": [ + "name", + "columns" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDto" + } + } + } + }, + "SummaryDto": { + "required": [ + "metrics", + "samples", + "coverage" + ], + "type": "object", + "properties": { + "metrics": { + "type": "object", + "additionalProperties": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + }, + "samples": { + "type": "object", + "additionalProperties": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + } + }, + "coverage": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "TaskDto": { + "required": [ + "id", + "scope", + "agent_id", + "base_task_id", + "kind", + "target", + "params", + "schedule", + "enabled", + "updated_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "agent_id": { + "type": [ + "null", + "string" + ] + }, + "base_task_id": { + "type": [ + "null", + "string" + ] + }, + "kind": { + "type": "string" + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "params": { + "type": "object" + }, + "schedule": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "updated_at": { + "format": "date-time" + } + } + }, + "TestDto": { + "required": [ + "id", + "agent_id", + "probe_id", + "plan", + "phases", + "status", + "phase", + "error_code", + "trigger", + "requested_at", + "started_at", + "finished_at", + "result" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "agent_id": { + "type": "string" + }, + "probe_id": { + "type": "string" + }, + "plan": { + "type": "string" + }, + "phases": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "phase": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "error_code": { + "type": [ + "null", + "string" + ] + }, + "trigger": { + "type": "string" + }, + "requested_at": { + "format": "date-time" + }, + "started_at": { + "format": "date-time" + }, + "finished_at": { + "format": "date-time" + }, + "result": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TestResultDto" + } + ] + } + } + }, + "TestResultDto": { + "required": [ + "metrics", + "scores", + "series", + "steps", + "caveats" + ], + "type": "object", + "properties": { + "metrics": { + "type": "object", + "additionalProperties": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + }, + "scores": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ScoreDto" + } + }, + "series": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + } + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestStepDto" + } + }, + "caveats": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TestStepDto": { + "required": [ + "index", + "target_mbps", + "loss_pct", + "jitter_p95_ms", + "delivered_mbps" + ], + "type": "object", + "properties": { + "index": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "target_mbps": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "loss_pct": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "jitter_p95_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "delivered_mbps": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "TestSummaryDto": { + "required": [ + "id", + "plan", + "status", + "requested_at", + "finished_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "plan": { + "type": "string" + }, + "status": { + "type": "string" + }, + "requested_at": { + "format": "date-time" + }, + "finished_at": { + "format": "date-time" + } + } + }, + "UserDto": { + "required": [ + "id", + "username", + "created_at" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "created_at": { + "format": "date-time" + } + } + } + } + }, + "tags": [ + { + "name": "public" + }, + { + "name": "catalog" + }, + { + "name": "admin-auth" + }, + { + "name": "admin-agents" + }, + { + "name": "admin-join-tokens" + }, + { + "name": "admin-probe" + }, + { + "name": "admin-tasks" + }, + { + "name": "admin-tests" + }, + { + "name": "admin-data" + }, + { + "name": "admin-api-tokens" + }, + { + "name": "admin-settings" + }, + { + "name": "admin-users" + }, + { + "name": "ops" + }, + { + "name": "agent" + } + ] +} \ No newline at end of file diff --git a/backend/probe/Directory.Build.props b/backend/probe/Directory.Build.props new file mode 100644 index 0000000..22f99e2 --- /dev/null +++ b/backend/probe/Directory.Build.props @@ -0,0 +1,17 @@ + + + + Size + true + false + true + false + false + false + false + false + + + + + diff --git a/backend/probe/README.md b/backend/probe/README.md new file mode 100644 index 0000000..4b50574 --- /dev/null +++ b/backend/probe/README.md @@ -0,0 +1,46 @@ +# Monitor Probe + +Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Agent、执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不直接连接 Backend,也不提供远程命令或文件管理功能。 + +## 工作流程 + +```text +Backend ──任务与节点列表──> Agent <──测量会话──> Probe + │ │ + └──汇总并上报数据 <───┘ +``` + +Probe 使用面板生成的 `probe.json` 读取自身身份、首次连接用的 Agent 地址和本地测试限制。成功连接后,Agent 会同步可用节点列表;Probe 将列表缓存到同目录的 `agents.json`。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。 + +## 构建 + +需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建: + +```sh +cd backend +dotnet build probe/src/Monitor.Probe -c Release +``` + +发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`` 应与构建环境和目标平台匹配: + +```sh +dotnet publish probe/src/Monitor.Probe -c Release -r -p:PublishAot=true +``` + +也可在仓库根目录运行 `bash scripts/publish.sh probe `,再使用 `scripts/package.sh probe ` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。 + +## 配置与运行 + +在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/monitor/probe.json`。随后运行: + +```sh +monitor-probe --config /etc/monitor/probe.json +``` + +不传 `--config` 时默认读取 `/etc/monitor/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。 + +## 设计说明 + +- Probe 只执行编译进程序的测量模块。它不认识 Backend,不落盘测量数据,也不能执行命令或自行更新。 +- 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 +- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。 diff --git a/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs b/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs new file mode 100644 index 0000000..6141819 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/Link/AgentConnection.cs @@ -0,0 +1,428 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Link; +using Fleet.Rpc; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Probe; + +/// 所有连接共用的东西。 +public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log); + +/// +/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 取 Agent 列表 → 按 Agent 下发的 line_task_set 运行角色 → +/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。 +/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。 +/// +public sealed class AgentConnection +{ + private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); + private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30); + private static readonly RpcRouter s_router = BuildRouter(); + + private readonly ConnectionEnv _env; + private volatile ConnectionSession? _session; + + public AgentConnection(BootstrapAgent endpoint, ConnectionEnv env) + { + Endpoint = endpoint; + _env = env; + } + + public BootstrapAgent Endpoint { get; } + + public bool IsConnected => _session is not null; + + public int RoleCount => _session?.RoleCount ?? 0; + + public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0; + + public async Task RunAsync(CancellationToken ct) + { + var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20)); + while (!ct.IsCancellationRequested) + { + try + { + string reason = await RunOnceAsync(backoff, ct); + _env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException) + { + _env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message)); + } + + try + { + await Task.Delay(backoff.Next(), _env.Time, ct); + } + catch (OperationCanceledException) + { + return; + } + } + } + + private async Task RunOnceAsync(Backoff backoff, CancellationToken ct) + { + (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( + Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); + var remote = (IPEndPoint)info.Remote!; + var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); + using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []); + udp.Bind(); + var outbox = new Outbox(8 << 20); + var clock = new ClockEstimator(); + var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time); + var blockEnv = new BlockEnv + { + Clock = _env.Clock, + Log = _env.Log, + Session = () => data, + Udp = udp, + PeerAddress = remote.Address, + PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki), + Fragments = shipper, + MaxPacketsPerSecond = _env.Limits.MaxTotalPps, + MaxBulkMbps = _env.Limits.MaxTestMbps, + }; + var session = new ConnectionSession(blockEnv, clock, outbox, _env); + var rpc = new RpcSession(transport, s_router, session, _env.Time); + rpc.OnBinary = (_, _) => ValueTask.CompletedTask; + + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task run = rpc.RunAsync(cts.Token); + Task receive = udp.RunAsync(cts.Token); + Task pump = PumpAsync(rpc, outbox, cts.Token); + try + { + // 先同步时钟(块头里要带偏移),再取 Agent 列表;Agent 随后推送线路任务集 + await SyncClockAsync(rpc, clock, rounds: 5, cts.Token); + backoff.Reset(); + _session = session; + _env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs)); + await RefreshDirectoryAsync(rpc, cts.Token); + Task maintenance = MaintainAsync(rpc, session, clock, cts.Token); + Task finished = await Task.WhenAny(run, receive, pump, maintenance); + await cts.CancelAsync(); + await Task.WhenAll(Quiet(run), Quiet(receive), Quiet(pump), Quiet(maintenance)); + return finished == run ? "closed by the agent" : "session task failed"; + } + finally + { + _session = null; + await cts.CancelAsync(); + await session.StopAllAsync(); + await Quiet(run); + await Quiet(receive); + await Quiet(pump); + await rpc.DisposeAsync(); + } + } + + private static async Task Quiet(Task task) + { + try + { + await task; + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException) + { + // 会话结束时的正常噪声 + } + } + + private static async Task PumpAsync(RpcSession rpc, Outbox outbox, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + byte[] frame = await outbox.TakeNextAsync(2 << 20, ct); + await rpc.SendBinaryAsync(frame, ct); + } + } + + private async Task SyncClockAsync(RpcSession rpc, ClockEstimator clock, int rounds, CancellationToken ct) + { + for (int i = 0; i < rounds; i++) + { + long sent = _env.Clock.NowUs(); + Result reply = await rpc.RequestAsync(MonitorMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MonitorJsonContext.Default.ClockParams, MonitorJsonContext.Default.ClockResult, ct); + if (reply.TryGet(out ClockResult? value, out _)) + { + clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs()); + } + } + } + + private async Task RefreshDirectoryAsync(RpcSession rpc, CancellationToken ct) + { + Result list = await rpc.RequestAsync( + MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct); + if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error)) + { + _env.Directory.Apply(agents); + } + else + { + _env.Log.Warn("could not fetch the agent list", ("agent", Endpoint.AgentId), ("err", error.Code)); + } + } + + /// 切块(每 10 秒)和时钟同步(每 30 秒)。 + private async Task MaintainAsync(RpcSession rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct) + { + using var flush = new PeriodicTimer(s_flushInterval, _env.Time); + long lastClockUs = _env.Clock.NowUs(); + while (await flush.WaitForNextTickAsync(ct)) + { + session.FlushLineBlocks(); + if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds) + { + lastClockUs = _env.Clock.NowUs(); + await SyncClockAsync(rpc, clock, rounds: 1, ct); + } + } + } + + private static RpcRouter BuildRouter() => + new RpcRouter() + .On( + FleetMethods.StateSync, + FleetRpcContext.Default.StateSyncParams, + FleetRpcContext.Default.StateSyncResult, + async (session, sync, _) => + { + var result = new StateSyncResult { AppliedVersion = sync.Version }; + if (sync.Type == LineTaskSet.StateType) + { + LineTaskSet? set = sync.Doc.Deserialize(MonitorJsonContext.Default.LineTaskSet); + if (set is null) + { + return Result.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed"); + } + + result.Rejected.AddRange(await session.ApplyAsync(set)); + } + + return Result.Ok(result); + }) + .On( + MonitorMethods.TestPhase, + MonitorJsonContext.Default.TestPhaseStartParams, + MonitorJsonContext.Default.TestPhaseStartResult, + (session, start, _) => ValueTask.FromResult(Result.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) }))) + .On( + MonitorMethods.TestPhaseEnd, + MonitorJsonContext.Default.TestPhaseEndParams, + MonitorJsonContext.Default.TestPhaseEndResult, + async (session, end, ct) => Result.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) })) + .OnNotify( + MonitorMethods.TestAbort, + MonitorJsonContext.Default.TestAbortParams, + async (session, abort, _) => await session.AbortAsync(abort.TestId)) + .OnNotify( + MonitorMethods.ProbeAgentsChanged, + MonitorJsonContext.Default.ProbeAgentsResult, + (session, list, _) => + { + session.Env.Directory.Apply(list); + return ValueTask.CompletedTask; + }) + .OnNotify( + FleetMethods.OutboxAck, + FleetRpcContext.Default.OutboxAckParams, + (session, ack, _) => + { + session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse)); + return ValueTask.CompletedTask; + }); +} + +/// 一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。 +public sealed class ConnectionSession +{ + private const int MaxBlocks = 16; + + private readonly object _gate = new(); + private readonly Dictionary _lineBlocks = []; + private readonly ConcurrentDictionary _phases = new(); + + public ConnectionSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, ConnectionEnv env) + { + BlockEnv = blockEnv; + Clock = clock; + Outbox = outbox; + Env = env; + } + + public BlockEnv BlockEnv { get; } + + public ClockEstimator Clock { get; } + + public Outbox Outbox { get; } + + public ConnectionEnv Env { get; } + + public int RoleCount + { + get + { + lock (_gate) + { + return _lineBlocks.Count; + } + } + } + + /// 调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。 + public async Task> ApplyAsync(LineTaskSet set) + { + var rejected = new List(); + var wanted = new Dictionary(); + foreach (LineTask task in set.Tasks) + { + BlockSpec? spec = BlockCatalog.ProbeKinds.Contains(task.Kind) + ? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null)) + : null; + if (spec is null) + { + rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.ProbeKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" }); + } + else if (wanted.Count >= MaxBlocks) + { + rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" }); + } + else + { + wanted[task.Id.Value] = spec; + } + } + + var stopped = new List(); + lock (_gate) + { + foreach ((string id, RunningBlock running) in _lineBlocks.ToList()) + { + if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature) + { + stopped.Add(running); + _lineBlocks.Remove(id); + } + } + + foreach ((string id, BlockSpec spec) in wanted) + { + if (!_lineBlocks.ContainsKey(id)) + { + _lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature); + } + } + } + + // 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到) + foreach (RunningBlock running in stopped) + { + await running.StopAsync(); + running.Block.Flush(FlushMode.Settled); + } + + return rejected; + } + + public void FlushLineBlocks() + { + List blocks; + lock (_gate) + { + blocks = [.. _lineBlocks.Values]; + } + + foreach (RunningBlock running in blocks) + { + running.Block.Flush(FlushMode.Settled); + } + } + + /// 按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。 + public bool StartPhase(TestPhaseStartParams start) + { + string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null; + if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.ProbeKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false) + { + return false; + } + + if (start.Kind == "udp_train") + { + double mbps = start.Params["mbps"].GetDouble(); + long size = start.Params["size"].GetInt64(); + long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size)); + if (mbps > Env.Limits.MaxTestMbps || pps > Env.Limits.MaxTotalPps) + { + return false; + } + } + + BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase)); + if (spec is null) + { + return false; + } + + _phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature)); + return true; + } + + /// 阶段结束:收尾、交出列块,返回块数。 + public async Task EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct) + { + if (!_phases.TryRemove(end.StreamId, out PhaseState? phase)) + { + return 0; + } + + // 最后一批包(单向数据流)、最后几个回显的回复还在途中 + await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct); + await phase.Running.StopAsync(); + return phase.Running.Block.Flush(FlushMode.All, end.SentCount); + } + + public async Task AbortAsync(TestId test) + { + foreach ((uint stream, PhaseState phase) in _phases.ToList()) + { + if (phase.Test == test && _phases.TryRemove(stream, out _)) + { + await phase.Running.StopAsync(); + } + } + } + + public async Task StopAllAsync() + { + List blocks; + lock (_gate) + { + blocks = [.. _lineBlocks.Values]; + _lineBlocks.Clear(); + } + + blocks.AddRange(_phases.Values.Select(p => p.Running)); + _phases.Clear(); + foreach (RunningBlock running in blocks) + { + await running.StopAsync(); + } + } + + private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running); +} diff --git a/backend/probe/src/Monitor.Probe/Link/AgentDirectory.cs b/backend/probe/src/Monitor.Probe/Link/AgentDirectory.cs new file mode 100644 index 0000000..ad06d12 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/Link/AgentDirectory.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Hosting; +using Monitor.Protocol; + +namespace Monitor.Probe; + +/// +/// Probe 要连接的 Agent 列表。Probe 不认识 Backend:列表先取自 probe.json 的 bootstrap(或上一次保存的 agents.json), +/// 连上任意一个 Agent 之后,由它代为向 Backend 查询(monitor.probe.agents),之后的变化由 Agent 推送(agents_changed)。 +/// 列表变化时用 写回 agents.json(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。 +/// +public sealed class AgentDirectory +{ + public const string FileName = "agents.json"; + + private readonly object _gate = new(); + private readonly ConfigStore _store; + private readonly INodeLog _log; + private List _agents; + private long _version; + + public AgentDirectory(ConfigStore store, string configDirectory, ProbeFile file, INodeLog log) + { + _store = store; + _log = log; + _agents = [.. file.Bootstrap]; + string cache = Path.Combine(configDirectory, FileName); + if (File.Exists(cache)) + { + try + { + AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache); + if (saved.Agents.Count > 0) + { + _agents = saved.Agents; + _version = saved.Version; + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException) + { + _log.Warn("agents.json is unreadable, using the bootstrap list", ("err", ex.Message)); + } + } + } + + /// 列表变化(调和连接)。 + public event Action? Changed; + + public long Version + { + get + { + lock (_gate) + { + return _version; + } + } + } + + public IReadOnlyList Agents + { + get + { + lock (_gate) + { + return [.. _agents]; + } + } + } + + /// 应用 Agent 转来的列表。版本不比当前新的忽略(乱序到达的旧推送)。 + public void Apply(ProbeAgentsResult result) + { + var next = result.Agents.Select(a => new BootstrapAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList(); + lock (_gate) + { + if (result.Version < _version || (result.Version == _version && Same(next, _agents))) + { + return; + } + + _version = result.Version; + _agents = next; + } + + try + { + _store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new AgentsCache { Version = result.Version, Agents = next }, ProbeConfigContext.Default.AgentsCache)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.Warn("could not save agents.json", ("err", ex.Message)); + } + + _log.Info("agent list updated", ("version", result.Version), ("agents", next.Count)); + Changed?.Invoke(); + } + + private static bool Same(List a, List b) => + a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); + + public static bool Equal(BootstrapAgent a, BootstrapAgent b) => + a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki; +} diff --git a/backend/probe/src/Monitor.Probe/Link/Support.cs b/backend/probe/src/Monitor.Probe/Link/Support.cs new file mode 100644 index 0000000..e593ef6 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/Link/Support.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Link; +using Monitor.Measurement; +using Monitor.Protocol; + +namespace Monitor.Probe; + +/// +/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 monitor.clock 往返取中点,在最近 8 次里取往返最短的一次 +/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。 +/// +public sealed class ClockEstimator +{ + private const int Window = 8; + + private readonly object _gate = new(); + private readonly Queue<(long RoundTripUs, long OffsetUs)> _samples = new(); + + public long OffsetUs { get; private set; } + + public bool HasEstimate { get; private set; } + + public void Add(long sendUs, long agentUs, long receiveUs) + { + lock (_gate) + { + _samples.Enqueue((receiveUs - sendUs, agentUs - ((sendUs + receiveUs) / 2))); + while (_samples.Count > Window) + { + _samples.Dequeue(); + } + + OffsetUs = _samples.MinBy(s => s.RoundTripUs).OffsetUs; + HasEstimate = true; + } + } +} + +/// +/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。 +/// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。 +/// +public sealed class FragmentShipper : IFragmentSink +{ + private readonly Outbox _outbox; + private readonly NodeId _agent; + private readonly ProbeId _probe; + private readonly ClockEstimator _clock; + private readonly TimeProvider _time; + + public FragmentShipper(Outbox outbox, NodeId agent, ProbeId probe, ClockEstimator clock, TimeProvider time) + { + _outbox = outbox; + _agent = agent; + _probe = probe; + _clock = clock; + _time = time; + } + + public void Ship(string kind, FragmentScope scope, IReadOnlyList streams, IReadOnlyDictionary? parameters, long startUtcMs, int durationMs, int port) + { + var header = new RawChunkHeader + { + ChunkId = ChunkId.New(_time), + Kind = kind, + TaskId = scope.Task, + TestId = scope.Test, + Phase = scope.Phase, + AgentId = _agent, + ProbeId = _probe, + Port = port, + StartUtcMs = startUtcMs, + DurationMs = Math.Max(1, durationMs), + Clock = new ChunkClock { ProbeOffsetUs = _clock.OffsetUs }, + }; + if (parameters is not null) + { + foreach ((string key, JsonElement value) in parameters) + { + header.Params[key] = value; + } + } + + _outbox.Enqueue(MonitorChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams))); + } +} diff --git a/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj new file mode 100644 index 0000000..9621c17 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/Monitor.Probe.csproj @@ -0,0 +1,14 @@ + + + Exe + monitor-probe + Monitor.Probe + true + + + + + + + + diff --git a/backend/probe/src/Monitor.Probe/ProbeApp.cs b/backend/probe/src/Monitor.Probe/ProbeApp.cs new file mode 100644 index 0000000..f6a71a9 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/ProbeApp.cs @@ -0,0 +1,154 @@ +using Fleet.Core; +using Fleet.Hosting; +using Fleet.Identity; +using Monitor.Measurement; + +namespace Monitor.Probe; + +/// +/// Probe 的组装点:Agent 列表 → 每个 Agent 一个连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。 +/// 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。 +/// +public sealed class ProbeApp : IDisposable +{ + public const string Version = "0.1.0"; + + private readonly ConnectionEnv _env; + private readonly Dictionary _connections = []; + private readonly SemaphoreSlim _changed = new(0); + private readonly INodeLog _log; + + public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log) + { + _log = log; + var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, file, log); + NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey)); + _env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log); + directory.Changed += () => _changed.Release(); + } + + public AgentDirectory Directory => _env.Directory; + + /// 某个 Agent 的会话是否已经建立(测试和状态查看用)。 + public bool IsConnected(string agentId) + { + lock (_connections) + { + return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected; + } + } + + public AgentConnection? Connection(string agentId) + { + lock (_connections) + { + return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null; + } + } + + public async Task RunAsync(CancellationToken ct) + { + if (_env.Directory.Agents.Count == 0) + { + _log.Warn("no agents known yet: probe.json has an empty bootstrap list"); + } + + try + { + while (!ct.IsCancellationRequested) + { + await ReconcileAsync(_env.Directory.Agents, ct); + await _changed.WaitAsync(ct); + } + } + catch (OperationCanceledException) + { + // 停止 + } + finally + { + await ReconcileAsync([], CancellationToken.None); + } + } + + public void Dispose() + { + _env.Identity.Dispose(); + _changed.Dispose(); + } + + private async Task ReconcileAsync(IReadOnlyList agents, CancellationToken ct) + { + var wanted = agents.ToDictionary(a => a.AgentId); + var stopped = new List(); + lock (_connections) + { + foreach ((string id, Running running) in _connections.ToList()) + { + if (!wanted.TryGetValue(id, out BootstrapAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint)) + { + stopped.Add(running); + _connections.Remove(id); + } + } + } + + foreach (Running running in stopped) + { + await running.StopAsync(); + } + + if (ct.IsCancellationRequested) + { + return; + } + + lock (_connections) + { + foreach ((string id, BootstrapAgent endpoint) in wanted) + { + if (!_connections.ContainsKey(id)) + { + _connections[id] = Running.Start(new AgentConnection(endpoint, _env)); + } + } + } + } + + private sealed class Running : IDisposable + { + private readonly CancellationTokenSource _cts = new(); + private Task _run = Task.CompletedTask; + + private Running(AgentConnection connection) + { + Connection = connection; + } + + public AgentConnection Connection { get; } + + public static Running Start(AgentConnection connection) + { + var running = new Running(connection); + running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None); + return running; + } + + public async Task StopAsync() + { + await _cts.CancelAsync(); + try + { + await _run.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + // 没有及时退出:不再等 + } + + Dispose(); + } + + public void Dispose() => _cts.Dispose(); + } +} diff --git a/backend/probe/src/Monitor.Probe/ProbeConfig.cs b/backend/probe/src/Monitor.Probe/ProbeConfig.cs new file mode 100644 index 0000000..9b72feb --- /dev/null +++ b/backend/probe/src/Monitor.Probe/ProbeConfig.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace Monitor.Probe; + +/// +/// probe.json:面板 POST /api/admin/probe/identity 生成的文件(只能下载一次)。 +/// limits 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。 +/// +public sealed class ProbeFile +{ + [JsonPropertyName("probe_id")] + public string ProbeId { get; set; } = ""; + + /// PKCS#8,base64。 + [JsonPropertyName("private_key")] + public string PrivateKey { get; set; } = ""; + + /// 首次连接用的 Agent;连上之后以 agents.json(最新的列表)为准。 + [JsonPropertyName("bootstrap")] + public List Bootstrap { get; set; } = []; + + [JsonPropertyName("limits")] + public ProbeLimits Limits { get; set; } = new(); +} + +public sealed class BootstrapAgent +{ + [JsonPropertyName("agent_id")] + public string AgentId { get; set; } = ""; + + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + [JsonPropertyName("tcp_port")] + public int TcpPort { get; set; } + + /// Agent 的 TLS 证书指纹(SHA-256,base64)。 + [JsonPropertyName("spki")] + public string Spki { get; set; } = ""; +} + +public sealed class ProbeLimits +{ + /// 按需测试里单个阶段允许的最大码率(Mbit/s)。 + [JsonPropertyName("max_test_mbps")] + public double MaxTestMbps { get; set; } = 100; + + /// 按需测试里单个阶段允许的最大包速率。 + [JsonPropertyName("max_total_pps")] + public long MaxTotalPps { get; set; } = 20_000; +} + +[JsonSerializable(typeof(ProbeFile))] +[JsonSerializable(typeof(AgentsCache))] +public sealed partial class ProbeConfigContext : JsonSerializerContext; + +/// agents.json:最近一次从 Agent 同步到的列表(配置,不是测量数据)。 +public sealed class AgentsCache +{ + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("agents")] + public List Agents { get; set; } = []; +} diff --git a/backend/probe/src/Monitor.Probe/Program.cs b/backend/probe/src/Monitor.Probe/Program.cs new file mode 100644 index 0000000..4892703 --- /dev/null +++ b/backend/probe/src/Monitor.Probe/Program.cs @@ -0,0 +1,50 @@ +using Fleet.Core; +using Fleet.Hosting; +using Monitor.Probe; + +TimeProvider time = TimeProvider.System; +INodeLog log = new ConsoleNodeLog(time); + +if (PlatformCheck.VerifyCrypto() is { } problem) +{ + log.Error("startup failed", ("err", problem)); + return 1; +} + +string configPath = "/etc/monitor/probe.json"; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--config") + { + configPath = args[i + 1]; + } +} + +ProbeFile file; +try +{ + file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile); + if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0) + { + throw new InvalidOperationException("probe.json needs probe_id and private_key (download it from the panel: POST /api/admin/probe/identity)"); + } +} +catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException) +{ + log.Error("startup failed", ("err", ex.Message)); + return 1; +} + +string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!; +try +{ + using var app = new ProbeApp(file, directory, time, log); + await ServiceShell.RunAsync(app.RunAsync, log); +} +catch (Exception ex) when (ex is FormatException or System.Security.Cryptography.CryptographicException) +{ + log.Error("startup failed", ("err", "private_key is not a valid PKCS#8 key: " + ex.Message)); + return 1; +} + +return 0; diff --git a/backend/probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj b/backend/probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj new file mode 100644 index 0000000..1367d6a --- /dev/null +++ b/backend/probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + false + + + + + + + + + diff --git a/backend/probe/tests/Monitor.Probe.Tests/ProbeTests.cs b/backend/probe/tests/Monitor.Probe.Tests/ProbeTests.cs new file mode 100644 index 0000000..60412c3 --- /dev/null +++ b/backend/probe/tests/Monitor.Probe.Tests/ProbeTests.cs @@ -0,0 +1,87 @@ +using Fleet.Core; +using Fleet.Hosting; +using Monitor.Protocol; + +namespace Monitor.Probe.Tests; + +public sealed class ProbeTests +{ + [Fact] + public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip() + { + var clock = new ClockEstimator(); + Assert.False(clock.HasEstimate); + + // Offset 1000 with a 50 ms round trip, offset 5000 with a 2 ms round trip (symmetry assumption is far better here) + clock.Add(sendUs: 0, agentUs: 25_000 + 1000, receiveUs: 50_000); + clock.Add(sendUs: 100_000, agentUs: 101_000 + 5000, receiveUs: 102_000); + Assert.True(clock.HasEstimate); + Assert.Equal(5000, clock.OffsetUs); + + // Slow samples do not displace the good one + clock.Add(sendUs: 200_000, agentUs: 300_000, receiveUs: 400_000); + Assert.Equal(5000, clock.OffsetUs); + } + + private static string NewDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "probe-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static ProbeAgentsResult List(long version, params string[] ids) => new() + { + Version = version, + Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })], + }; + + [Fact] + public void AgentDirectory_StartsFromBootstrap_IgnoresStaleLists_PersistsChanges_AndReloadsThem() + { + string directory = NewDirectory(); + try + { + var file = new ProbeFile { ProbeId = "prb_x", Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "boot.example", TcpPort = 7443, Spki = "c3BraQ==" }] }; + var directoryA = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance); + int changes = 0; + directoryA.Changed += () => changes++; + Assert.Equal(["agt_boot"], directoryA.Agents.Select(a => a.AgentId)); + + directoryA.Apply(List(5, "agt_1", "agt_2")); + Assert.Equal(1, changes); + Assert.Equal(["agt_1", "agt_2"], directoryA.Agents.Select(a => a.AgentId)); + + directoryA.Apply(List(4, "agt_old")); // out-of-order older push + directoryA.Apply(List(5, "agt_1", "agt_2")); // same version and content + Assert.Equal(1, changes); + Assert.Equal(5, directoryA.Version); + + // The next start uses the saved list, not the bootstrap one + var directoryB = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance); + Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId)); + Assert.Equal(5, directoryB.Version); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void AgentDirectory_FallsBackToBootstrap_WhenTheSavedFileIsCorrupt() + { + string directory = NewDirectory(); + try + { + new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray()); + var file = new ProbeFile { Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "h", TcpPort = 1, Spki = "AA==" }] }; + var loaded = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance); + Assert.Equal(["agt_boot"], loaded.Agents.Select(a => a.AgentId)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/backend/shared/Directory.Build.props b/backend/shared/Directory.Build.props new file mode 100644 index 0000000..12d9ad0 --- /dev/null +++ b/backend/shared/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/backend/shared/Monitor.Measurement/Blocks/BlockBase.cs b/backend/shared/Monitor.Measurement/Blocks/BlockBase.cs new file mode 100644 index 0000000..8463d00 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/BlockBase.cs @@ -0,0 +1,105 @@ +using System.Net; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 积木的公共部分:环境、参数、把行切成列块交出去。 +public abstract class BlockBase : IBlock +{ + protected BlockBase(BlockEnv env, BlockSpec spec) + { + Env = env; + Spec = spec; + } + + public virtual long Produced => 0; + + protected BlockEnv Env { get; } + + protected BlockSpec Spec { get; } + + public abstract Task RunAsync(CancellationToken ct); + + public virtual int Flush(FlushMode mode, long peerProduced = 0) => 0; + + public virtual RowStore? Records(string streamName) => null; + + protected IPEndPoint PeerEndpoint(int port) => new(Env.PeerAddress!, port); + + /// 交出一个流的行;起点和时长取自时间列 的首尾(本侧的单调时钟,微秒)。 + protected void Ship(RawStream stream, string timeColumn, IReadOnlyList? extra = null, IReadOnlyDictionary? parameters = null) + { + RawColumn? time = stream.Find(timeColumn); + long first = stream.Count > 0 && time is not null ? time.Values[0] : Env.Clock.NowUs(); + long last = stream.Count > 0 && time is not null ? time.Values[stream.Count - 1] : first; + List streams = [stream]; + if (extra is not null) + { + streams.AddRange(extra); + } + + Env.Fragments!.Ship(Spec.KindId, Spec.Scope, streams, parameters ?? Spec.Params.Values, Env.Clock.UtcMsAt(first), (int)((last - first) / 1000), Spec.Port); + } + + /// 什么也不做、一直等到取消(监听方里不需要自己运行逻辑的积木)。 + protected async Task WaitUntilCancelledAsync(CancellationToken ct) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, Env.Time, ct); + } + catch (OperationCanceledException) + { + // 被停止 + } + } +} + +/// +/// 拨号方作为行的主导方时记录的行:行按键(序号)追加,回复到达时按键回填列,到点把已经稳定的行从游标处切下来交出去。 +/// 环形缓冲被覆盖的行(交出太慢)直接跳过。 +/// +internal sealed class DriverLog +{ + private readonly object _gate = new(); + private long _cursor; + + public DriverLog(StreamSpec spec, Side driver, int capacity) + { + Store = new RowStore(spec, driver, capacity); + Spec = spec; + } + + public RowStore Store { get; } + + public StreamSpec Spec { get; } + + public long Count => Store.NextKey; + + /// 取出游标之后已经稳定的行: 为 null 或 时取全部。 + public RawStream? Take(Func? settled, bool all, int maxRows = int.MaxValue) + { + long from; + long to; + lock (_gate) + { + from = _cursor; + to = from; + long end = Store.NextKey; + while (to < end && to - from < maxRows && (all || settled is null || !Store.Contains(to) || settled(to))) + { + to++; + } + + if (to == from) + { + return null; + } + + _cursor = to; + } + + RawStream stream = Store.Cut(from, to, includeKey: Spec.Key is not null); + return stream.Count == 0 ? null : stream; + } +} diff --git a/backend/shared/Monitor.Measurement/Blocks/BlockCatalog.cs b/backend/shared/Monitor.Measurement/Blocks/BlockCatalog.cs new file mode 100644 index 0000000..3970f2b --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/BlockCatalog.cs @@ -0,0 +1,45 @@ +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// +/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。 +/// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。 +/// +public sealed record BlockPair( + string Kind, + Func Dialer, + Func? Listener, + bool RunsAsTarget = false, + bool OnDemandOnly = false, + IReadOnlyList? Directions = null) +{ + /// 这个 Kind 支持 吗(没有 direction 参数的 Kind 传 null)。 + public bool Supports(string? direction) => Directions is null || direction is null || Directions.Contains(direction); +} + +/// +/// 积木目录:新增一种采集方法 = 在 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。 +/// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。 +/// +public static class BlockCatalog +{ + public static readonly IReadOnlyList All = + [ + new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)), + new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)), + new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]), + new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]), + new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true), + new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)), + new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true), + ]; + + public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind); + + /// 这个构建的 Agent 能运行的 Kind(监听方,或目标任务)。 + public static IReadOnlyList AgentKinds { get; } = [.. All.Where(p => p.Listener is not null || p.RunsAsTarget).Select(p => p.Kind)]; + + /// 这个构建的 Probe 能运行的 Kind(有监听方可以配对的拨号方)。 + public static IReadOnlyList ProbeKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)]; +} diff --git a/backend/shared/Monitor.Measurement/Blocks/Bulk.cs b/backend/shared/Monitor.Measurement/Blocks/Bulk.cs new file mode 100644 index 0000000..5fc0591 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/Bulk.cs @@ -0,0 +1,277 @@ +using System.Net.Sockets; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 批量传输的收发循环(两端共用)。 +internal static class BulkIo +{ + private const int ChunkBytes = 16 * 1024; + + /// + /// 发送:尽可能快,但不超过 (本地硬上限,防止把小机器的 CPU 和链路打满)。 + /// 发送的数据是随机字节(避免被链路上的压缩或重复数据消除"优化"掉)。 + /// + public static async Task SendAsync(Stream stream, double mbps, Action onBytes, TimeProvider time, CancellationToken ct) + { + byte[] buffer = new byte[ChunkBytes]; + Random.Shared.NextBytes(buffer); + double bytesPerSecond = mbps * 1_000_000 / 8; + long start = time.GetTimestamp(); + long sent = 0; + while (!ct.IsCancellationRequested) + { + await stream.WriteAsync(buffer, ct); + sent += buffer.Length; + onBytes(buffer.Length); + if (bytesPerSecond > 0) + { + double ahead = (sent / bytesPerSecond) - time.GetElapsedTime(start).TotalSeconds; + if (ahead > 0.002) + { + await Task.Delay(TimeSpan.FromSeconds(Math.Min(ahead, 0.05)), time, ct); + } + } + } + } + + /// 接收并丢弃,直到对端关闭或取消。 + public static async Task ReceiveAsync(Stream stream, Action onBytes, CancellationToken ct) + { + byte[] buffer = new byte[4 * ChunkBytes]; + while (!ct.IsCancellationRequested) + { + int read = await stream.ReadAsync(buffer, ct); + if (read <= 0) + { + return; + } + + onBytes(read); + } + } + + public static bool IsExpected(Exception ex) => + ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException or System.Security.Authentication.AuthenticationException or InvalidOperationException; +} + +/// +/// 批量传输,拨号方(Probe):开 streams 条 TCP 连接(可选 TLS),按 direction 发送(fwd)或接收(rev),持续 duration_s。 +/// 记录每个采样间隔每条连接的累计字节数(bytes 流,Probe 的时钟),Backend 据此求每个间隔的吞吐; +/// 同时采样第一条连接自己这一侧的内核 tcp_info(多条连接的累计计数器不能混在同一个序列里求差)。 +/// 发送速率不超过本地上限 。 +/// +public sealed class TcpBulkDialer : BlockBase +{ + private const int MaxRowsPerFragment = 40_000; + + private readonly DriverLog _log; + private readonly TcpInfoRecorder _info; + private readonly int _ts; + private readonly int _conn; + private readonly int _bytes; + private long[] _counters = []; + + public TcpBulkDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + long expectedRows = spec.Int("streams") * spec.Int("duration_s") * Math.Max(1, 1000 / Math.Max(1, spec.Int("sample_ms"))); + _log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Probe, (int)Math.Clamp(expectedRows + 256, 1024, 262_144)); + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock); + _ts = _log.Store.ColumnIndex("ts"); + _conn = _log.Store.ColumnIndex("conn"); + _bytes = _log.Store.ColumnIndex("bytes"); + } + + public override long Produced => _log.Count; + + public override async Task RunAsync(CancellationToken ct) + { + int streams = (int)Spec.Int("streams"); + bool send = Spec.Params.GetString("direction") == "fwd"; + TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s")); + TimeSpan sampleEvery = TimeSpan.FromMilliseconds(Spec.Int("sample_ms")); + _counters = new long[streams]; + + var links = new TcpLink?[streams]; + try + { + await Task.WhenAll(Enumerable.Range(0, streams).Select(async i => + { + try + { + links[i] = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct); + } + catch (Exception ex) when (BulkIo.IsExpected(ex)) + { + // 这条连接没有建起来:它的字节数一直是 0 + } + })); + + using var timeout = new CancellationTokenSource(duration, Env.Time); + using var run = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + var work = new List(); + double perStream = Env.MaxBulkMbps / streams; + for (int i = 0; i < streams; i++) + { + if (links[i] is not { } link) + { + continue; + } + + int index = i; + work.Add(RunConnectionAsync(link.Stream, index, send, perStream, run.Token)); + } + + if (links.FirstOrDefault(l => l is not null) is { } first) + { + work.Add(_info.RunAsync(first.Socket, sampleEvery, run.Token)); + } + + Task sampler = SampleAsync(sampleEvery, run.Token); + await Task.WhenAll(work); + await run.CancelAsync(); + await sampler; + Sample(); // 最后一个采样点 + } + finally + { + foreach (TcpLink? link in links) + { + link?.Dispose(); + } + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + int fragments = 0; + while (_log.Take(null, all: true, MaxRowsPerFragment) is { } stream) + { + RawStream kernel = _info.Rows.Drain(includeKey: false); + Ship(stream, "ts", kernel.Count > 0 ? [kernel] : null); + fragments++; + } + + if (fragments == 0 && _info.Rows.Drain(includeKey: false) is { Count: > 0 } onlyKernel) + { + Ship(onlyKernel, "ts"); + fragments++; + } + + return fragments; + } + + private async Task RunConnectionAsync(Stream stream, int index, bool send, double mbps, CancellationToken ct) + { + try + { + if (send) + { + await BulkIo.SendAsync(stream, mbps, n => Interlocked.Add(ref _counters[index], n), Env.Time, ct); + } + else + { + await BulkIo.ReceiveAsync(stream, n => Interlocked.Add(ref _counters[index], n), ct); + } + } + catch (Exception ex) when (BulkIo.IsExpected(ex)) + { + // 时间到或者连接断开:计数停在这里 + } + } + + private async Task SampleAsync(TimeSpan every, CancellationToken ct) + { + using var timer = new PeriodicTimer(every, Env.Time); + try + { + while (await timer.WaitForNextTickAsync(ct)) + { + Sample(); + } + } + catch (OperationCanceledException) + { + // 阶段结束 + } + } + + private void Sample() + { + long now = Env.Clock.NowUs(); + for (int i = 0; i < _counters.Length; i++) + { + long key = _log.Store.Append(); + _log.Store.Set(key, _ts, now); + _log.Store.Set(key, _conn, i); + _log.Store.Set(key, _bytes, Interlocked.Read(ref _counters[i])); + } + } +} + +/// +/// 批量传输,监听方(Agent):接受连接,rev 时向 Probe 发送(受本地码率上限约束),fwd 时接收并丢弃; +/// 采样第一条连接自己这一侧的内核 tcp_info(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。 +/// 连接在 duration_s 之后(加几秒余量)或阶段结束时关闭。 +/// +public sealed class TcpBulkListener : BlockBase +{ + private readonly TcpInfoRecorder _info; + private CancellationToken _stop; + private int _sampled; + + public TcpBulkListener(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock); + } + + public override RowStore? Records(string streamName) => streamName == "tcp_info" ? _info.Rows : null; + + public override async Task RunAsync(CancellationToken ct) + { + _stop = ct; + Env.TcpListen!.Register(Spec.StreamId, HandleAsync); + try + { + await WaitUntilCancelledAsync(ct); + } + finally + { + Env.TcpListen.Unregister(Spec.StreamId); + } + } + + private async Task HandleAsync(TcpConnection connection, CancellationToken acceptorToken) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(Spec.Int("duration_s") + 3), Env.Time); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(acceptorToken, _stop, timeout.Token); + Task sampler = Task.CompletedTask; + if (Interlocked.CompareExchange(ref _sampled, 1, 0) == 0) + { + sampler = _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("sample_ms")), linked.Token); + } + + try + { + if (Spec.Params.GetString("direction") == "rev") + { + await BulkIo.SendAsync(connection.Stream, Env.MaxBulkMbps / Math.Max(1, Spec.Int("streams")), _ => Env.Counters.PacketOut(), Env.Time, linked.Token); + } + else + { + await BulkIo.ReceiveAsync(connection.Stream, _ => Env.Counters.PacketIn(), linked.Token); + } + } + catch (Exception ex) when (BulkIo.IsExpected(ex)) + { + // 时间到、阶段结束或对端关闭 + } + finally + { + await linked.CancelAsync(); + await sampler; + } + } +} diff --git a/backend/shared/Monitor.Measurement/Blocks/Echo.cs b/backend/shared/Monitor.Measurement/Blocks/Echo.cs new file mode 100644 index 0000000..94c51b8 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/Echo.cs @@ -0,0 +1,383 @@ +using System.Net; +using System.Net.Sockets; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// +/// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。 +/// UDP 回显和 TCP 回显共用,列由 Kind 的 msg 流声明决定。 +/// +internal sealed class EchoRows +{ + /// UDP:没有在这段时间内回来的包就是丢了。 + public const long UdpGraceUs = 2_000_000; + + /// + /// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。 + /// 如果像 UDP 那样等 2 秒就把没有回复的行当作丢失交出去,最严重的长尾反而成了"丢包",RTT 里一点也看不到。 + /// 所以 TCP 的行要等回复(或者连接真的断了——30 秒之后仍然没有回复才交出,t4 缺失)。 + /// + public const long TcpGraceUs = 30_000_000; + + private readonly DriverLog _log; + private readonly int _size; + private readonly int _t1; + private readonly int _t4; + private readonly long _graceUs; + private readonly bool _settleOnReply; + + public EchoRows(KindSpec kind, int capacity, long graceUs, bool settleOnReply) + { + _graceUs = graceUs; + _settleOnReply = settleOnReply; + StreamSpec spec = kind.FindStream("msg")!; + _log = new DriverLog(spec, Side.Probe, capacity); + _size = _log.Store.ColumnIndex("size"); + _t1 = _log.Store.ColumnIndex("t1"); + _t4 = _log.Store.ColumnIndex("t4"); + } + + public long Count => _log.Count; + + public uint Add(int size, long t1Us) + { + long key = _log.Store.Append(); + _log.Store.Set(key, _size, size); + _log.Store.Set(key, _t1, t1Us); + return (uint)key; + } + + public void Reply(uint seq, long t4Us) + { + if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4)) + { + _log.Store.Set(seq, _t4, t4Us); + } + } + + public RawStream? Take(long nowUs, bool all) => + _log.Take( + key => (_settleOnReply && _log.Store.IsSet(key, _t4)) || (_log.Store.TryGet(key, _t1, out long t1) && t1 <= nowUs - _graceUs), + all); +} + +/// +/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。 +/// +public sealed class UdpEchoDialer : BlockBase +{ + private readonly EchoRows _rows; + + public UdpEchoDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false); + } + + public override long Produced => _rows.Count; + + public override async Task RunAsync(CancellationToken ct) + { + UdpMux udp = Env.Udp!; + DataSession session = Env.Session() ?? throw new BlockFaultException("no data session"); + IPEndPoint target = PeerEndpoint(Spec.Port); + int size = (int)Spec.Int("size"); + byte[] packet = new byte[size]; + udp.Register(Spec.StreamId, OnPacket); + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time); + while (await timer.WaitForNextTickAsync(ct)) + { + long t1 = Env.Clock.NowUs(); + uint seq = _rows.Add(size, t1); + UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key); + udp.Send(target, packet); + } + } + catch (OperationCanceledException) + { + // 积木被停止 + } + finally + { + udp.Unregister(Spec.StreamId); + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All); + if (stream is null) + { + return 0; + } + + Ship(stream, "t1"); + return 1; + } + + private void OnPacket(in UdpDelivery delivery) + { + if (delivery.Header.Type == UdpPacketType.EchoReply) + { + _rows.Reply(delivery.Header.Seq, delivery.ReceiveUs); + } + } +} + +/// UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。 +public sealed class UdpEchoListener : BlockBase +{ + private readonly RowStore _records; + + public UdpEchoListener(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768); + } + + public override RowStore? Records(string streamName) => streamName == "msg" ? _records : null; + + public override async Task RunAsync(CancellationToken ct) + { + Env.Udp!.Register(Spec.StreamId, OnPacket); + try + { + await WaitUntilCancelledAsync(ct); + } + finally + { + Env.Udp.Unregister(Spec.StreamId); + } + } + + private void OnPacket(in UdpDelivery delivery) + { + if (delivery.Header.Type != UdpPacketType.EchoRequest) + { + return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址 + } + + long t2 = delivery.ReceiveUs; + delivery.ReplyAs(UdpPacketType.EchoReply); + Span row = [t2, Env.Clock.NowUs()]; + _records.SetRow(delivery.Header.Seq, row); + } +} + +/// +/// TCP 回显,拨号方:一条常驻连接(可选 TLS)上按速率发小消息,回复按序到达。连接断开期间不发消息(没有连接不是丢包), +/// 之后按退避重连;断开和重连本身由 connect/hold 记录。 +/// +public sealed class TcpEchoDialer : BlockBase +{ + private readonly EchoRows _rows; + private readonly TcpInfoRecorder _info; + + public TcpEchoDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _rows = new EchoRows(spec.Kind, 16384, EchoRows.TcpGraceUs, settleOnReply: true); + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock); + } + + public override long Produced => _rows.Count; + + public override async Task RunAsync(CancellationToken ct) + { + var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + while (!ct.IsCancellationRequested) + { + try + { + using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct); + backoff.Reset(); + await ExchangeAsync(link, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) when (ex is SocketException or IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException) + { + // 连接失败或断开:退避之后重连 + } + + try + { + await Task.Delay(backoff.Next(), Env.Time, ct); + } + catch (OperationCanceledException) + { + return; + } + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All); + RawStream kernel = _info.Rows.Drain(includeKey: false); + if (stream is null && kernel.Count == 0) + { + return 0; + } + + // 内核采样(tcp_info)和回显行一起交出;只有采样没有回显行(例如连接刚断开)时单独交出 + if (stream is null) + { + Ship(kernel, "ts"); + } + else + { + Ship(stream, "t1", kernel.Count > 0 ? [kernel] : null); + } + + return 1; + } + + private async Task ExchangeAsync(TcpLink link, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task reader = ReadRepliesAsync(link.Stream, cts.Token); + Task writer = WriteRequestsAsync(link.Stream, cts.Token); + Task sampler = _info.RunAsync(link.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), cts.Token); + await Task.WhenAny(reader, writer); + await cts.CancelAsync(); + foreach (Task task in new[] { reader, writer, sampler }) + { + try + { + await task; + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException) + { + // 连接已经断开 + } + } + } + + private async Task WriteRequestsAsync(Stream stream, CancellationToken ct) + { + int min = (int)Spec.Int("min"); + int max = (int)Spec.Int("max"); + byte[] body = new byte[Math.Max(max, min)]; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time); + while (await timer.WaitForNextTickAsync(ct)) + { + int size = Math.Max(TcpWire.MinEchoBody, max > min ? Random.Shared.Next(min, max + 1) : min); + long t1 = Env.Clock.NowUs(); + uint seq = _rows.Add(size, t1); + body.AsSpan(0, size).Clear(); + TcpWire.WriteEchoBody(body, seq, t1); + await TcpWire.WriteMessageAsync(stream, body.AsMemory(0, size), ct); + await stream.FlushAsync(ct); + } + } + + private async Task ReadRepliesAsync(Stream stream, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct); + if (reply is null) + { + return; // 对端关闭 + } + + long now = Env.Clock.NowUs(); + if (TcpWire.TryReadEchoBody(reply, out uint seq, out _)) + { + _rows.Reply(seq, now); + } + } + } +} + +/// TCP 回显,监听方:逐条回显并记录 t2(读到)和 t3(回复发出)。保持(hold)的监听方也用它,只是不记录。 +public sealed class TcpEchoListener : BlockBase +{ + private readonly RowStore? _records; + private readonly TcpInfoRecorder? _info; + + public TcpEchoListener(BlockEnv env, BlockSpec spec, bool record = true) + : base(env, spec) + { + _records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768) : null; + _info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock) : null; + } + + public override RowStore? Records(string streamName) => streamName switch + { + "msg" => _records, + "tcp_info" => _info?.Rows, + _ => null, + }; + + public override async Task RunAsync(CancellationToken ct) + { + Env.TcpListen!.Register(Spec.StreamId, EchoAsync); + try + { + await WaitUntilCancelledAsync(ct); + } + finally + { + Env.TcpListen.Unregister(Spec.StreamId); + } + } + + private async Task EchoAsync(TcpConnection connection, CancellationToken ct) + { + // 这条连接上 Agent 一侧套接字的内核采样(下行方向的重传、cwnd、srtt) + using var samplerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task sampler = _info is null ? Task.CompletedTask : _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), samplerCts.Token); + try + { + await EchoLoopAsync(connection, ct); + } + finally + { + await samplerCts.CancelAsync(); + await sampler; + } + } + + private async Task EchoLoopAsync(TcpConnection connection, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + byte[]? body = await TcpWire.ReadMessageAsync(connection.Stream, ct); + if (body is null) + { + return; + } + + long t2 = Env.Clock.NowUs(); + Env.Counters.PacketIn(); + if (!TcpWire.TryReadEchoBody(body, out uint seq, out _)) + { + Env.Counters.DropInvalid(); + return; // 协议错乱:关闭连接 + } + + if (Env.Admission is { } admission && !admission.AdmitMessage(connection.Session, connection.StreamId, t2)) + { + Env.Counters.DropRateLimited(); + continue; + } + + long t3 = Env.Clock.NowUs(); + await TcpWire.WriteMessageAsync(connection.Stream, body, ct); + await connection.Stream.FlushAsync(ct); + Env.Counters.PacketOut(); + if (_records is not null) + { + Span row = [t2, t3]; + _records.SetRow(seq, row); + } + } + } +} diff --git a/backend/shared/Monitor.Measurement/Blocks/Session.cs b/backend/shared/Monitor.Measurement/Blocks/Session.cs new file mode 100644 index 0000000..0e52380 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/Session.cs @@ -0,0 +1,362 @@ +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Security.Authentication; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。 +public sealed class IdleListener : BlockBase +{ + public IdleListener(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + } + + public override Task RunAsync(CancellationToken ct) => WaitUntilCancelledAsync(ct); +} + +/// +/// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 ConnectOutcome 一致: +/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭); +/// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。 +/// +public sealed class ConnectDialer : BlockBase +{ + private readonly DriverLog _log; + private readonly int _attempt; + private readonly int _ts; + private readonly int _tcp; + private readonly int _tls; + private readonly int _outcome; + + public ConnectDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Probe, 1024); + _attempt = _log.Store.ColumnIndex("attempt"); + _ts = _log.Store.ColumnIndex("ts"); + _tcp = _log.Store.ColumnIndex("tcp_us"); + _tls = _log.Store.ColumnIndex("tls_us"); + _outcome = _log.Store.ColumnIndex("outcome"); + } + + public override long Produced => _log.Count; + + public override async Task RunAsync(CancellationToken ct) + { + TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute")); + TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms")); + bool tls = Spec.Bool("tls"); + try + { + while (!ct.IsCancellationRequested) + { + long start = Env.Clock.NowUs(); + await AttemptAsync(tls, timeout, start, ct); + TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start); + if (wait > TimeSpan.Zero) + { + await Task.Delay(wait, Env.Time, ct); + } + } + } + catch (OperationCanceledException) + { + // 积木被停止 + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + RawStream? stream = _log.Take(null, all: true); + if (stream is null) + { + return 0; + } + + Ship(stream, "ts"); + return 1; + } + + private async Task AttemptAsync(bool tls, TimeSpan timeout, long startUs, CancellationToken ct) + { + using var timeoutCts = new CancellationTokenSource(timeout, Env.Time); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + long tcpUs = 0; + long? tlsUs = null; + int outcome; + try + { + // 对端不需要认证首帧:只测量建立连接 + using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls, Spec.StreamId, sendAuth: false, linked.Token); + tcpUs = link.TcpUs; + tlsUs = link.TlsUs; + outcome = 0; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + outcome = 1; + } + catch (SocketException ex) when (ex.SocketErrorCode is SocketError.ConnectionRefused or SocketError.ConnectionReset) + { + outcome = 2; + } + catch (Exception ex) when (ex is AuthenticationException || ex.InnerException is AuthenticationException) + { + outcome = 3; + } + catch (Exception ex) when (ex is SocketException or IOException) + { + outcome = ex is IOException { InnerException: SocketException { SocketErrorCode: SocketError.ConnectionReset } } ? 2 : 4; + } + + // 失败时 tcp_us/tls_us 缺失;成功时没有 TLS 则 tls_us 缺失 + long key = _log.Store.Append(); + _log.Store.Set(key, _attempt, key); + _log.Store.Set(key, _ts, startUs); + _log.Store.Set(key, _outcome, outcome); + if (outcome == 0) + { + _log.Store.Set(key, _tcp, tcpUs); + if (tlsUs is { } t) + { + _log.Store.Set(key, _tls, t); + } + } + } +} + +/// +/// 保持,拨号方:一条长时间保持的 TLS 连接,每隔 heartbeat_s 发一个心跳并等回复。记录打开、心跳、重置(连接意外中断)和关闭, +/// 每一行带着连接已经存活的时间。连接中断之后按退避重连。监听方是不记录的 。 +/// +public sealed class HoldDialer : BlockBase +{ + private const int Open = 0; + private const int Heartbeat = 1; + private const int Reset = 2; + private const int Close = 3; + + private readonly DriverLog _log; + private readonly int _ts; + private readonly int _event; + private readonly int _lifetime; + + public HoldDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Probe, 1024); + _ts = _log.Store.ColumnIndex("ts"); + _event = _log.Store.ColumnIndex("event"); + _lifetime = _log.Store.ColumnIndex("lifetime_ms"); + } + + public override long Produced => _log.Count; + + public override async Task RunAsync(CancellationToken ct) + { + var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + while (!ct.IsCancellationRequested) + { + long openedUs = 0; + bool opened = false; + try + { + using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls: true, Spec.StreamId, sendAuth: true, ct); + openedUs = Env.Clock.NowUs(); + opened = true; + backoff.Reset(); + Record(Open, openedUs, openedUs); + await HoldAsync(link.Stream, openedUs, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + if (opened) + { + Record(Close, Env.Clock.NowUs(), openedUs); + } + + return; + } + catch (Exception ex) when (ex is SocketException or IOException or AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException) + { + if (opened) + { + Record(Reset, Env.Clock.NowUs(), openedUs); + } + } + + try + { + await Task.Delay(backoff.Next(), Env.Time, ct); + } + catch (OperationCanceledException) + { + return; + } + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + RawStream? stream = _log.Take(null, all: true); + if (stream is null) + { + return 0; + } + + Ship(stream, "ts"); + return 1; + } + + private async Task HoldAsync(Stream stream, long openedUs, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task reader = ReadAsync(stream, openedUs, cts.Token); + Task writer = WriteAsync(stream, cts.Token); + Task finished = await Task.WhenAny(reader, writer); + await cts.CancelAsync(); + try + { + await finished; // 连接中断时这里抛出,由调用方记录 Reset + if (!ct.IsCancellationRequested) + { + throw new IOException("the peer closed the connection"); + } + } + finally + { + foreach (Task task in new[] { reader, writer }) + { + try + { + await task; + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException) + { + // 已经处理过 + } + } + } + } + + private async Task WriteAsync(Stream stream, CancellationToken ct) + { + byte[] body = new byte[TcpWire.MinEchoBody]; + uint seq = 0; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Spec.Int("heartbeat_s")), Env.Time); + while (await timer.WaitForNextTickAsync(ct)) + { + TcpWire.WriteEchoBody(body, seq++, Env.Clock.NowUs()); + await TcpWire.WriteMessageAsync(stream, body, ct); + await stream.FlushAsync(ct); + } + } + + private async Task ReadAsync(Stream stream, long openedUs, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct); + if (reply is null) + { + return; + } + + Record(Heartbeat, Env.Clock.NowUs(), openedUs); + } + } + + private void Record(int eventCode, long nowUs, long openedUs) + { + long key = _log.Store.Append(); + _log.Store.Set(key, _ts, nowUs); + _log.Store.Set(key, _event, eventCode); + _log.Store.Set(key, _lifetime, Math.Max(0, (nowUs - openedUs) / 1000)); + } +} + +/// +/// ICMP 回显(只有一侧:Agent 对目标地址):按速率发 ICMP 并记录发出和收到的时刻(本地时钟);超时没有回复的行 t4 缺失。 +/// 没有对端,所以没有监听方;行交给 (Agent 自己盖章上传)。 +/// +public sealed class IcmpEchoSolo : BlockBase +{ + private readonly DriverLog _log; + private readonly int _seq; + private readonly int _size; + private readonly int _t1; + private readonly int _t4; + + public IcmpEchoSolo(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + _log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Probe, 1024); + _seq = _log.Store.ColumnIndex("seq"); + _size = _log.Store.ColumnIndex("size"); + _t1 = _log.Store.ColumnIndex("t1"); + _t4 = _log.Store.ColumnIndex("t4"); + } + + public override long Produced => _log.Count; + + public override async Task RunAsync(CancellationToken ct) + { + TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")); + int size = (int)Spec.Int("size"); + byte[] payload = new byte[Math.Max(0, size - 28)]; + using var ping = new Ping(); + try + { + while (!ct.IsCancellationRequested) + { + long t1 = Env.Clock.NowUs(); + long? t4 = null; + try + { + PingReply reply = await ping.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct); + if (reply.Status == IPStatus.Success) + { + t4 = Env.Clock.NowUs(); + } + } + catch (PingException) + { + // 发送失败(例如本机没有路由):记为丢失 + } + + long key = _log.Store.Append(); + _log.Store.Set(key, _seq, key); + _log.Store.Set(key, _size, size); + _log.Store.Set(key, _t1, t1); + if (t4 is { } received) + { + _log.Store.Set(key, _t4, received); + } + + TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - t1); + if (wait > TimeSpan.Zero) + { + await Task.Delay(wait, Env.Time, ct); + } + } + } + catch (OperationCanceledException) + { + // 积木被停止 + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + RawStream? stream = _log.Take(null, all: true); + if (stream is null) + { + return 0; + } + + Ship(stream, "t1"); + return 1; + } +} diff --git a/backend/shared/Monitor.Measurement/Blocks/Train.cs b/backend/shared/Monitor.Measurement/Blocks/Train.cs new file mode 100644 index 0000000..33a1117 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Blocks/Train.cs @@ -0,0 +1,239 @@ +using System.Diagnostics; +using System.Net; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// +/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里, +/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。 +/// 等不到对端的地址(5 秒内没有任何认证过的包)就以 结束。 +/// +public sealed class TrainListener : BlockBase +{ + private static readonly TimeSpan s_endpointWait = TimeSpan.FromSeconds(5); + + private readonly RowStore _records; + private long _sent; + + public TrainListener(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size")); + _records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Agent, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000)); + } + + public override long Produced => Interlocked.Read(ref _sent); + + public override RowStore? Records(string streamName) => streamName == "pkt" ? _records : null; + + public override async Task RunAsync(CancellationToken ct) + { + // Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容 + Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { }); + try + { + DataSession session = await WaitForPeerAsync(ct); + IPEndPoint target = session.UdpEndpoint!; + TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s")); + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + try + { + Send(session, target, duration, ct); + done.SetResult(); + } + catch (Exception ex) + { + done.SetException(ex); + } + }) + { + IsBackground = true, + Name = "udp-train", + }; + thread.Start(); + await done.Task; + } + finally + { + Env.Udp.Unregister(Spec.StreamId); + } + } + + private async Task WaitForPeerAsync(CancellationToken ct) + { + DateTimeOffset deadline = Env.Time.GetUtcNow() + s_endpointWait; + while (true) + { + DataSession? session = Env.Session(); + if (session?.UdpEndpoint is not null) + { + return session; + } + + if (Env.Time.GetUtcNow() >= deadline) + { + throw new BlockFaultException("the peer's address is unknown"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(50), Env.Time, ct); + } + } + + private void Send(DataSession session, IPEndPoint target, TimeSpan duration, CancellationToken ct) + { + double packetsPerSecond = Spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Spec.Int("size"); + int size = (int)Spec.Int("size"); + byte[] packet = new byte[size]; + using var pause = new ManualResetEventSlim(false); + uint seq = 0; + long startTicks = Stopwatch.GetTimestamp(); + long endTicks = startTicks + (long)(duration.TotalSeconds * Stopwatch.Frequency); + while (!ct.IsCancellationRequested) + { + long now = Stopwatch.GetTimestamp(); + if (now >= endTicks) + { + break; + } + + // 到目前为止应该发出的包数(均匀):落后了就追,领先了就等;追赶的上限是 50 ms 的量,避免长时间停顿之后的突发 + double elapsed = (now - startTicks) / (double)Stopwatch.Frequency; + long due = Math.Min((long)(elapsed * packetsPerSecond) + 1, seq + Math.Max(1, (long)(packetsPerSecond * 0.05))); + if (seq >= due) + { + // 离下一个包还早就让出 CPU(至少 1 ms 的粒度),临近了再自旋,保证发送间隔均匀 + if ((seq / packetsPerSecond) - elapsed > 0.002) + { + pause.Wait(1, ct); + } + else + { + Thread.SpinWait(50); + } + + continue; + } + + for (; seq < due; seq++) + { + long sendUs = Env.Clock.NowUs(); + UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key); + Env.Udp!.Send(target, packet); + Span row = [size, sendUs]; + _records.SetRow(seq, row); + Interlocked.Exchange(ref _sent, seq + 1L); + } + } + } +} + +/// +/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配); +/// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 的记录。 +/// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。 +/// +public sealed class TrainDialer : BlockBase +{ + private const int PerFragment = 40_000; + + private readonly RowStore _received; + private readonly RowStore _step; + private readonly int _tRecv; + private readonly long _maxSeq; + private long _startUs; + private long _endUs; + + public TrainDialer(BlockEnv env, BlockSpec spec) + : base(env, spec) + { + double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size")); + long seconds = Math.Max(1, spec.Int("duration_s")); + _maxSeq = Math.Max(1024, Math.Min((long)(pps * seconds * 1.25), env.MaxPacketsPerSecond * seconds * 3 / 2)); + _received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Probe, (int)_maxSeq); + _tRecv = _received.ColumnIndex("t_recv"); + _step = new RowStore(spec.Kind.FindStream("step")!, Side.Probe, 16); + } + + public override async Task RunAsync(CancellationToken ct) + { + UdpMux udp = Env.Udp!; + DataSession session = Env.Session() ?? throw new BlockFaultException("no data session"); + _startUs = Env.Clock.NowUs(); + udp.Register(Spec.StreamId, OnPacket); + try + { + byte[] hello = new byte[UdpPacket.MinBytes + 20]; + IPEndPoint target = PeerEndpoint(Spec.Port); + for (uint seq = 0; seq < 3; seq++) + { + UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key); + udp.Send(target, hello); + } + + await WaitUntilCancelledAsync(ct); + } + finally + { + _endUs = Env.Clock.NowUs(); + udp.Unregister(Spec.StreamId); + } + } + + public override int Flush(FlushMode mode, long peerProduced = 0) + { + if (mode != FlushMode.All) + { + return 0; + } + + long end = _endUs == 0 ? Env.Clock.NowUs() : _endUs; + long step = _step.Append(); + _step.Set(step, _step.ColumnIndex("index"), 0); + _step.Set(step, _step.ColumnIndex("mbps"), (long)Spec.Params.GetNumber("mbps")); + _step.Set(step, _step.ColumnIndex("start_ts"), _startUs); + _step.Set(step, _step.ColumnIndex("end_ts"), end); + + long total = Math.Min(peerProduced, _maxSeq); + int fragments = 0; + for (long from = 0; from < total || fragments == 0; from += PerFragment) + { + long to = Math.Min(from + PerFragment, total); + RawStream pkt = _received.Cut(from, to, includeKey: true); + var parameters = new Dictionary(Spec.Params.Values) + { + [FragmentKeys.SeqFrom] = Number(from), + [FragmentKeys.SeqTo] = Number(to), + }; + Env.Fragments!.Ship( + "udp_train", + Spec.Scope, + fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt], + parameters, + Env.Clock.UtcMsAt(_startUs), + (int)((end - _startUs) / 1000), + Spec.Port); + fragments++; + if (total == 0) + { + break; + } + } + + return fragments; + } + + private static System.Text.Json.JsonElement Number(long value) => + System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone(); + + private void OnPacket(in UdpDelivery delivery) + { + long seq = delivery.Header.Seq; + if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv)) + { + _received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖) + } + } +} diff --git a/backend/shared/Monitor.Measurement/Core/Contracts.cs b/backend/shared/Monitor.Measurement/Core/Contracts.cs new file mode 100644 index 0000000..9919324 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Core/Contracts.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// +/// 数据面会话:session_id 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。 +/// Agent 的会话还带重放窗口等状态,所以是可以继承的。 +/// +public class DataSession +{ + public DataSession(long sessionId, byte[] key) + { + SessionId = sessionId; + Key = key; + } + + public long SessionId { get; } + + public byte[] Key { get; } + + /// 对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。 + public virtual IPEndPoint? UdpEndpoint => null; + + /// TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。 + public virtual bool UseNonce(ReadOnlySpan nonce) => true; +} + +public interface IDataSessions +{ + DataSession? Find(long sessionId); +} + +/// 只有一个会话(拨号方:Probe 与某个 Agent 的会话)。 +public sealed class SingleSession : IDataSessions +{ + private readonly DataSession _session; + + public SingleSession(DataSession session) + { + _session = session; + } + + public DataSession? Find(long sessionId) => sessionId == _session.SessionId ? _session : null; +} + +public enum Admission +{ + Accepted, + Invalid, + RateLimited, +} + +/// +/// 监听方的准入策略(安全边界):重放窗口、令牌桶限速、源地址学习。积木本身不含任何策略; +/// 拨号方不需要,只做 MAC 校验。任何一步失败都是静默丢弃并计数,不回复。 +/// +public interface IAdmission +{ + /// UDP 包: 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。 + Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs); + + /// TCP 回显的一条消息(只限速)。 + bool AdmitMessage(DataSession session, uint streamId, long nowUs); +} + +/// 数据面计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。 +public sealed class DataPlaneCounters +{ + private long _packetsIn; + private long _packetsOut; + private long _invalid; + private long _rateLimited; + + public long PacketsIn => Interlocked.Read(ref _packetsIn); + + public long PacketsOut => Interlocked.Read(ref _packetsOut); + + public long DroppedInvalid => Interlocked.Read(ref _invalid); + + public long DroppedRateLimited => Interlocked.Read(ref _rateLimited); + + public void PacketIn() => Interlocked.Increment(ref _packetsIn); + + public void PacketOut() => Interlocked.Increment(ref _packetsOut); + + public void DropInvalid() => Interlocked.Increment(ref _invalid); + + public void DropRateLimited() => Interlocked.Increment(ref _rateLimited); +} + +/// 一个列块属于线路任务还是按需测试的某个阶段。 +public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase); + +/// +/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。 +/// +public interface IFragmentSink +{ + void Ship(string kind, FragmentScope scope, IReadOnlyList streams, IReadOnlyDictionary? parameters, long startUtcMs, int durationMs, int port); +} + +public enum FlushMode +{ + /// 只交出已经稳定的行(回显类等过宽限期的)。 + Settled, + + /// 交出全部(阶段结束或角色停止)。 + All, +} + +/// 积木无法继续(例如监听方等不到对端的地址):由编排者决定整个测试怎么结束。 +public sealed class BlockFaultException : Exception +{ + public BlockFaultException(string message) + : base(message) + { + } +} + +/// +/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里, +/// 共用包的构造、校验、行的记录;它们只产生原始列,不做任何统计,也不知道上层的方案或协议。 +/// +public interface IBlock +{ + /// 运行到取消(回显、保持)或到自己结束(单向数据流的发送端)。 + Task RunAsync(CancellationToken ct); + + /// + /// 拨号方把已完成的行切成列块交给 ,返回块数;监听方返回 0(它的行留在 里等合并)。 + /// :对端是行的主导方时,对端产生的行数(单向数据流的发送包数)。 + /// + int Flush(FlushMode mode, long peerProduced = 0); + + /// 这一侧作为主导方产生的行数(发出的包数)。 + long Produced { get; } + + /// 这一侧为合并保留的按键记录;没有则为 null。 + RowStore? Records(string streamName); +} + +/// 运行一个积木所需的全部参数:Kind、已经校验并补全默认值的参数、流编号、端口、列块归属。 +public sealed class BlockSpec +{ + public BlockSpec(KindSpec kind, ParamValues parameters, uint streamId, int port, FragmentScope scope) + { + Kind = kind; + Params = parameters; + StreamId = streamId; + Port = port; + Scope = scope; + } + + public KindSpec Kind { get; } + + public ParamValues Params { get; } + + public uint StreamId { get; } + + public int Port { get; } + + public FragmentScope Scope { get; } + + public string KindId => Kind.Id; + + public long Int(string name) => Params.GetInt(name); + + public bool Bool(string name) => Params.GetBool(name); + + /// 参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。 + public string ConfigSignature => KindId + "|" + Port + "|" + + string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText())); + + /// 参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。 + public string Signature => StreamId + "|" + ConfigSignature; + + /// 校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。 + public static BlockSpec? Create(string kindId, IReadOnlyDictionary parameters, uint streamId, int port, FragmentScope scope) + { + KindSpec? kind = Kinds.Find(kindId); + if (kind is null) + { + return null; + } + + ParamResolution resolved = ParamResolver.Resolve(kind.Params, parameters); + return resolved.IsValid ? new BlockSpec(kind, resolved.Values!, streamId, port, scope) : null; + } +} + +/// 积木运行的环境:时钟、套接字、对端地址、列块出口。拨号方和监听方各自组装自己用得到的部分。 +public sealed class BlockEnv +{ + public required MonoClock Clock { get; init; } + + public required INodeLog Log { get; init; } + + /// 当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。 + public required Func Session { get; init; } + + public UdpMux? Udp { get; init; } + + /// 监听方的准入策略(限速等);拨号方为 null。 + public IAdmission? Admission { get; init; } + + public DataPlaneCounters Counters { get; init; } = new(); + + /// 监听方:接受 TCP 数据连接。 + public TcpAcceptor? TcpListen { get; init; } + + /// 拨号方:对端(Agent,或目标任务的目标地址)。 + public IPAddress? PeerAddress { get; init; } + + /// 拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。 + public byte[]? PeerSpkiSha256 { get; init; } + + /// 拨号方:列块出口。 + public IFragmentSink? Fragments { get; init; } + + /// 本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。 + public double MaxBulkMbps { get; init; } = 100; + + /// 本地硬上限:单向数据流接收缓冲的包速率上界。 + public long MaxPacketsPerSecond { get; init; } = 20_000; + + public TimeProvider Time => Clock.Time; + + /// 同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。 + public BlockEnv WithSession(DataSession session) => + new() + { + Clock = Clock, + Log = Log, + Session = () => session, + Udp = Udp, + Admission = Admission, + Counters = Counters, + TcpListen = TcpListen, + PeerAddress = PeerAddress, + PeerSpkiSha256 = PeerSpkiSha256, + Fragments = Fragments, + MaxBulkMbps = MaxBulkMbps, + MaxPacketsPerSecond = MaxPacketsPerSecond, + }; +} diff --git a/backend/shared/Monitor.Measurement/Core/MonoClock.cs b/backend/shared/Monitor.Measurement/Core/MonoClock.cs new file mode 100644 index 0000000..ca52868 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Core/MonoClock.cs @@ -0,0 +1,26 @@ +namespace Monitor.Measurement; + +/// 节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。 +public sealed class MonoClock +{ + private readonly TimeProvider _time; + + public MonoClock(TimeProvider time) + { + _time = time; + } + + public TimeProvider Time => _time; + + public long NowUs() + { + long ticks = _time.GetTimestamp(); + // 先除后乘会丢精度,先乘再除在 64 位内不会溢出(频率通常是 1e9 或 1e7) + return (ticks / _time.TimestampFrequency * 1_000_000) + (ticks % _time.TimestampFrequency * 1_000_000 / _time.TimestampFrequency); + } + + public ClockAnchor Anchor() => new(NowUs(), _time.GetUtcNow().ToUnixTimeMilliseconds()); + + /// 单调时钟读数 对应的 UTC 毫秒(只用于块的粗略起点,精确时间由块头的时钟锚点换算)。 + public long UtcMsAt(long thenUs) => _time.GetUtcNow().ToUnixTimeMilliseconds() - (Math.Max(0, NowUs() - thenUs) / 1000); +} diff --git a/backend/shared/Monitor.Measurement/Core/RowStore.cs b/backend/shared/Monitor.Measurement/Core/RowStore.cs new file mode 100644 index 0000000..9160195 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Core/RowStore.cs @@ -0,0 +1,239 @@ +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// +/// 一侧记录的行:只含这一侧拥有的列(见 ),按连接键(通常是 seq)索引的环形缓冲, +/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并; +/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。 +/// +public sealed class RowStore +{ + private readonly object _gate = new(); + private readonly StreamSpec _spec; + private readonly IReadOnlyList _columns; + private readonly ColumnSpec? _keyColumn; + private readonly int _capacity; + private readonly long[] _keys; + private readonly long[][] _values; + private readonly bool[]?[] _valid; + private long _next; + private long _drained; + + public RowStore(StreamSpec spec, Side owner, int capacity) + { + _spec = spec; + _columns = spec.ColumnsOf(owner); + _keyColumn = spec.Key is null ? null : spec.Columns.First(c => c.Name == spec.Key); + _capacity = Math.Max(16, capacity); + _keys = new long[_capacity]; + Array.Fill(_keys, -1); + _values = new long[_columns.Count][]; + _valid = new bool[]?[_columns.Count]; + for (int i = 0; i < _columns.Count; i++) + { + _values[i] = new long[_capacity]; + _valid[i] = _columns[i].Nullable ? new bool[_capacity] : null; + } + } + + public string Name => _spec.Name; + + /// 下一个自动分配的键(也是已经追加的行数)。 + public long NextKey + { + get + { + lock (_gate) + { + return _next; + } + } + } + + public int ColumnIndex(string name) + { + for (int i = 0; i < _columns.Count; i++) + { + if (_columns[i].Name == name) + { + return i; + } + } + + throw new ArgumentException($"column {name} is not recorded on this side of stream {_spec.Name}", nameof(name)); + } + + /// + /// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始, + /// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。 + /// + public void Clear() + { + lock (_gate) + { + Array.Fill(_keys, -1); + _next = 0; + _drained = 0; + } + } + + /// 取出上次取出之后追加的所有行(只用于两侧各自记录完整行的 union 流:每一行只交出一次)。 + public RawStream Drain(bool includeKey) + { + lock (_gate) + { + RawStream rows = Cut(_drained, _next, includeKey); + _drained = _next; + return rows; + } + } + + /// 追加一行,键自动递增(从 0 开始,与 seq 一致)。 + public long Append() + { + lock (_gate) + { + long key = _next++; + Claim(key); + return key; + } + } + + public void Set(long key, int column, long value) + { + lock (_gate) + { + int slot = Claim(key); + _values[column][slot] = value; + if (_valid[column] is { } valid) + { + valid[slot] = true; + } + } + } + + /// 一次写入这一侧的所有列(顺序同 )。 + public void SetRow(long key, ReadOnlySpan values) + { + lock (_gate) + { + int slot = Claim(key); + for (int i = 0; i < _columns.Count; i++) + { + _values[i][slot] = values[i]; + if (_valid[i] is { } valid) + { + valid[slot] = true; + } + } + } + } + + public bool Contains(long key) + { + lock (_gate) + { + return key >= 0 && _keys[(int)(key % _capacity)] == key; + } + } + + /// 读一个值;行不存在或该列没有设置(可空列)返回 false。 + public bool TryGet(long key, int column, out long value) + { + lock (_gate) + { + int slot = (int)(key % _capacity); + value = 0; + if (key < 0 || _keys[slot] != key || (_valid[column] is { } valid && !valid[slot])) + { + return false; + } + + value = _values[column][slot]; + return true; + } + } + + public bool IsSet(long key, int column) => TryGet(key, column, out _); + + /// + /// 取出键在 [, ) 内、仍然存在的行(按键升序),不删除。 + /// 列按声明顺序排列; 时带上连接键那一列。 + /// + public RawStream Cut(long fromKey, long toKey, bool includeKey) + { + lock (_gate) + { + var slots = new List(); + var keys = new List(); + for (long key = Math.Max(0, fromKey); key < toKey; key++) + { + int slot = (int)(key % _capacity); + if (_keys[slot] == key) + { + slots.Add(slot); + keys.Add(key); + } + } + + var columns = new List(); + foreach (ColumnSpec spec in _spec.Columns) + { + if (includeKey && _keyColumn is not null && spec.Name == _keyColumn.Name) + { + columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. keys], null)); + continue; + } + + int index = -1; + for (int i = 0; i < _columns.Count; i++) + { + if (_columns[i].Name == spec.Name) + { + index = i; + } + } + + if (index < 0) + { + continue; + } + + long[] values = new long[slots.Count]; + bool[]? valid = spec.Nullable ? new bool[slots.Count] : null; + for (int r = 0; r < slots.Count; r++) + { + values[r] = _values[index][slots[r]]; + if (valid is not null) + { + valid[r] = _valid[index]![slots[r]]; + } + } + + columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, values, valid)); + } + + return new RawStream(_spec.Name, slots.Count, columns); + } + } + + private int Claim(long key) + { + int slot = (int)(key % _capacity); + if (_keys[slot] != key) + { + _keys[slot] = key; + for (int i = 0; i < _columns.Count; i++) + { + _values[i][slot] = 0; + if (_valid[i] is { } valid) + { + valid[slot] = false; + } + } + } + + return slot; + } +} diff --git a/backend/shared/Monitor.Measurement/Core/RunningBlock.cs b/backend/shared/Monitor.Measurement/Core/RunningBlock.cs new file mode 100644 index 0000000..c246ba5 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Core/RunningBlock.cs @@ -0,0 +1,47 @@ +namespace Monitor.Measurement; + +/// 一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。 +public sealed class RunningBlock : IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + + private RunningBlock(IBlock block, string signature) + { + Block = block; + Signature = signature; + Completion = Task.CompletedTask; + } + + public IBlock Block { get; } + + /// 参数、端口或流编号变化时调和器据此重启积木。 + public string Signature { get; } + + /// 积木自己结束(单向数据流的发送端)或失败()时完成;被 停止时正常完成。 + public Task Completion { get; private set; } + + public static RunningBlock Start(IBlock block, string signature) + { + var running = new RunningBlock(block, signature); + running.Completion = Task.Run(() => block.RunAsync(running._cts.Token), CancellationToken.None); + return running; + } + + /// 停止积木并等它退出(最多 5 秒)。 + public async Task StopAsync() + { + await _cts.CancelAsync(); + try + { + await Completion.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or BlockFaultException) + { + // 没有及时退出或者已经失败:不再等 + } + + Dispose(); + } + + public void Dispose() => _cts.Dispose(); +} diff --git a/backend/shared/Monitor.Measurement/Execution/BudgetGuard.cs b/backend/shared/Monitor.Measurement/Execution/BudgetGuard.cs new file mode 100644 index 0000000..f01e5e5 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Execution/BudgetGuard.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Text.Json; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 节点的本地硬上限(写在本机配置里,Backend 无法修改)。 +public sealed class NodeLimits +{ + public double MaxTotalMbps { get; init; } = 1200; + + public long MaxTotalPps { get; init; } = 20_000; + + /// 目标任务允许访问的网段;默认为空,即不开放任何目标任务。 + public IReadOnlyList AllowedTargetCidrs { get; init; } = []; + + public int MaxConcurrentTests { get; init; } = 1; + + /// 任务参数里的 port 只能取这些端口(本机配置的 listen 列表)。 + public IReadOnlyList AllowedUdpPorts { get; init; } = [7443]; + + public IReadOnlyList AllowedTcpPorts { get; init; } = [7444]; +} + +/// 被拒绝的原因,用于 rejected[]:unsupported、invalid_params、budget_exceeded、policy_denied。 +public static class RejectReasons +{ + public const string Unsupported = "unsupported"; + public const string InvalidParams = "invalid_params"; + public const string BudgetExceeded = "budget_exceeded"; + public const string PolicyDenied = "policy_denied"; +} + +public sealed record TaskDecision(bool Accepted, string? Reason, ParamValues? Resolved, KindSpec? Kind) +{ + public static TaskDecision Reject(string reason) => new(false, reason, null, null); +} + +/// +/// 本地硬上限检查:Backend 下发的任何参数都要先经过 校验,再经过这里的本地上限检查,两道都不能省略。 +/// 持续任务按顺序累计码率和包速率,超出总预算的任务被拒绝(并给出原因),其余照常运行。 +/// +public sealed class BudgetGuard +{ + private readonly NodeLimits _limits; + private readonly IReadOnlyCollection _supportedKinds; + private readonly List<(IPAddress Network, int Prefix)> _cidrs = []; + + public BudgetGuard(NodeLimits limits, IReadOnlyCollection supportedKinds) + { + _limits = limits; + _supportedKinds = supportedKinds; + foreach (string cidr in limits.AllowedTargetCidrs) + { + if (TryParseCidr(cidr, out IPAddress? network, out int prefix)) + { + _cidrs.Add((network, prefix)); + } + } + } + + /// 检查一个任务; 和 是已经接受的任务占用的预算,接受之后会累加。 + public TaskDecision Check(TaskSpec task, ref double usedMbps, ref long usedPps) + { + KindSpec? kind = Kinds.Find(task.Kind); + if (kind is null || !_supportedKinds.Contains(task.Kind)) + { + return TaskDecision.Reject(RejectReasons.Unsupported); + } + + ParamResolution resolved = ParamResolver.Resolve(kind.Params, task.Params); + if (!resolved.IsValid) + { + return TaskDecision.Reject(RejectReasons.InvalidParams); + } + + if (task.Scope == "target") + { + if (!TargetAllowed(task.Target)) + { + return TaskDecision.Reject(RejectReasons.PolicyDenied); + } + } + else if (!PortAllowed(kind, resolved.Values!)) + { + return TaskDecision.Reject(RejectReasons.PolicyDenied); + } + + double mbps = kind.BudgetBitsPerSecond(resolved.Values!) / 1_000_000.0; + long pps = EstimatePps(kind.Id, resolved.Values!); + if (usedMbps + mbps > _limits.MaxTotalMbps || usedPps + pps > _limits.MaxTotalPps) + { + return TaskDecision.Reject(RejectReasons.BudgetExceeded); + } + + usedMbps += mbps; + usedPps += pps; + return new TaskDecision(true, null, resolved.Values, kind); + } + + /// 按需测试的一个阶段是否在本地上限之内(单独运行,不和持续任务累计)。 + public TaskDecision CheckPhase(string kindId, IReadOnlyDictionary parameters) + { + double mbps = 0; + long pps = 0; + return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary(parameters) }, ref mbps, ref pps); + } + + public bool TargetAllowed(string? target) + { + if (target is null) + { + return false; + } + + string host = target; + if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint)) + { + host = endpoint.Address.ToString(); + } + + if (!IPAddress.TryParse(host, out IPAddress? address)) + { + return false; + } + + foreach ((IPAddress network, int prefix) in _cidrs) + { + if (InCidr(address, network, prefix)) + { + return true; + } + } + + return false; + } + + private bool PortAllowed(KindSpec kind, ParamValues values) + { + if (!kind.Params.Any(p => p.Name == "port")) + { + return true; + } + + int port = (int)values.GetInt("port"); + return kind.Blocks.Transport == "udp" ? _limits.AllowedUdpPorts.Contains(port) : _limits.AllowedTcpPorts.Contains(port); + } + + private static long EstimatePps(string kindId, ParamValues values) => kindId switch + { + "udp_echo" or "tcp_echo" => values.GetInt("rate_hz") * 2, + "icmp_echo" => values.GetInt("rate_hz") * 2, + "udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))), + "connect" => values.GetInt("per_minute") * 6 / 60, + _ => 1, + }; + + private static bool TryParseCidr(string text, out IPAddress network, out int prefix) + { + network = IPAddress.None; + prefix = 0; + int slash = text.IndexOf('/', StringComparison.Ordinal); + if (slash < 0 || !IPAddress.TryParse(text.AsSpan(0, slash), out IPAddress? parsed) || !int.TryParse(text.AsSpan(slash + 1), out prefix)) + { + return false; + } + + network = parsed; + return prefix >= 0 && prefix <= parsed.GetAddressBytes().Length * 8; + } + + private static bool InCidr(IPAddress address, IPAddress network, int prefix) + { + byte[] a = address.GetAddressBytes(); + byte[] n = network.GetAddressBytes(); + if (a.Length != n.Length) + { + return false; + } + + int bits = prefix; + for (int i = 0; i < a.Length && bits > 0; i++, bits -= 8) + { + int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF; + if ((a[i] & mask) != (n[i] & mask)) + { + return false; + } + } + + return true; + } +} diff --git a/backend/shared/Monitor.Measurement/Merging/ChunkFinalizer.cs b/backend/shared/Monitor.Measurement/Merging/ChunkFinalizer.cs new file mode 100644 index 0000000..c91de05 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Merging/ChunkFinalizer.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。 +public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs); + +/// +/// 给合并好的流盖章:生成完整的块头(任务/测试、参数快照、端口、时钟锚点、起止时间)。 +/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受 Probe 交付延迟的影响。 +/// +public static class ChunkFinalizer +{ + public const int MaxDurationMs = 600_000; + + public static RawChunk Finalize( + KindSpec spec, + RawChunk fragment, + IReadOnlyList streams, + NodeId agent, + ClockAnchor anchor, + IReadOnlyDictionary resolvedParams, + int port, + ChunkId id) + { + RawChunkHeader source = fragment.Header; + long offsetUs = source.Clock.ProbeOffsetUs; + long min = long.MaxValue; + long max = long.MinValue; + foreach (RawStream stream in streams) + { + if (stream.Name == "tcp_info") + { + continue; // 这个流的时钟取决于 side 列,不参与起止时间的计算 + } + + foreach (RawColumn column in stream.Columns) + { + if (column.Role == ColumnRole.None) + { + continue; + } + + for (int i = 0; i < stream.Count; i++) + { + if (!column.IsValid(i)) + { + continue; + } + + long utc = ToUtcMs(column.Values[i], column.Role, anchor, offsetUs); + min = Math.Min(min, utc); + max = Math.Max(max, utc); + } + } + } + + long start = min == long.MaxValue ? anchor.UtcMs - source.DurationMs : min; + int duration = (int)Math.Clamp(Math.Max(source.DurationMs, min == long.MaxValue ? 0 : max - start), 1, MaxDurationMs); + + var header = new RawChunkHeader + { + ChunkId = id, + Kind = spec.Id, + TaskId = source.TaskId, + TestId = source.TestId, + Phase = source.Phase, + AgentId = agent, + ProbeId = source.ProbeId, + Target = null, + Params = new Dictionary(resolvedParams), + Port = port, + Direction = source.Direction, + StartUtcMs = start, + DurationMs = duration, + Partial = false, + Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = offsetUs }, + }; + return new RawChunk(header, streams); + } + + public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long probeOffsetUs) + { + long agentUs = role == ColumnRole.TsProbe ? value + probeOffsetUs : value; + long delta = agentUs - anchor.AgentMonoUs; + return anchor.UtcMs + (delta >= 0 ? delta / 1000 : -((-delta + 999) / 1000)); + } +} diff --git a/backend/shared/Monitor.Measurement/Merging/FragmentMerger.cs b/backend/shared/Monitor.Measurement/Merging/FragmentMerger.cs new file mode 100644 index 0000000..e029204 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Merging/FragmentMerger.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// Agent 一侧为合并保留的按键记录(由正在运行的积木提供)。 +public interface IRecordSource +{ + RowStore? Records(uint streamId, string streamName); +} + +/// +/// 把 Probe 一侧的列块(一个只带 Probe 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 声明驱动: +/// +/// 声明了连接键、两侧都有列:按键连接。主导方是 Probe(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); +/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失)。 +/// 没有连接键,或者只有主导方一侧有列:原样透传,只按声明的列顺序重排。 +/// +/// 合并之后的流按声明的列顺序排列,所以 Backend 的分析层不需要知道这些列是谁记录的;新增一种记录流不需要写合并代码。 +/// +public static class FragmentMerger +{ + public static Result> Merge(KindSpec spec, RawChunk fragment, uint streamId, IRecordSource records) + { + var streams = new List(); + foreach (RawStream stream in fragment.Streams) + { + StreamSpec? streamSpec = spec.FindStream(stream.Name); + if (streamSpec is null) + { + return Result>.Fail(MonitorErrors.ValidationFailed, $"stream {stream.Name} is not declared by {spec.Id}"); + } + + Result merged = Join(streamSpec, stream, fragment.Header, records.Records(streamId, stream.Name)); + if (!merged.TryGet(out RawStream? value, out FleetError? error)) + { + return Result>.Fail(error); + } + + streams.Add(value); + } + + // 两侧各自记录完整行的流:Probe 的列块里没有它(例如 Probe 在不支持内核采样的平台上)时,Agent 自己的行也要交出去 + foreach (StreamSpec union in spec.Streams.Where(s => s.Union && fragment.Find(s.Name) is null)) + { + RawStream? own = records.Records(streamId, union.Name)?.Drain(includeKey: false); + if (own is { Count: > 0 }) + { + streams.Add(Conform(union, own)); + } + } + + return Result>.Ok(streams); + } + + private static Result Join(StreamSpec spec, RawStream probe, RawChunkHeader header, RowStore? agent) + { + if (spec.Union) + { + return Result.Ok(Concatenate(spec, probe, agent)); + } + + IReadOnlyList probeColumns = spec.ColumnsOf(Side.Probe); + IReadOnlyList agentColumns = spec.ColumnsOf(Side.Agent); + if (spec.Key is null || (spec.Driver == Side.Probe && agentColumns.Count == 0)) + { + return Result.Ok(Conform(spec, probe)); + } + + RawColumn? key = probe.Find(spec.Key); + if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null)) + { + return Result.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}"); + } + + return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent); + } + + private static Result JoinOnProbeRows(StreamSpec spec, RawStream probe, RawColumn key, RowStore? agent) + { + int n = probe.Count; + var columns = new List(spec.Columns.Count); + foreach (ColumnSpec column in spec.Columns) + { + long[] values = new long[n]; + bool[] valid = new bool[n]; + if (column.Name == spec.Key) + { + key.Values.AsSpan(0, n).CopyTo(values); + Array.Fill(valid, true); + } + else if (spec.OwnerOf(column) == Side.Probe) + { + RawColumn source = probe.Find(column.Name)!; + for (int i = 0; i < n; i++) + { + values[i] = source.Values[i]; + valid[i] = source.IsValid(i); + } + } + else if (agent is not null) + { + int index = agent.ColumnIndex(column.Name); + for (int i = 0; i < n; i++) + { + valid[i] = agent.TryGet(key.Values[i], index, out values[i]); + } + } + + columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null)); + } + + return Result.Ok(new RawStream(spec.Name, n, columns)); + } + + private static Result JoinOnAgentRows(StreamSpec spec, RawStream probe, RawColumn key, RawChunkHeader header, RowStore? agent) + { + if (!TryGetRange(header, out uint from, out uint to)) + { + return Result.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range"); + } + + // 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失 + RawStream rows = agent?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []); + var arrived = new Dictionary(); + for (int i = 0; i < probe.Count; i++) + { + arrived.TryAdd(key.Values[i], i); // 重复的包只算第一次 + } + + int n = rows.Count; + var columns = new List(spec.Columns.Count); + foreach (ColumnSpec column in spec.Columns) + { + long[] values = new long[n]; + bool[] valid = new bool[n]; + RawColumn? own = rows.Find(column.Name); + if (own is not null) + { + own.Values.AsSpan(0, n).CopyTo(values); + for (int i = 0; i < n; i++) + { + valid[i] = own.IsValid(i); + } + } + else if (probe.Find(column.Name) is { } source) + { + RawColumn keys = rows.Find(spec.Key!)!; + for (int i = 0; i < n; i++) + { + if (arrived.TryGetValue(keys.Values[i], out int row) && source.IsValid(row)) + { + values[i] = source.Values[row]; + valid[i] = true; + } + } + } + + columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null)); + } + + return Result.Ok(new RawStream(spec.Name, n, columns)); + } + + /// + /// 两侧各自记录完整行的流(例如各自套接字的内核采样):Probe 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。 + /// 每一行里有一列标明它属于哪一侧,所以不需要连接。 + /// + private static RawStream Concatenate(StreamSpec spec, RawStream probe, RowStore? agent) + { + RawStream own = Conform(spec, probe); + RawStream? extra = agent?.Drain(includeKey: false); + if (extra is null || extra.Count == 0) + { + return own; + } + + int n = own.Count + extra.Count; + var columns = new List(spec.Columns.Count); + foreach (ColumnSpec column in spec.Columns) + { + RawColumn a = own.Find(column.Name)!; + RawColumn b = extra.Find(column.Name)!; + long[] values = new long[n]; + bool[] valid = new bool[n]; + for (int i = 0; i < own.Count; i++) + { + values[i] = a.Values[i]; + valid[i] = a.IsValid(i); + } + + for (int i = 0; i < extra.Count; i++) + { + values[own.Count + i] = b.Values[i]; + valid[own.Count + i] = b.IsValid(i); + } + + columns.Add(new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, values, column.Nullable ? valid : null)); + } + + return new RawStream(spec.Name, n, columns); + } + + /// 按声明的列顺序重排;Probe 一侧已经是完整的行时不改变内容。 + private static RawStream Conform(StreamSpec spec, RawStream stream) + { + var columns = new List(spec.Columns.Count); + foreach (ColumnSpec column in spec.Columns) + { + RawColumn? existing = stream.Find(column.Name); + columns.Add(existing ?? new RawColumn(column.Name, column.Type, column.Enc, column.Nullable, column.Role, new long[stream.Count], column.Nullable ? new bool[stream.Count] : null)); + } + + return new RawStream(stream.Name, stream.Count, columns); + } + + public static bool TryGetRange(RawChunkHeader header, out uint from, out uint to) + { + from = 0; + to = 0; + return header.Params.TryGetValue(FragmentKeys.SeqFrom, out JsonElement f) && f.TryGetUInt32(out from) + && header.Params.TryGetValue(FragmentKeys.SeqTo, out JsonElement t) && t.TryGetUInt32(out to) && to >= from; + } +} diff --git a/backend/shared/Monitor.Measurement/Monitor.Measurement.csproj b/backend/shared/Monitor.Measurement/Monitor.Measurement.csproj new file mode 100644 index 0000000..8853475 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Monitor.Measurement.csproj @@ -0,0 +1,10 @@ + + + true + + + + + + + diff --git a/backend/shared/Monitor.Measurement/Net/Tcp.cs b/backend/shared/Monitor.Measurement/Net/Tcp.cs new file mode 100644 index 0000000..f0e6fe4 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Net/Tcp.cs @@ -0,0 +1,337 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 拨号方的一条数据面 TCP 连接(已经完成 TLS 和首帧认证)。 +public sealed class TcpLink : IDisposable +{ + private readonly TcpClient _client; + + private TcpLink(TcpClient client, Stream stream, long tcpUs, long? tlsUs) + { + _client = client; + Socket = client.Client; + Stream = stream; + TcpUs = tcpUs; + TlsUs = tlsUs; + } + + public Stream Stream { get; } + + /// 底层套接字(内核采样用)。 + public Socket Socket { get; } + + public long TcpUs { get; } + + public long? TlsUs { get; } + + /// + /// 连接 → (TLS)→ 认证首帧。 为 false 时只测量建立连接的耗时(connect)。 + /// 对端证书按 固定;没有指纹(目标任务,目标是任意地址)就只测握手耗时、不校验证书。 + /// 失败时抛出 、、 或超时的 。 + /// + public static async Task OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct) + { + var client = new TcpClient { NoDelay = true }; + try + { + long start = env.Clock.NowUs(); + await client.ConnectAsync(env.PeerAddress!, port, ct); + long tcpUs = env.Clock.NowUs() - start; + Stream stream = client.GetStream(); + long? tlsUs = null; + if (tls) + { + long tlsStart = env.Clock.NowUs(); + byte[]? pin = env.PeerSpkiSha256; +#pragma warning disable CA5359 // 没有指纹时只测量握手耗时(目标任务的目标是任意地址) + var ssl = new SslStream(stream, false, (_, certificate, _, _) => pin is null || (certificate is not null && Pinned(certificate, pin))); +#pragma warning restore CA5359 + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = env.PeerAddress!.ToString() }, ct); + stream = ssl; + tlsUs = env.Clock.NowUs() - tlsStart; + } + + if (sendAuth) + { + DataSession session = env.Session() ?? throw new InvalidOperationException("no data session"); + byte[] auth = new byte[TcpWire.AuthBytes]; + Span nonce = stackalloc byte[TcpWire.NonceBytes]; + RandomNumberGenerator.Fill(nonce); + TcpWire.WriteAuth(auth, session.SessionId, streamId, nonce, session.Key); + await stream.WriteAsync(auth, ct); + } + + return new TcpLink(client, stream, tcpUs, tlsUs); + } + catch + { + client.Dispose(); + throw; + } + } + + public void Dispose() + { + Stream.Dispose(); + _client.Dispose(); + } + + private static bool Pinned(X509Certificate certificate, byte[] expected) + { + X509Certificate2 full = certificate as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(certificate.GetRawCertData()); + return CryptographicOperations.FixedTimeEquals(SHA256.HashData(full.PublicKey.ExportSubjectPublicKeyInfo()), expected); + } +} + +/// 监听方收到的一条已经认证的数据面 TCP 连接。 +public sealed class TcpConnection +{ + public TcpConnection(Stream stream, Socket socket, DataSession session, uint streamId) + { + Stream = stream; + Socket = socket; + Session = session; + StreamId = streamId; + } + + public Stream Stream { get; } + + /// 底层套接字(内核采样用)。 + public Socket Socket { get; } + + public DataSession Session { get; } + + public uint StreamId { get; } +} + +public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct); + +/// +/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。 +/// 随后是认证首帧("MN" | ver | session_id | stream_id | nonce | mac,随机数只能用一次),通过之后交给按 stream_id 登记的处理函数。 +/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。 +/// +public sealed class TcpAcceptor +{ + private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(5); + + private readonly IPAddress _bind; + private readonly IReadOnlyList _ports; + private readonly X509Certificate2 _certificate; + private readonly IDataSessions _sessions; + private readonly DataPlaneCounters _counters; + private readonly ConcurrentDictionary _handlers = new(); + private readonly List _listeners = []; + + public TcpAcceptor(IPAddress bind, IReadOnlyList ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters) + { + _bind = bind; + _ports = ports; + _certificate = certificate; + _sessions = sessions; + _counters = counters; + } + + public IReadOnlyList BoundPorts { get; private set; } = []; + + public void Register(uint streamId, TcpResponder handler) => _handlers[streamId] = handler; + + public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _); + + /// 开始监听(端口被占用等错误在这里抛出)。 + public void Bind() + { + var bound = new List(); + foreach (int port in _ports) + { + var listener = new TcpListener(_bind, port); + listener.Start(128); + _listeners.Add(listener); + bound.Add(((IPEndPoint)listener.LocalEndpoint).Port); + } + + BoundPorts = bound; + } + + public async Task RunAsync(CancellationToken ct) + { + try + { + await Task.WhenAll(_listeners.Select(l => AcceptLoopAsync(l, ct))); + } + finally + { + foreach (TcpListener listener in _listeners) + { + listener.Stop(); + } + } + } + + private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync(ct); + } + catch (OperationCanceledException) + { + return; + } + catch (SocketException) + { + continue; + } + + _ = Task.Run(() => HandleAsync(client, ct), CancellationToken.None); + } + } + + private async Task HandleAsync(TcpClient client, CancellationToken ct) + { + using (client) + { + client.NoDelay = true; + try + { + Stream stream = client.GetStream(); + using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + authCts.CancelAfter(s_authTimeout); + + byte[] first = new byte[1]; + if (await stream.ReadAsync(first, authCts.Token) != 1) + { + return; + } + + stream = new PrefixedStream(stream, first[0]); + if (first[0] == 0x16) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = _certificate }, authCts.Token); + stream = ssl; + } + + byte[] auth = new byte[TcpWire.AuthBytes]; + await stream.ReadExactlyAsync(auth, authCts.Token); + if (!TcpWire.TryParseAuth(auth, out long sessionId, out uint streamId, out ReadOnlySpan nonce) + || _sessions.Find(sessionId) is not { } session + || !_handlers.TryGetValue(streamId, out TcpResponder? handler) + || !TcpWire.VerifyAuth(auth, session.Key) + || !session.UseNonce(nonce)) + { + _counters.DropInvalid(); + return; + } + + await handler(new TcpConnection(stream, client.Client, session, streamId), ct); + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException or AuthenticationException or InvalidDataException or EndOfStreamException) + { + // 连接被对端关闭、超时或者格式错误:直接结束这个连接 + } + } + } + + /// 把已经读出的第一个字节放回流的开头(用于探测是 TLS 还是明文)。 + private sealed class PrefixedStream : Stream + { + private readonly Stream _inner; + private byte? _prefix; + + public PrefixedStream(Stream inner, byte prefix) + { + _inner = inner; + _prefix = prefix; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + if (buffer.IsEmpty) + { + return 0; + } + + if (_prefix is { } prefix) + { + buffer[0] = prefix; + _prefix = null; + return 1; + } + + return _inner.Read(buffer); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + return ValueTask.FromResult(0); + } + + if (_prefix is { } prefix) + { + buffer.Span[0] = prefix; + _prefix = null; + return ValueTask.FromResult(1); + } + + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) => _inner.Write(buffer); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => _inner.WriteAsync(buffer, cancellationToken); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inner.WriteAsync(buffer, offset, count, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/backend/shared/Monitor.Measurement/Net/TcpInfo.cs b/backend/shared/Monitor.Measurement/Net/TcpInfo.cs new file mode 100644 index 0000000..d9a60d7 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Net/TcpInfo.cs @@ -0,0 +1,118 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 内核对一条 TCP 连接的采样(Linux TCP_INFO)。 +public readonly record struct TcpInfoSample(long SrttUs, long RttVarUs, long Cwnd, long TotalRetrans, long SegsOut, long BytesAcked); + +/// +/// 读 Linux 的 TCP_INFO:通过 ,不需要 P/Invoke(NativeAOT、glibc 和 musl 都一样)。 +/// 缓冲区先清零,并且只读内核实际返回的长度之内的字段:老内核返回的结构更短,缺的字段是 0,而不是垃圾值。 +/// 其他平台返回 false(没有这一类采样,指标在对应窗口里就是没有数据)。 +/// +public static class TcpInfo +{ + private const int SolTcp = 6; + private const int TcpInfoOption = 11; + + // struct tcp_info 的偏移(include/uapi/linux/tcp.h):8 字节的状态位之后是 u32 字段;total_retrans 之后是对齐到 8 字节的 u64 字段 + private const int RttOffset = 68; + private const int RttVarOffset = 72; + private const int SndCwndOffset = 80; + private const int TotalRetransOffset = 100; + private const int BytesAckedOffset = 120; + private const int SegsOutOffset = 136; + + /// 读到 segs_out 为止(内核 ≥ 4.2)。 + public const int Length = SegsOutOffset + 4; + + public static bool Supported => OperatingSystem.IsLinux(); + + public static bool TrySample(Socket socket, out TcpInfoSample sample) + { + sample = default; + if (!Supported) + { + return false; + } + + try + { + Span buffer = stackalloc byte[Length]; + buffer.Clear(); + int returned = socket.GetRawSocketOption(SolTcp, TcpInfoOption, buffer); + if (returned < RttVarOffset + 4) + { + return false; + } + + ReadOnlySpan info = buffer[..Math.Min(returned, Length)]; + sample = new TcpInfoSample( + U32(info, RttOffset), + U32(info, RttVarOffset), + U32(info, SndCwndOffset), + U32(info, TotalRetransOffset), + U32(info, SegsOutOffset), + U64(info, BytesAckedOffset)); + return true; + } + catch (Exception ex) when (ex is SocketException or ObjectDisposedException or PlatformNotSupportedException) + { + return false; + } + } + + private static long U32(ReadOnlySpan info, int offset) => offset + 4 <= info.Length ? BinaryPrimitives.ReadUInt32LittleEndian(info[offset..]) : 0; + + private static long U64(ReadOnlySpan info, int offset) => offset + 8 <= info.Length ? (long)BinaryPrimitives.ReadUInt64LittleEndian(info[offset..]) : 0; +} + +/// +/// 一条连接上周期性的内核采样(tcp_info 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked)。 +/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。 +/// 累计计数器在 Backend 按 side 分别求差。 +/// +public sealed class TcpInfoRecorder +{ + private readonly MonoClock _clock; + private readonly long _side; + + public TcpInfoRecorder(StreamSpec spec, Side side, MonoClock clock) + { + Rows = new RowStore(spec, side, 4096); + _clock = clock; + _side = side == Side.Probe ? 0 : 1; + } + + public RowStore Rows { get; } + + /// 每隔 采样一次,直到取消或套接字不可用(连接断开)。 + public async Task RunAsync(Socket socket, TimeSpan interval, CancellationToken ct) + { + if (!TcpInfo.Supported) + { + return; + } + + try + { + using var timer = new PeriodicTimer(interval, _clock.Time); + while (await timer.WaitForNextTickAsync(ct)) + { + if (!TcpInfo.TrySample(socket, out TcpInfoSample s)) + { + return; + } + + long key = Rows.Append(); + Rows.SetRow(key, [_clock.NowUs(), _side, s.SrttUs, s.RttVarUs, s.Cwnd, s.TotalRetrans, s.SegsOut, s.BytesAcked]); + } + } + catch (OperationCanceledException) + { + // 连接结束 + } + } +} diff --git a/backend/shared/Monitor.Measurement/Net/UdpMux.cs b/backend/shared/Monitor.Measurement/Net/UdpMux.cs new file mode 100644 index 0000000..dc03456 --- /dev/null +++ b/backend/shared/Monitor.Measurement/Net/UdpMux.cs @@ -0,0 +1,216 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Measurement; + +/// 一个通过校验的 UDP 包,交给按 stream_id 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。 +public readonly ref struct UdpDelivery +{ + private readonly Span _packet; + private readonly Socket _socket; + private readonly SocketAddress _source; + private readonly DataPlaneCounters _counters; + + internal UdpDelivery(UdpHeader header, Span packet, long receiveUs, DataSession session, Socket socket, SocketAddress source, DataPlaneCounters counters) + { + Header = header; + _packet = packet; + ReceiveUs = receiveUs; + Session = session; + _socket = socket; + _source = source; + _counters = counters; + } + + public UdpHeader Header { get; } + + public ReadOnlySpan Packet => _packet; + + /// 包到达的时刻(本节点的单调时钟,微秒):从接收返回的那一刻取,不含校验的耗时。 + public long ReceiveUs { get; } + + public DataSession Session { get; } + + /// + /// 原地回复:同样的长度,只把 type 改成 并重新计算 MAC,发回请求的实际源地址。 + /// 回复与请求等长,所以监听方不能被当成放大器。发送失败(源地址已经不可达)不抛出。 + /// + public void ReplyAs(UdpPacketType type) + { + _packet[3] = (byte)type; + UdpPacket.Seal(_packet, Session.Key); + try + { + _socket.SendTo(_packet, SocketFlags.None, _source); + _counters.PacketOut(); + } + catch (SocketException) + { + // 回复丢失由对端的缺失体现 + } + } +} + +public delegate void UdpHandler(in UdpDelivery delivery); + +/// +/// UDP 数据面:一个或多个套接字,按 stream_id 把包分发给积木。校验顺序:长度、magic、version、type → +/// 流和会话都存在 → MAC → (监听方)(重放窗口、限速)。任何一步失败都静默丢弃并计数,不回复。 +/// 接收循环只做校验和分发,不分配对象。两端共用这一份代码:Agent 监听多个端口并带准入策略,Probe 每个 Agent 一个临时端口。 +/// +public sealed class UdpMux : IDisposable +{ + private const int WindowsUdpConnReset = -1744830452; // SIO_UDP_CONNRESET + + private readonly IDataSessions _sessions; + private readonly IAdmission? _admission; + private readonly MonoClock _clock; + private readonly DataPlaneCounters _counters; + private readonly INodeLog _log; + private readonly IPAddress _bind; + private readonly IReadOnlyList _ports; + private readonly List _sockets = []; + private readonly ConcurrentDictionary _handlers = new(); + + /// 要监听的端口(0 表示由系统分配);空表示一个临时端口。 + public UdpMux(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList ports) + { + _sessions = sessions; + _admission = admission; + _clock = clock; + _counters = counters; + _log = log; + _bind = bind; + _ports = ports.Count == 0 ? [0] : ports; + } + + public IReadOnlyList BoundPorts { get; private set; } = []; + + public DataPlaneCounters Counters => _counters; + + /// 绑定套接字(端口被占用等错误在这里抛出)。 + public void Bind() + { + var bound = new List(); + foreach (int port in _ports) + { + var socket = new Socket(_bind.AddressFamily, SocketType.Dgram, ProtocolType.Udp) { ReceiveBufferSize = 4 << 20, SendBufferSize = 4 << 20 }; + if (_bind.AddressFamily == AddressFamily.InterNetworkV6) + { + socket.DualMode = true; + } + + if (OperatingSystem.IsWindows()) + { + // Windows 上对一个已关闭的端口发送之后,下一次接收会抛 ConnectionReset:关掉这个行为 + socket.IOControl(WindowsUdpConnReset, [0], null); + } + + socket.Bind(new IPEndPoint(_bind, port)); + bound.Add(((IPEndPoint)socket.LocalEndPoint!).Port); + _sockets.Add(socket); + } + + BoundPorts = bound; + } + + public void Register(uint streamId, UdpHandler handler) => _handlers[streamId] = handler; + + public void Unregister(uint streamId) => _handlers.TryRemove(streamId, out _); + + /// 从第一个套接字发出一个已经封好的包。发送失败(没有路由、缓冲区满)不抛出:丢包本身就是测量结果。 + public void Send(EndPoint to, ReadOnlySpan packet) + { + try + { + _sockets[0].SendTo(packet, SocketFlags.None, to); + _counters.PacketOut(); + } + catch (SocketException) + { + // 记为丢失 + } + } + + public async Task RunAsync(CancellationToken ct) + { + try + { + await Task.WhenAll(_sockets.Select(s => ReceiveLoopAsync(s, ct))); + } + catch (ObjectDisposedException) + { + // 套接字已释放:停止 + } + } + + public void Dispose() + { + foreach (Socket socket in _sockets) + { + socket.Dispose(); + } + } + + private async Task ReceiveLoopAsync(Socket socket, CancellationToken ct) + { + byte[] buffer = new byte[UdpPacket.MaxBytes + 64]; + var source = new SocketAddress(socket.AddressFamily); + while (!ct.IsCancellationRequested) + { + int length; + try + { + length = await socket.ReceiveFromAsync(buffer.AsMemory(), SocketFlags.None, source, ct); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException ex) + { + // 个别错误(例如对端的 ICMP 不可达)不能让接收循环退出 + _log.Warn("udp receive failed", ("err", ex.SocketErrorCode)); + continue; + } + + Dispatch(socket, buffer, length, source, _clock.NowUs()); + } + } + + private void Dispatch(Socket socket, byte[] buffer, int length, SocketAddress source, long receiveUs) + { + _counters.PacketIn(); + Span packet = buffer.AsSpan(0, length); + if (!UdpPacket.TryParseHeader(packet, out UdpHeader header) + || !_handlers.TryGetValue(header.StreamId, out UdpHandler? handler) + || _sessions.Find(header.SessionId) is not { } session + || !UdpPacket.VerifyMac(packet, session.Key)) + { + _counters.DropInvalid(); + return; + } + + if (_admission is not null) + { + switch (_admission.AdmitPacket(session, header, source, receiveUs)) + { + case Admission.Invalid: + _counters.DropInvalid(); + return; + case Admission.RateLimited: + _counters.DropRateLimited(); + return; + } + } + + handler(new UdpDelivery(header, packet, receiveUs, session, socket, source, _counters)); + } +} diff --git a/backend/shared/Monitor.Protocol/Catalog/KindSpec.cs b/backend/shared/Monitor.Protocol/Catalog/KindSpec.cs new file mode 100644 index 0000000..7345228 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Catalog/KindSpec.cs @@ -0,0 +1,128 @@ +namespace Monitor.Protocol; + +[Flags] +public enum Scope +{ + Line = 1, + Target = 2, +} + +public enum ColumnType +{ + U8, + U16, + U32, + U64, + I32, + I64, +} + +public enum ColumnEnc +{ + Plain, + Delta, +} + +/// 时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。 +public enum ColumnRole +{ + None, + TsProbe, + TsAgent, + TsLocal, +} + +public sealed record ColumnSpec(string Name, ColumnType Type, ColumnEnc Enc = ColumnEnc.Plain, bool Nullable = false, ColumnRole Role = ColumnRole.None); + +/// 数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。 +public enum Side +{ + Probe, + Agent, +} + +/// +/// 一个记录流(一张表)。 是行的主导方:行由它发出的包或事件决定,另一侧的列按 (例如 seq)补充, +/// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 的流只有主导方一侧的列,原样透传。 +/// 每一列属于哪一侧由它的时钟决定(TsProbe 属于 Probe,TsAgent 属于 Agent),其他列属于主导方。 +/// 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。 +/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。 +/// +public sealed record StreamSpec(string Name, IReadOnlyList Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false) +{ + public Side OwnerOf(ColumnSpec column) => column.Role switch + { + ColumnRole.TsProbe => Side.Probe, + ColumnRole.TsAgent => Side.Agent, + _ => Driver, + }; + + /// + /// 该侧记录的列(不含连接键:键两侧都有,由行的编号表示)。 流两侧各自记录完整的行(例如各自套接字的内核采样), + /// 所以每一侧拥有所有列。 + /// + public IReadOnlyList ColumnsOf(Side side) => + [.. Columns.Where(c => c.Name != Key && (Union || OwnerOf(c) == side))]; +} + +/// 积木组合(只描述,不含代码;节点按这个描述组装执行图)。 +public sealed record BlockSet(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList Samplers); + +/// +/// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。 +/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。 +/// +public sealed record KindSpec( + string Id, + string Title, + string Description, + Scope Scopes, + BlockSet Blocks, + IReadOnlyList Params, + IReadOnlyList Streams, + Func BudgetBitsPerSecond) +{ + public StreamSpec? FindStream(string name) + { + foreach (StreamSpec stream in Streams) + { + if (stream.Name == name) + { + return stream; + } + } + + return null; + } +} + +public static class Col +{ + public static ColumnSpec U8(string name, bool nullable = false) => new(name, ColumnType.U8, ColumnEnc.Plain, nullable); + + public static ColumnSpec U16(string name, bool nullable = false) => new(name, ColumnType.U16, ColumnEnc.Plain, nullable); + + public static ColumnSpec U32(string name, bool nullable = false) => new(name, ColumnType.U32, ColumnEnc.Plain, nullable); + + public static ColumnSpec U64(string name, ColumnEnc enc = ColumnEnc.Plain) => new(name, ColumnType.U64, enc); + + /// 单调递增的序号:差分编码。 + public static ColumnSpec Seq(string name) => new(name, ColumnType.U32, ColumnEnc.Delta); + + public static ColumnSpec TsProbe(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsProbe); + + public static ColumnSpec TsAgent(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsAgent); + + public static ColumnSpec TsLocal(string name) => new(name, ColumnType.I64, ColumnEnc.Delta, false, ColumnRole.TsLocal); +} + +/// 带宽预算的估算函数(bit/s)。 +public static class Bandwidth +{ + private const int UdpOverheadBytes = 28; + private const int TcpOverheadBytes = 52; + + public static long UdpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + UdpOverheadBytes) * 8) * 2; + + public static long TcpEcho(long rateHz, long sizeBytes) => rateHz * ((sizeBytes + TcpOverheadBytes) * 8) * 2; +} diff --git a/backend/shared/Monitor.Protocol/Catalog/Kinds/Kinds.cs b/backend/shared/Monitor.Protocol/Catalog/Kinds/Kinds.cs new file mode 100644 index 0000000..d311e91 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Catalog/Kinds/Kinds.cs @@ -0,0 +1,161 @@ +namespace Monitor.Protocol; + +/// +/// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 ; +/// 参数一律用 声明默认值和上下限。 +/// +public static class Kinds +{ + private static readonly string[] s_directions = ["fwd", "rev", "bidir"]; + + private static readonly StreamSpec s_echoMsg = new( + "msg", + [ + Col.Seq("seq"), Col.U16("size"), + Col.TsProbe("t1"), Col.TsAgent("t2", nullable: true), Col.TsAgent("t3", nullable: true), Col.TsProbe("t4", nullable: true), + ], + Side.Probe, + "seq"); + + private static readonly StreamSpec s_tcpInfo = new( + "tcp_info", + [ + Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), Col.U32("rttvar_us"), Col.U32("cwnd"), + Col.U32("retrans_total"), Col.U32("segs_out"), Col.U64("bytes_acked"), + ], + Union: true); + + public static readonly KindSpec UdpEcho = new( + "udp_echo", + "UDP echo", + "Fixed-rate UDP echo with four timestamps per packet.", + Scope.Line, + new BlockSet("udp", "fixed", "fixed", "echo", []), + [ + P.Int("rate_hz", 10, 1, 200, "Hz"), + P.Int("size", 64, 44, 1400, "B"), + P.Int("port", 7443, 1, 65535), + ], + [s_echoMsg], + p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size"))); + + public static readonly KindSpec UdpTrain = new( + "udp_train", + "UDP train", + "Fixed-rate or laddered one-way UDP stream (Agent to Probe) for loss and jitter under load.", + Scope.Line, + new BlockSet("udp", "ladder", "fixed", "sink", []), + [ + P.Number("mbps", 10, 0.1, 1000, "Mbit/s"), + P.Int("size", 1250, 200, 1400, "B"), + P.Int("duration_s", 10, 1, 120, "s"), + P.Enum("direction", "rev", s_directions), + P.Int("port", 7443, 1, 65535), + ], + [ + new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsAgent("t_send"), Col.TsProbe("t_recv", nullable: true)], Side.Agent, "seq"), + new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsProbe("start_ts"), Col.TsProbe("end_ts")]), + ], + p => (long)(p.GetNumber("mbps") * 1_000_000)); + + public static readonly KindSpec TcpEcho = new( + "tcp_echo", + "TCP echo", + "Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.", + Scope.Line, + new BlockSet("tcp", "fixed", "uniform", "echo", ["tcp_info"]), + [ + P.Int("rate_hz", 20, 1, 200, "Hz"), + P.Int("min", 32, 16, 1400, "B"), + P.Int("max", 64, 16, 1400, "B"), + P.Int("tcp_info_ms", 100, 10, 1000, "ms"), + P.Int("port", 7444, 1, 65535), + P.Bool("tls", false), + ], + [s_echoMsg, s_tcpInfo], + p => Bandwidth.TcpEcho(p.GetInt("rate_hz"), p.GetInt("max"))); + + public static readonly KindSpec TcpBulk = new( + "tcp_bulk", + "TCP bulk", + "Bulk TCP transfer on several streams with periodic byte counters and tcp_info.", + Scope.Line, + new BlockSet("tcp", "fixed", "fixed", "source", ["tcp_info"]), + [ + P.Int("streams", 4, 1, 16), + P.Int("duration_s", 4, 1, 120, "s"), + P.Enum("direction", "rev", s_directions), + P.Int("sample_ms", 100, 20, 1000, "ms"), + P.Int("port", 7444, 1, 65535), + P.Bool("tls", true), + ], + [ + new StreamSpec("bytes", [Col.TsProbe("ts"), Col.U8("conn"), Col.U64("bytes")]), + s_tcpInfo, + ], + _ => 0); + + public static readonly KindSpec Connect = new( + "connect", + "Connect", + "Repeated TCP and TLS connection establishment with per-phase timings and outcome.", + Scope.Line | Scope.Target, + new BlockSet("tcp", "fixed", "fixed", "connect", []), + [ + P.Int("per_minute", 6, 1, 60), + P.Bool("tls", true), + P.Int("timeout_ms", 5000, 500, 30000, "ms"), + P.Int("port", 7444, 1, 65535), + ], + [ + new StreamSpec( + "conn", + [Col.Seq("attempt"), Col.TsProbe("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]), + ], + p => p.GetInt("per_minute") * 4000 / 60); + + public static readonly KindSpec Hold = new( + "hold", + "Hold", + "A long-lived TLS connection with periodic heartbeats; records resets and lifetime.", + Scope.Line, + new BlockSet("tls", "fixed", "fixed", "hold", []), + [ + P.Int("heartbeat_s", 30, 5, 300, "s"), + P.Int("port", 7444, 1, 65535), + ], + [ + new StreamSpec("hold", [Col.TsProbe("ts"), Col.U8("event"), Col.U32("lifetime_ms")]), + ], + p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8); + + public static readonly KindSpec IcmpEcho = new( + "icmp_echo", + "ICMP echo", + "Probe or Agent side ICMP echo to a target address.", + Scope.Target, + new BlockSet("icmp", "fixed", "fixed", "echo", []), + [ + P.Int("rate_hz", 1, 1, 20, "Hz"), + P.Int("size", 64, 44, 1400, "B"), + ], + [ + new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsLocal("t1"), new ColumnSpec("t4", ColumnType.I64, ColumnEnc.Delta, true, ColumnRole.TsLocal)]), + ], + p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size"))); + + public static readonly IReadOnlyList All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho]; + + public static KindSpec? Find(string id) + { + foreach (KindSpec kind in All) + { + if (kind.Id == id) + { + return kind; + } + } + + return null; + } +} diff --git a/backend/shared/Monitor.Protocol/Catalog/ParamSpec.cs b/backend/shared/Monitor.Protocol/Catalog/ParamSpec.cs new file mode 100644 index 0000000..0f415cd --- /dev/null +++ b/backend/shared/Monitor.Protocol/Catalog/ParamSpec.cs @@ -0,0 +1,223 @@ +using System.Globalization; +using System.Text.Json; + +namespace Monitor.Protocol; + +public enum ParamType +{ + Int, + Number, + Bool, + Enum, +} + +/// +/// 参数定义:类型、默认值、上下限。节点与 Backend 使用同一套校验,禁止写没有上下限的参数。 +/// +public abstract record ParamSpec(string Name, string Description, string? Unit) +{ + public abstract ParamType Type { get; } + + /// 用于目录展示的默认值。 + public abstract JsonElement DefaultValue { get; } + + /// 校验并规范化一个参数值;失败时返回错误说明。 + public abstract bool TryNormalize(JsonElement input, out JsonElement normalized, out string error); + + protected static JsonElement Of(long value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Int64); + + protected static JsonElement Of(double value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Double); + + protected static JsonElement Of(bool value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.Boolean); + + protected static JsonElement Of(string value) => JsonSerializer.SerializeToElement(value, ParamJson.Default.String); +} + +public sealed record IntParam(string Name, long Default, long Min, long Max, string? Unit = null, string Description = "") + : ParamSpec(Name, Description, Unit) +{ + public override ParamType Type => ParamType.Int; + + public override JsonElement DefaultValue => Of(Default); + + public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error) + { + normalized = default; + error = ""; + if (input.ValueKind != JsonValueKind.Number || !input.TryGetInt64(out long value)) + { + error = "must be an integer"; + return false; + } + + if (value < Min || value > Max) + { + error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}"; + return false; + } + + normalized = Of(value); + return true; + } +} + +public sealed record NumberParam(string Name, double Default, double Min, double Max, string? Unit = null, string Description = "") + : ParamSpec(Name, Description, Unit) +{ + public override ParamType Type => ParamType.Number; + + public override JsonElement DefaultValue => Of(Default); + + public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error) + { + normalized = default; + error = ""; + if (input.ValueKind != JsonValueKind.Number || !input.TryGetDouble(out double value) || !double.IsFinite(value)) + { + error = "must be a number"; + return false; + } + + if (value < Min || value > Max) + { + error = $"must be between {Min.ToString(CultureInfo.InvariantCulture)} and {Max.ToString(CultureInfo.InvariantCulture)}"; + return false; + } + + normalized = Of(value); + return true; + } +} + +public sealed record BoolParam(string Name, bool Default, string Description = "") + : ParamSpec(Name, Description, null) +{ + public override ParamType Type => ParamType.Bool; + + public override JsonElement DefaultValue => Of(Default); + + public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error) + { + normalized = default; + error = ""; + if (input.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + error = "must be a boolean"; + return false; + } + + normalized = Of(input.GetBoolean()); + return true; + } +} + +public sealed record EnumParam(string Name, string Default, IReadOnlyList Values, string Description = "") + : ParamSpec(Name, Description, null) +{ + public override ParamType Type => ParamType.Enum; + + public override JsonElement DefaultValue => Of(Default); + + public override bool TryNormalize(JsonElement input, out JsonElement normalized, out string error) + { + normalized = default; + error = ""; + string? value = input.ValueKind == JsonValueKind.String ? input.GetString() : null; + if (value is null || !Values.Contains(value)) + { + error = "must be one of: " + string.Join(", ", Values); + return false; + } + + normalized = Of(value); + return true; + } +} + +/// 参数声明的简写。 +public static class P +{ + public static ParamSpec Int(string name, long @default, long min, long max, string? unit = null, string description = "") => + new IntParam(name, @default, min, max, unit, description); + + public static ParamSpec Number(string name, double @default, double min, double max, string? unit = null, string description = "") => + new NumberParam(name, @default, min, max, unit, description); + + public static ParamSpec Bool(string name, bool @default, string description = "") => new BoolParam(name, @default, description); + + public static ParamSpec Enum(string name, string @default, string[] values, string description = "") => + new EnumParam(name, @default, values, description); +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(long))] +[System.Text.Json.Serialization.JsonSerializable(typeof(double))] +[System.Text.Json.Serialization.JsonSerializable(typeof(bool))] +[System.Text.Json.Serialization.JsonSerializable(typeof(string))] +internal sealed partial class ParamJson : System.Text.Json.Serialization.JsonSerializerContext; + +/// 已经校验并补全了默认值的参数。 +public sealed class ParamValues +{ + private readonly Dictionary _values; + + public ParamValues(Dictionary values) + { + _values = values; + } + + public IReadOnlyDictionary Values => _values; + + public long GetInt(string name) => _values[name].GetInt64(); + + public double GetNumber(string name) => _values[name].GetDouble(); + + public bool GetBool(string name) => _values[name].GetBoolean(); + + public string GetString(string name) => _values[name].GetString()!; +} + +public sealed record ParamResolution(ParamValues? Values, IReadOnlyDictionary Errors) +{ + public bool IsValid => Errors.Count == 0; +} + +public static class ParamResolver +{ + /// 未知参数视为错误;缺失的参数取默认值。 + public static ParamResolution Resolve(IReadOnlyList specs, IReadOnlyDictionary? input) + { + var errors = new Dictionary(); + var values = new Dictionary(specs.Count); + foreach (ParamSpec spec in specs) + { + if (input is not null && input.TryGetValue(spec.Name, out JsonElement raw)) + { + if (spec.TryNormalize(raw, out JsonElement normalized, out string error)) + { + values[spec.Name] = normalized; + } + else + { + errors[spec.Name] = error; + } + } + else + { + values[spec.Name] = spec.DefaultValue; + } + } + + if (input is not null) + { + foreach (string key in input.Keys) + { + if (!values.ContainsKey(key) && !errors.ContainsKey(key)) + { + errors[key] = "is not a parameter of this kind"; + } + } + } + + return new ParamResolution(errors.Count == 0 ? new ParamValues(values) : null, errors); + } +} diff --git a/backend/shared/Monitor.Protocol/Ids/MonitorIds.cs b/backend/shared/Monitor.Protocol/Ids/MonitorIds.cs new file mode 100644 index 0000000..2881993 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Ids/MonitorIds.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; +using Fleet.Core; + +namespace Monitor.Protocol; + +[JsonConverter(typeof(ProbeIdJsonConverter))] +public readonly record struct ProbeId(string Value) +{ + public const string Prefix = "prb"; + + public override string ToString() => Value; +} + +[JsonConverter(typeof(TaskIdJsonConverter))] +public readonly record struct TaskId(string Value) +{ + public const string Prefix = "tsk"; + + public override string ToString() => Value; +} + +[JsonConverter(typeof(TestIdJsonConverter))] +public readonly record struct TestId(string Value) +{ + public const string Prefix = "tst"; + + public override string ToString() => Value; +} + +/// 原始块 ID(UUIDv7,同时是发件箱的 msg_id)。 +[JsonConverter(typeof(ChunkIdJsonConverter))] +public readonly record struct ChunkId(Guid Value) +{ + public static ChunkId New(TimeProvider time) => new(Guid.CreateVersion7(time.GetUtcNow())); + + public override string ToString() => Value.ToString(); +} + +public static class AgentIds +{ + /// Agent 就是 Fleet 节点,ID 前缀为 agt。 + public const string Prefix = "agt"; +} + +public sealed class ProbeIdJsonConverter : StringIdJsonConverter +{ + protected override ProbeId Create(string value) => new(value); + + protected override string GetValue(ProbeId id) => id.Value; +} + +public sealed class TaskIdJsonConverter : StringIdJsonConverter +{ + protected override TaskId Create(string value) => new(value); + + protected override string GetValue(TaskId id) => id.Value; +} + +public sealed class TestIdJsonConverter : StringIdJsonConverter +{ + protected override TestId Create(string value) => new(value); + + protected override string GetValue(TestId id) => id.Value; +} + +public sealed class ChunkIdJsonConverter : StringIdJsonConverter +{ + protected override ChunkId Create(string value) => new(Guid.Parse(value)); + + protected override string GetValue(ChunkId id) => id.Value.ToString(); +} diff --git a/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj b/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj new file mode 100644 index 0000000..12d5252 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Monitor.Protocol.csproj @@ -0,0 +1,9 @@ + + + true + + + + + + diff --git a/backend/shared/Monitor.Protocol/MonitorErrors.cs b/backend/shared/Monitor.Protocol/MonitorErrors.cs new file mode 100644 index 0000000..7de2b95 --- /dev/null +++ b/backend/shared/Monitor.Protocol/MonitorErrors.cs @@ -0,0 +1,45 @@ +using Fleet.Core; + +namespace Monitor.Protocol; + +/// Monitor 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。 +public static class MonitorErrors +{ + public static readonly ErrorCode ValidationFailed = new("validation_failed"); + public static readonly ErrorCode BudgetExceeded = new("budget_exceeded"); + public static readonly ErrorCode Unauthorized = new("unauthorized"); + public static readonly ErrorCode CsrfFailed = new("csrf_failed"); + public static readonly ErrorCode Forbidden = new("forbidden"); + public static readonly ErrorCode SitePrivate = new("site_private"); + public static readonly ErrorCode NotFound = new("not_found"); + public static readonly ErrorCode AgentNotFound = new("agent_not_found"); + public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); + public static readonly ErrorCode ProbeRevoked = new("probe_revoked"); + public static readonly ErrorCode ProbeRequired = new("probe_required"); + public static readonly ErrorCode TaskNotFound = new("task_not_found"); + public static readonly ErrorCode TestNotFound = new("test_not_found"); + public static readonly ErrorCode ChunkNotFound = new("chunk_not_found"); + public static readonly ErrorCode TokenNotFound = new("token_not_found"); + public static readonly ErrorCode UnknownKind = new("unknown_kind"); + public static readonly ErrorCode UnknownPlan = new("unknown_plan"); + public static readonly ErrorCode NameConflict = new("name_conflict"); + public static readonly ErrorCode ProbeOffline = new("probe_offline"); + public static readonly ErrorCode TestBusy = new("test_busy"); + public static readonly ErrorCode AgentNotActive = new("agent_not_active"); + public static readonly ErrorCode AgentOffline = new("agent_offline"); + public static readonly ErrorCode RawExpired = new("raw_expired"); + public static readonly ErrorCode JoinTokenExpired = new("join_token_expired"); + public static readonly ErrorCode PreconditionFailed = new("precondition_failed"); + public static readonly ErrorCode PayloadTooLarge = new("payload_too_large"); + public static readonly ErrorCode RangeTooLarge = new("range_too_large"); + public static readonly ErrorCode InvalidStep = new("invalid_step"); + public static readonly ErrorCode InvalidRange = new("invalid_range"); + public static readonly ErrorCode UnknownMetric = new("unknown_metric"); + public static readonly ErrorCode UnknownPreset = new("unknown_preset"); + public static readonly ErrorCode TooManyMetrics = new("too_many_metrics"); + public static readonly ErrorCode TooManyRequests = new("too_many_requests"); + public static readonly ErrorCode TooManyStreams = new("too_many_streams"); + public static readonly ErrorCode StorageUnavailable = new("storage_unavailable"); + public static readonly ErrorCode RawCorrupt = new("raw_corrupt"); + public static readonly ErrorCode Internal = new("internal_error"); +} diff --git a/backend/shared/Monitor.Protocol/ProtocolLimits.cs b/backend/shared/Monitor.Protocol/ProtocolLimits.cs new file mode 100644 index 0000000..f227b86 --- /dev/null +++ b/backend/shared/Monitor.Protocol/ProtocolLimits.cs @@ -0,0 +1,43 @@ +namespace Monitor.Protocol; + +public static class ProtocolVersion +{ + public const int Container = 1; + public const int Wire = 1; +} + +public static class ProtocolLimits +{ + /// 持续任务每 10 秒切一块。 + public const int ChunkSeconds = 10; + + /// 单块记录数上限。 + public const int MaxRecordsPerChunk = 50_000; + + /// 解压后的块上限(防解压炸弹)。 + public const int MaxDecodedChunkBytes = 64 << 20; + + public const int MaxStreamsPerChunk = 16; + + public const int MaxColumnsPerStream = 64; + + /// 每条线路持续任务的总带宽上限(bit/s)。 + public const long MaxLineBudgetBitsPerSecond = 100_000; +} + +/// 二进制通道号(0–15 属于 Fleet,Monitor 从 16 开始)。 +public static class MonitorChannels +{ + /// Agent → Backend:RawChunk(发件箱)。 + public const byte RawChunk = 16; + + /// Probe → Agent:列块(点对点会话的发件箱)。 + public const byte ColumnBlock = 17; +} + +public static class MonitorCapabilities +{ + public const string Base = "monitor.v1"; + public const string KindsPrefix = "monitor.kinds:"; + public const string BlocksPrefix = "monitor.blocks:"; +} diff --git a/backend/shared/Monitor.Protocol/Raw/Crc32c.cs b/backend/shared/Monitor.Protocol/Raw/Crc32c.cs new file mode 100644 index 0000000..0a6d0f9 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Raw/Crc32c.cs @@ -0,0 +1,35 @@ +namespace Monitor.Protocol; + +/// CRC-32C(Castagnoli),RawChunk 的完整性校验。 +public static class Crc32c +{ + private static readonly uint[] s_table = BuildTable(); + + public static uint Compute(ReadOnlySpan data) + { + uint crc = uint.MaxValue; + foreach (byte b in data) + { + crc = s_table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + + return ~crc; + } + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int k = 0; k < 8; k++) + { + c = (c & 1) != 0 ? (c >> 1) ^ 0x82F63B78u : c >> 1; + } + + table[i] = c; + } + + return table; + } +} diff --git a/backend/shared/Monitor.Protocol/Raw/RawChunk.cs b/backend/shared/Monitor.Protocol/Raw/RawChunk.cs new file mode 100644 index 0000000..d6ae3d3 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Raw/RawChunk.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; + +namespace Monitor.Protocol; + +/// Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。 +public sealed class ChunkClock +{ + [JsonPropertyName("agent_mono_us")] + public long AgentMonoUs { get; set; } + + [JsonPropertyName("utc_ms")] + public long UtcMs { get; set; } + + /// Agent 时钟 − Probe 时钟(微秒)。 + [JsonPropertyName("probe_offset_us")] + public long ProbeOffsetUs { get; set; } + + /// Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。 + [JsonPropertyName("probe_drift_ppb")] + public long ProbeDriftPpb { get; set; } +} + +public sealed class ColumnHeader +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// u8 | u16 | u32 | u64 | i32 | i64 + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + /// plain | delta + [JsonPropertyName("enc")] + public string Enc { get; set; } = "plain"; + + [JsonPropertyName("nullable")] + public bool Nullable { get; set; } + + /// ts_probe | ts_agent | ts_local,其他为空。 + [JsonPropertyName("role")] + public string? Role { get; set; } +} + +public sealed class StreamHeader +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("columns")] + public List Columns { get; set; } = []; +} + +/// 块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。 +public sealed class RawChunkHeader +{ + [JsonPropertyName("chunk_id")] + public ChunkId ChunkId { get; set; } + + [JsonPropertyName("container_version")] + public int ContainerVersion { get; set; } = ProtocolVersion.Container; + + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("task_id")] + public TaskId? TaskId { get; set; } + + [JsonPropertyName("test_id")] + public TestId? TestId { get; set; } + + [JsonPropertyName("phase")] + public int? Phase { get; set; } + + [JsonPropertyName("agent_id")] + public NodeId AgentId { get; set; } + + [JsonPropertyName("probe_id")] + public ProbeId? ProbeId { get; set; } + + [JsonPropertyName("target")] + public string? Target { get; set; } + + /// 参数的完整快照(包含默认值)。 + [JsonPropertyName("params")] + public Dictionary Params { get; set; } = []; + + [JsonPropertyName("port")] + public int Port { get; set; } + + [JsonPropertyName("direction")] + public string? Direction { get; set; } + + [JsonPropertyName("start_utc_ms")] + public long StartUtcMs { get; set; } + + [JsonPropertyName("duration_ms")] + public int DurationMs { get; set; } + + /// Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。 + [JsonPropertyName("partial")] + public bool Partial { get; set; } + + [JsonPropertyName("clock")] + public ChunkClock Clock { get; set; } = new(); + + [JsonPropertyName("streams")] + public List Streams { get; set; } = []; + + /// + /// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。 + /// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。 + /// + [JsonIgnore] + public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? ""); + + public const string LinePrefix = "line:"; +} + +public sealed class RawColumn +{ + public RawColumn(string name, ColumnType type, ColumnEnc enc, bool nullable, ColumnRole role, long[] values, bool[]? valid) + { + Name = name; + Type = type; + Enc = enc; + Nullable = nullable; + Role = role; + Values = values; + Valid = valid; + } + + public string Name { get; } + + public ColumnType Type { get; } + + public ColumnEnc Enc { get; } + + public bool Nullable { get; } + + public ColumnRole Role { get; } + + /// 所有列统一用 long 存放(u64 按位保存)。缺失值对应的位置内容无意义,请先看 。 + public long[] Values { get; } + + public bool[]? Valid { get; } + + public int Count => Values.Length; + + public bool IsValid(int row) => Valid is null || Valid[row]; +} + +public sealed class RawStream +{ + public RawStream(string name, int count, IReadOnlyList columns) + { + Name = name; + Count = count; + Columns = columns; + } + + public string Name { get; } + + public int Count { get; } + + public IReadOnlyList Columns { get; } + + public RawColumn? Find(string name) + { + foreach (RawColumn column in Columns) + { + if (column.Name == name) + { + return column; + } + } + + return null; + } +} + +public sealed class RawChunk +{ + public RawChunk(RawChunkHeader header, IReadOnlyList streams) + { + Header = header; + Streams = streams; + } + + public RawChunkHeader Header { get; } + + public IReadOnlyList Streams { get; } + + public RawStream? Find(string name) + { + foreach (RawStream stream in Streams) + { + if (stream.Name == name) + { + return stream; + } + } + + return null; + } +} diff --git a/backend/shared/Monitor.Protocol/Raw/RawChunkCodec.cs b/backend/shared/Monitor.Protocol/Raw/RawChunkCodec.cs new file mode 100644 index 0000000..00c5298 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Raw/RawChunkCodec.cs @@ -0,0 +1,345 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text.Json; +using Fleet.Core; + +namespace Monitor.Protocol; + +/// +/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。 +/// 布局:"MNRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )。 +/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。 +/// +public static class RawChunkCodec +{ + private const int PrefixBytes = 4 + 1 + 4; + + private static ReadOnlySpan Magic => "MNRC"u8; + + /// 压缩之前的正文:u32 header_len | header JSON | 各列数据。布局变化时黄金字节测试会失败,提醒升级版本。 + public static byte[] EncodeBody(RawChunk chunk) + { + RawChunkHeader header = chunk.Header; + header.ContainerVersion = ProtocolVersion.Container; + header.Streams = []; + foreach (RawStream stream in chunk.Streams) + { + var streamHeader = new StreamHeader { Name = stream.Name, Count = stream.Count }; + foreach (RawColumn column in stream.Columns) + { + streamHeader.Columns.Add(new ColumnHeader + { + Name = column.Name, + Type = TypeName(column.Type), + Enc = column.Enc == ColumnEnc.Delta ? "delta" : "plain", + Nullable = column.Nullable, + Role = RoleName(column.Role), + }); + } + + header.Streams.Add(streamHeader); + } + + byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MonitorJsonContext.Default.RawChunkHeader); + + using var body = new MemoryStream(); + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(length, (uint)headerJson.Length); + body.Write(length); + body.Write(headerJson); + foreach (RawStream stream in chunk.Streams) + { + foreach (RawColumn column in stream.Columns) + { + WriteColumn(body, column); + } + } + + return body.ToArray(); + } + + public static byte[] Encode(RawChunk chunk) + { + byte[] plain = EncodeBody(chunk); + using var compressed = new MemoryStream(); + using (var brotli = new BrotliStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + brotli.Write(plain); + } + + byte[] payload = compressed.ToArray(); + byte[] result = new byte[PrefixBytes + payload.Length]; + Magic.CopyTo(result); + result[4] = ProtocolVersion.Container; + BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload)); + payload.CopyTo(result, PrefixBytes); + return result; + } + + public static Result Decode(ReadOnlySpan data) + { + if (data.Length < PrefixBytes || !data[..4].SequenceEqual(Magic)) + { + return Corrupt("bad magic"); + } + + if (data[4] != ProtocolVersion.Container) + { + return Corrupt($"unsupported container version {data[4]}"); + } + + ReadOnlySpan payload = data[PrefixBytes..]; + if (BinaryPrimitives.ReadUInt32LittleEndian(data[5..]) != Crc32c.Compute(payload)) + { + return Corrupt("crc mismatch"); + } + + byte[]? plain = Inflate(payload); + if (plain is null) + { + return Corrupt("invalid or oversized compressed data"); + } + + if (plain.Length < 4) + { + return Corrupt("truncated header length"); + } + + uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(plain); + if (headerLength > plain.Length - 4) + { + return Corrupt("header length exceeds data"); + } + + RawChunkHeader? header; + try + { + header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MonitorJsonContext.Default.RawChunkHeader); + } + catch (JsonException) + { + return Corrupt("header is not valid json"); + } + + if (header is null || header.Streams.Count > ProtocolLimits.MaxStreamsPerChunk) + { + return Corrupt("header is missing or has too many streams"); + } + + ReadOnlySpan columns = plain.AsSpan(4 + (int)headerLength); + var streams = new List(header.Streams.Count); + foreach (StreamHeader streamHeader in header.Streams) + { + if (streamHeader.Count is < 0 or > ProtocolLimits.MaxRecordsPerChunk || streamHeader.Columns.Count > ProtocolLimits.MaxColumnsPerStream) + { + return Corrupt($"stream {streamHeader.Name} has an invalid size"); + } + + var cols = new List(streamHeader.Columns.Count); + foreach (ColumnHeader columnHeader in streamHeader.Columns) + { + if (!TryParseType(columnHeader.Type, out ColumnType type) || !TryParseEnc(columnHeader.Enc, out ColumnEnc enc)) + { + return Corrupt($"column {columnHeader.Name} has an unknown type or encoding"); + } + + RawColumn? column = ReadColumn(ref columns, columnHeader, type, enc, streamHeader.Count); + if (column is null) + { + return Corrupt($"column {streamHeader.Name}.{columnHeader.Name} is truncated or out of range"); + } + + cols.Add(column); + } + + streams.Add(new RawStream(streamHeader.Name, streamHeader.Count, cols)); + } + + return columns.Length == 0 ? Result.Ok(new RawChunk(header, streams)) : Corrupt("trailing bytes"); + } + + private static Result Corrupt(string message) => + Result.Fail(MonitorErrors.RawCorrupt, message); + + private static byte[]? Inflate(ReadOnlySpan payload) + { + using var input = new MemoryStream(payload.ToArray()); + using var brotli = new BrotliStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + try + { + int read; + while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0) + { + output.Write(buffer, 0, read); + if (output.Length > ProtocolLimits.MaxDecodedChunkBytes) + { + return null; + } + } + } + catch (InvalidDataException) + { + return null; + } + + return output.ToArray(); + } + + private static void WriteColumn(MemoryStream body, RawColumn column) + { + int count = column.Count; + if (column.Nullable) + { + byte[] bitmap = new byte[(count + 7) / 8]; + for (int i = 0; i < count; i++) + { + if (column.IsValid(i)) + { + bitmap[i >> 3] |= (byte)(1 << (i & 7)); + } + } + + body.Write(bitmap); + } + + bool signed = column.Type is ColumnType.I32 or ColumnType.I64; + long previous = 0; + for (int i = 0; i < count; i++) + { + if (!column.IsValid(i)) + { + continue; + } + + long value = column.Values[i]; + if (column.Enc == ColumnEnc.Delta) + { + VarInt.Write(body, VarInt.ZigZag(value - previous)); + previous = value; + } + else + { + VarInt.Write(body, signed ? VarInt.ZigZag(value) : (ulong)value); + } + } + } + + private static RawColumn? ReadColumn(ref ReadOnlySpan data, ColumnHeader header, ColumnType type, ColumnEnc enc, int count) + { + bool[]? valid = null; + if (header.Nullable) + { + int bitmapBytes = (count + 7) / 8; + if (data.Length < bitmapBytes) + { + return null; + } + + valid = new bool[count]; + for (int i = 0; i < count; i++) + { + valid[i] = (data[i >> 3] & (1 << (i & 7))) != 0; + } + + data = data[bitmapBytes..]; + } + + bool signed = type is ColumnType.I32 or ColumnType.I64; + long[] values = new long[count]; + long previous = 0; + for (int i = 0; i < count; i++) + { + if (valid is not null && !valid[i]) + { + continue; + } + + if (!VarInt.TryRead(data, out ulong raw, out int consumed)) + { + return null; + } + + data = data[consumed..]; + long value; + if (enc == ColumnEnc.Delta) + { + value = previous + VarInt.UnZigZag(raw); + previous = value; + } + else + { + value = signed ? VarInt.UnZigZag(raw) : unchecked((long)raw); + } + + if (!InRange(type, value)) + { + return null; + } + + values[i] = value; + } + + return new RawColumn(header.Name, type, enc, header.Nullable, ParseRole(header.Role), values, valid); + } + + private static bool InRange(ColumnType type, long value) => type switch + { + ColumnType.U8 => value is >= 0 and <= byte.MaxValue, + ColumnType.U16 => value is >= 0 and <= ushort.MaxValue, + ColumnType.U32 => value is >= 0 and <= uint.MaxValue, + ColumnType.I32 => value is >= int.MinValue and <= int.MaxValue, + _ => true, + }; + + public static string TypeName(ColumnType type) => type switch + { + ColumnType.U8 => "u8", + ColumnType.U16 => "u16", + ColumnType.U32 => "u32", + ColumnType.U64 => "u64", + ColumnType.I32 => "i32", + _ => "i64", + }; + + private static bool TryParseType(string text, out ColumnType type) + { + switch (text) + { + case "u8": type = ColumnType.U8; return true; + case "u16": type = ColumnType.U16; return true; + case "u32": type = ColumnType.U32; return true; + case "u64": type = ColumnType.U64; return true; + case "i32": type = ColumnType.I32; return true; + case "i64": type = ColumnType.I64; return true; + default: type = default; return false; + } + } + + private static bool TryParseEnc(string text, out ColumnEnc enc) + { + switch (text) + { + case "plain": enc = ColumnEnc.Plain; return true; + case "delta": enc = ColumnEnc.Delta; return true; + default: enc = default; return false; + } + } + + private static string? RoleName(ColumnRole role) => role switch + { + ColumnRole.TsProbe => "ts_probe", + ColumnRole.TsAgent => "ts_agent", + ColumnRole.TsLocal => "ts_local", + _ => null, + }; + + private static ColumnRole ParseRole(string? text) => text switch + { + "ts_probe" => ColumnRole.TsProbe, + "ts_agent" => ColumnRole.TsAgent, + "ts_local" => ColumnRole.TsLocal, + _ => ColumnRole.None, + }; +} diff --git a/backend/shared/Monitor.Protocol/Raw/StreamBuilder.cs b/backend/shared/Monitor.Protocol/Raw/StreamBuilder.cs new file mode 100644 index 0000000..65a5d38 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Raw/StreamBuilder.cs @@ -0,0 +1,58 @@ +namespace Monitor.Protocol; + +/// 按 逐行构造一个 (用于测试、模拟器和离线工具,不用于节点热路径)。 +public sealed class StreamBuilder +{ + private readonly StreamSpec _spec; + private readonly List[] _values; + private readonly List[] _valid; + + public StreamBuilder(StreamSpec spec) + { + _spec = spec; + _values = new List[spec.Columns.Count]; + _valid = new List[spec.Columns.Count]; + for (int i = 0; i < _values.Length; i++) + { + _values[i] = []; + _valid[i] = []; + } + } + + public int Count { get; private set; } + + /// 添加一行;null 表示缺失值(只允许用于可空列)。 + public StreamBuilder Add(params long?[] row) + { + if (row.Length != _values.Length) + { + throw new ArgumentException($"stream {_spec.Name} expects {_values.Length} values, got {row.Length}", nameof(row)); + } + + for (int i = 0; i < row.Length; i++) + { + if (row[i] is null && !_spec.Columns[i].Nullable) + { + throw new ArgumentException($"column {_spec.Columns[i].Name} is not nullable", nameof(row)); + } + + _values[i].Add(row[i] ?? 0); + _valid[i].Add(row[i] is not null); + } + + Count++; + return this; + } + + public RawStream Build() + { + var columns = new List(_values.Length); + for (int i = 0; i < _values.Length; i++) + { + ColumnSpec spec = _spec.Columns[i]; + columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. _values[i]], spec.Nullable ? [.. _valid[i]] : null)); + } + + return new RawStream(_spec.Name, Count, columns); + } +} diff --git a/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs new file mode 100644 index 0000000..c7466da --- /dev/null +++ b/backend/shared/Monitor.Protocol/Rpc/MonitorRpc.cs @@ -0,0 +1,440 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; + +namespace Monitor.Protocol; + +/// monitor.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 .On(...)。 +public static class MonitorMethods +{ + public const string TestRun = "monitor.test.run"; + public const string TestCancel = "monitor.test.cancel"; + public const string ProbeDisconnect = "monitor.probe.disconnect"; + public const string TestStatus = "monitor.test.status"; + public const string Event = "monitor.event"; + public const string Host = "monitor.host"; + public const string ProbeAgents = "monitor.probe.agents"; + public const string ProbeAgentsChanged = "monitor.probe.agents_changed"; + + // Probe ↔ Agent(点对点会话) + public const string Clock = "monitor.clock"; + public const string TestPhase = "monitor.test.phase"; + public const string TestPhaseEnd = "monitor.test.phase_end"; + public const string TestAbort = "monitor.test.abort"; +} + +public static class AgentEventKinds +{ + public const string ProbeConnected = "probe_connected"; + public const string ProbeDisconnected = "probe_disconnected"; + public const string EndpointChanged = "endpoint_changed"; + public const string SessionRejected = "session_rejected"; + public const string ClockStep = "clock_step"; + public const string Gap = "gap"; + public const string CpuSaturated = "cpu_saturated"; + + public static readonly IReadOnlyList All = + [ProbeConnected, ProbeDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated]; +} + +public static class TestStatuses +{ + public const string Queued = "queued"; + public const string Running = "running"; + public const string PhaseDone = "phase_done"; + public const string Succeeded = "succeeded"; + public const string Failed = "failed"; + public const string Aborted = "aborted"; +} + +public sealed class TestPhase +{ + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("params")] + public Dictionary Params { get; set; } = []; + + [JsonPropertyName("duration_s")] + public int DurationSeconds { get; set; } +} + +public sealed class TestRunParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } + + /// 在哪个 Probe 上运行:必须是当前连着这个 Agent 的 Probe。 + [JsonPropertyName("probe_id")] + public ProbeId ProbeId { get; set; } + + [JsonPropertyName("plan")] + public string Plan { get; set; } = ""; + + [JsonPropertyName("phases")] + public List Phases { get; set; } = []; +} + +public sealed class TestRunResult +{ + [JsonPropertyName("accepted")] + public bool Accepted { get; set; } +} + +public sealed class TestCancelParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } +} + +/// Backend → Agent:断开某个 Probe 的会话(Probe 会按退避重连,除非它已经被吊销)。 +public sealed class ProbeDisconnectParams +{ + [JsonPropertyName("probe_id")] + public ProbeId ProbeId { get; set; } +} + +public sealed class TestStatusParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; + + [JsonPropertyName("phase")] + public int? Phase { get; set; } + + [JsonPropertyName("error_code")] + public string? ErrorCode { get; set; } +} + +public sealed class AgentEventParams +{ + /// probe_connected 时:这个 Probe 的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。 + [JsonPropertyName("probe")] + public ConnectedProbe? Probe { get; set; } + + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("at_utc_ms")] + public long AtUtcMs { get; set; } + + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} + +public sealed class AgentEndpoint +{ + [JsonPropertyName("agent_id")] + public NodeId AgentId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + [JsonPropertyName("tcp_port")] + public int TcpPort { get; set; } + + [JsonPropertyName("udp_ports")] + public List UdpPorts { get; set; } = []; + + /// Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。 + [JsonPropertyName("spki")] + public string Spki { get; set; } = ""; +} + +public sealed class ProbeAgentsParams +{ + /// Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。 + [JsonPropertyName("have_version")] + public long HaveVersion { get; set; } +} + +public sealed class ProbeAgentsResult +{ + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("agents")] + public List Agents { get; set; } = []; +} + +/// 注册元数据(Monitor 定义,经 Fleet 的 meta 字段带上来)。 +public sealed class AgentEnrollMeta +{ + [JsonPropertyName("probe_tcp_port")] + public int ProbeTcpPort { get; set; } = 7443; + + [JsonPropertyName("probe_udp_ports")] + public List ProbeUdpPorts { get; set; } = [7443]; + + [JsonPropertyName("public_host")] + public string? PublicHost { get; set; } + + [JsonPropertyName("kinds")] + public List Kinds { get; set; } = []; +} + +public sealed class LocalLimits +{ + [JsonPropertyName("max_total_mbps")] + public double MaxTotalMbps { get; set; } + + [JsonPropertyName("max_total_pps")] + public long MaxTotalPps { get; set; } + + [JsonPropertyName("allowed_target_cidrs")] + public List AllowedTargetCidrs { get; set; } = []; + + [JsonPropertyName("max_concurrent_tests")] + public int MaxConcurrentTests { get; set; } = 1; +} + +/// fleet.hello 的附加字段。 +public sealed class AgentHelloMeta +{ + [JsonPropertyName("tls_spki")] + public string? TlsSpki { get; set; } + + [JsonPropertyName("probe_tcp_port")] + public int? ProbeTcpPort { get; set; } + + [JsonPropertyName("probe_udp_ports")] + public List? ProbeUdpPorts { get; set; } + + [JsonPropertyName("local_limits")] + public LocalLimits? LocalLimits { get; set; } +} + +/// +/// 主机资源占用的只读快照(Agent → Backend,每隔几秒一次)。只有汇总的数字:没有 CPU 型号、主机名、网卡名,也没有任何 IP 地址。 +/// 读不到的项是 null。 +/// +public sealed class HostSnapshot +{ + [JsonPropertyName("cpu_percent")] + public double? CpuPercent { get; set; } + + [JsonPropertyName("mem_used")] + public long? MemUsed { get; set; } + + [JsonPropertyName("mem_total")] + public long? MemTotal { get; set; } + + [JsonPropertyName("disk_used")] + public long? DiskUsed { get; set; } + + [JsonPropertyName("disk_total")] + public long? DiskTotal { get; set; } + + /// 所有物理网卡的累计收发字节(自开机以来)。 + [JsonPropertyName("net_rx_total")] + public long? NetRxTotal { get; set; } + + [JsonPropertyName("net_tx_total")] + public long? NetTxTotal { get; set; } + + [JsonPropertyName("net_rx_bps")] + public long? NetRxBps { get; set; } + + [JsonPropertyName("net_tx_bps")] + public long? NetTxBps { get; set; } + + [JsonPropertyName("uptime_s")] + public long? UptimeSeconds { get; set; } + + [JsonPropertyName("at_utc_ms")] + public long AtUtcMs { get; set; } +} + +/// Agent 眼中的一个已连接 Probe。 +public sealed class ConnectedProbe +{ + [JsonPropertyName("id")] + public ProbeId Id { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// Probe 的远端地址(经过 NAT 之后的公网 IP:端口)。 + [JsonPropertyName("remote")] + public string? Remote { get; set; } + + [JsonPropertyName("since_utc_ms")] + public long SinceUtcMs { get; set; } +} + +/// fleet.heartbeat 的附加字段:只有进程内的计数,没有主机信息。 +public sealed class AgentHeartbeatExtra +{ + /// 当前连着的 Probe 会话数(等于 的个数)。 + [JsonPropertyName("sessions")] + public int Sessions { get; set; } + + /// 当前连着的 Probe:每次心跳都是完整的快照,Backend 靠它纠正错过的 probe_connected / probe_disconnected 事件。 + [JsonPropertyName("probes")] + public List Probes { get; set; } = []; + + [JsonPropertyName("pps_in")] + public long PpsIn { get; set; } + + [JsonPropertyName("pps_out")] + public long PpsOut { get; set; } + + [JsonPropertyName("dropped_invalid")] + public long DroppedInvalid { get; set; } + + [JsonPropertyName("dropped_rate_limited")] + public long DroppedRateLimited { get; set; } + + [JsonPropertyName("queue_bytes")] + public long QueueBytes { get; set; } +} + +/// Agent 下发给 Probe 的一条线路任务(期望状态类型 line_task_set 里的一项)。 +public sealed class LineTask +{ + [JsonPropertyName("id")] + public TaskId Id { get; set; } + + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("params")] + public Dictionary Params { get; set; } = []; + + /// 数据面用它区分任务;Agent 在本进程生命周期内分配。 + [JsonPropertyName("stream_id")] + public uint StreamId { get; set; } + + [JsonPropertyName("port")] + public int Port { get; set; } +} + +/// 这条线路的期望状态(Agent → Probe):Probe 按它调和自己一侧的角色。 +public sealed class LineTaskSet +{ + public const string StateType = "line_task_set"; + + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = []; + + /// Agent 的数据面端口(Probe 把包发到这里)。 + [JsonPropertyName("udp_ports")] + public List UdpPorts { get; set; } = []; + + [JsonPropertyName("tcp_ports")] + public List TcpPorts { get; set; } = []; +} + +/// monitor.clock:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。 +public sealed class ClockParams +{ + [JsonPropertyName("probe_mono_us")] + public long ProbeMonoUs { get; set; } +} + +public sealed class ClockResult +{ + [JsonPropertyName("agent_mono_us")] + public long AgentMonoUs { get; set; } +} + +/// 按需测试的一个阶段(Agent → Probe):Agent 编排时间,Probe 准备好自己一侧的角色之后应答。 +public sealed class TestPhaseStartParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } + + [JsonPropertyName("phase")] + public int Phase { get; set; } + + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("params")] + public Dictionary Params { get; set; } = []; + + [JsonPropertyName("stream_id")] + public uint StreamId { get; set; } + + [JsonPropertyName("duration_s")] + public int DurationSeconds { get; set; } + + [JsonPropertyName("port")] + public int Port { get; set; } +} + +public sealed class TestPhaseStartResult +{ + [JsonPropertyName("accepted")] + public bool Accepted { get; set; } +} + +/// 阶段结束(Agent → Probe):Probe 收尾并把自己一侧的列块交给 Agent。sent_count 是 Agent 一侧发出的包数(单向数据流用)。 +public sealed class TestPhaseEndParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } + + [JsonPropertyName("phase")] + public int Phase { get; set; } + + [JsonPropertyName("stream_id")] + public uint StreamId { get; set; } + + [JsonPropertyName("sent_count")] + public long SentCount { get; set; } +} + +/// Probe 收尾之后回复:它交给 Agent 的列块个数(Agent 据此等待全部列块到齐再进入下一阶段)。 +public sealed class TestPhaseEndResult +{ + [JsonPropertyName("fragments")] + public int Fragments { get; set; } +} + +public sealed class TestAbortParams +{ + [JsonPropertyName("test_id")] + public TestId TestId { get; set; } +} + +/// Probe 一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由 Agent 一侧按它取行)。 +public static class FragmentKeys +{ + public const string SeqFrom = "_seq_from"; + public const string SeqTo = "_seq_to"; +} + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(LineTaskSet))] +[JsonSerializable(typeof(ClockParams))] +[JsonSerializable(typeof(ClockResult))] +[JsonSerializable(typeof(TestPhaseStartParams))] +[JsonSerializable(typeof(TestPhaseStartResult))] +[JsonSerializable(typeof(TestPhaseEndParams))] +[JsonSerializable(typeof(TestPhaseEndResult))] +[JsonSerializable(typeof(TestAbortParams))] +[JsonSerializable(typeof(TaskSet))] +[JsonSerializable(typeof(TestRunParams))] +[JsonSerializable(typeof(TestRunResult))] +[JsonSerializable(typeof(TestCancelParams))] +[JsonSerializable(typeof(ProbeDisconnectParams))] +[JsonSerializable(typeof(HostSnapshot))] +[JsonSerializable(typeof(TestStatusParams))] +[JsonSerializable(typeof(AgentEventParams))] +[JsonSerializable(typeof(ProbeAgentsParams))] +[JsonSerializable(typeof(ProbeAgentsResult))] +[JsonSerializable(typeof(AgentEnrollMeta))] +[JsonSerializable(typeof(AgentHelloMeta))] +[JsonSerializable(typeof(AgentHeartbeatExtra))] +[JsonSerializable(typeof(RawChunkHeader))] +public sealed partial class MonitorJsonContext : JsonSerializerContext; diff --git a/backend/shared/Monitor.Protocol/Tasks/TaskSet.cs b/backend/shared/Monitor.Protocol/Tasks/TaskSet.cs new file mode 100644 index 0000000..89476d0 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Tasks/TaskSet.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; + +namespace Monitor.Protocol; + +/// Task = { id, scope, kind, target, params, schedule, enabled }。参数已经按 ParamSpec 校验并补全默认值。 +public sealed class TaskSpec +{ + [JsonPropertyName("id")] + public TaskId Id { get; set; } + + /// line | target + [JsonPropertyName("scope")] + public string Scope { get; set; } = "line"; + + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + [JsonPropertyName("target")] + public string? Target { get; set; } + + [JsonPropertyName("params")] + public Dictionary Params { get; set; } = []; + + /// continuous | every:<分钟> | once + [JsonPropertyName("schedule")] + public string Schedule { get; set; } = TaskSchedule.Continuous; + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; +} + +public sealed class PeerSpec +{ + [JsonPropertyName("probe_id")] + public ProbeId ProbeId { get; set; } + + /// Probe 的身份公钥(SPKI,base64)。 + [JsonPropertyName("pubkey")] + public string PublicKey { get; set; } = ""; +} + +/// 期望状态文档(Fleet state type = task_set):Backend 为每个 Agent 计算的有效任务集。 +public sealed class TaskSet +{ + public const string StateType = "task_set"; + + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = []; + + /// 允许连接到这个 Agent 的 Probe 公钥。 + [JsonPropertyName("peers")] + public List Peers { get; set; } = []; + + [JsonPropertyName("probe_agents_version")] + public long ProbeAgentsVersion { get; set; } +} + +public static class TaskSchedule +{ + public const string Continuous = "continuous"; + public const string Once = "once"; + public const string EveryPrefix = "every:"; + + public static string Every(int minutes) => EveryPrefix + minutes.ToString(CultureInfo.InvariantCulture); + + /// 校验调度字符串:every:N 的 N ≥ 60(适用于重负载 Kind)。 + public static Result Validate(string schedule) + { + if (schedule is Continuous or Once) + { + return Result.Ok(null); + } + + if (schedule.StartsWith(EveryPrefix, StringComparison.Ordinal) + && int.TryParse(schedule.AsSpan(EveryPrefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out int minutes)) + { + return minutes >= 60 + ? Result.Ok(minutes) + : Result.Fail(MonitorErrors.ValidationFailed, "every:N requires N >= 60 minutes"); + } + + return Result.Fail(MonitorErrors.ValidationFailed, "schedule must be continuous, once or every:"); + } +} diff --git a/backend/shared/Monitor.Protocol/Wire/TcpWire.cs b/backend/shared/Monitor.Protocol/Wire/TcpWire.cs new file mode 100644 index 0000000..6ca01af --- /dev/null +++ b/backend/shared/Monitor.Protocol/Wire/TcpWire.cs @@ -0,0 +1,132 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using Fleet.Core; + +namespace Monitor.Protocol; + +/// +/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头, +/// 明文首帧以 "MN" 开头,所以同一个端口可以区分)。首帧: +/// "MN"(2) version(1) session_id(8) stream_id(4) nonce(16) mac(16),认证失败直接关闭连接; +/// 之后是 VarInt 长度 | 正文 的消息,回显类消息的正文是 seq(u32) send_ts_us(i64) | padding。 +/// +public static class TcpWire +{ + public const int AuthBytes = 2 + 1 + 8 + 4 + 16 + 16; + public const int NonceBytes = 16; + public const int MinEchoBody = 12; + public const int MaxMessageBytes = 1 << 20; + + public static void WriteAuth(Span destination, long sessionId, uint streamId, ReadOnlySpan nonce, ReadOnlySpan key) + { + BinaryPrimitives.WriteUInt16LittleEndian(destination, UdpPacket.Magic); + destination[2] = UdpPacket.Version; + BinaryPrimitives.WriteInt64LittleEndian(destination[3..], sessionId); + BinaryPrimitives.WriteUInt32LittleEndian(destination[11..], streamId); + nonce[..NonceBytes].CopyTo(destination[15..]); + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, destination[..(AuthBytes - 16)], full); + full[..16].CopyTo(destination[(AuthBytes - 16)..]); + } + + /// 解析首帧(MAC 要在查到会话密钥之后用 校验)。 + public static bool TryParseAuth(ReadOnlySpan frame, out long sessionId, out uint streamId, out ReadOnlySpan nonce) + { + sessionId = 0; + streamId = 0; + nonce = default; + if (frame.Length != AuthBytes || BinaryPrimitives.ReadUInt16LittleEndian(frame) != UdpPacket.Magic || frame[2] != UdpPacket.Version) + { + return false; + } + + sessionId = BinaryPrimitives.ReadInt64LittleEndian(frame[3..]); + streamId = BinaryPrimitives.ReadUInt32LittleEndian(frame[11..]); + nonce = frame.Slice(15, NonceBytes); + return true; + } + + public static bool VerifyAuth(ReadOnlySpan frame, ReadOnlySpan key) + { + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, frame[..(AuthBytes - 16)], full); + return CryptographicOperations.FixedTimeEquals(full[..16], frame[(AuthBytes - 16)..]); + } + + /// 回显消息正文:seq | send_ts_us | padding,总长度 。 + public static void WriteEchoBody(Span destination, uint seq, long sendTsUs) + { + destination.Clear(); + BinaryPrimitives.WriteUInt32LittleEndian(destination, seq); + BinaryPrimitives.WriteInt64LittleEndian(destination[4..], sendTsUs); + } + + public static bool TryReadEchoBody(ReadOnlySpan body, out uint seq, out long sendTsUs) + { + seq = 0; + sendTsUs = 0; + if (body.Length < MinEchoBody) + { + return false; + } + + seq = BinaryPrimitives.ReadUInt32LittleEndian(body); + sendTsUs = BinaryPrimitives.ReadInt64LittleEndian(body[4..]); + return true; + } + + /// 读取一条 VarInt 长度 | 正文 消息;连接结束或长度非法时返回 null。 + public static async ValueTask ReadMessageAsync(Stream stream, CancellationToken ct) + { + ulong length = 0; + int shift = 0; + byte[] one = new byte[1]; + for (int i = 0; i < VarInt.MaxBytes; i++) + { + if (await stream.ReadAsync(one, ct) == 0) + { + return null; + } + + length |= (ulong)(one[0] & 0x7F) << shift; + if ((one[0] & 0x80) == 0) + { + break; + } + + shift += 7; + if (i == VarInt.MaxBytes - 1) + { + return null; + } + } + + if (length > MaxMessageBytes) + { + return null; + } + + byte[] body = new byte[length]; + int read = 0; + while (read < body.Length) + { + int n = await stream.ReadAsync(body.AsMemory(read), ct); + if (n == 0) + { + return null; + } + + read += n; + } + + return body; + } + + public static async ValueTask WriteMessageAsync(Stream stream, ReadOnlyMemory body, CancellationToken ct) + { + byte[] prefix = new byte[VarInt.MaxBytes]; + int n = VarInt.Write(prefix, (ulong)body.Length); + await stream.WriteAsync(prefix.AsMemory(0, n), ct); + await stream.WriteAsync(body, ct); + } +} diff --git a/backend/shared/Monitor.Protocol/Wire/UdpPacket.cs b/backend/shared/Monitor.Protocol/Wire/UdpPacket.cs new file mode 100644 index 0000000..2827ba6 --- /dev/null +++ b/backend/shared/Monitor.Protocol/Wire/UdpPacket.cs @@ -0,0 +1,91 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace Monitor.Protocol; + +public enum UdpPacketType : byte +{ + EchoRequest = 1, + EchoReply = 2, + + /// 单向数据(udp_train),以及 Probe 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。 + Data = 3, +} + +public readonly record struct UdpHeader(UdpPacketType Type, long SessionId, uint StreamId, uint Seq, long SendTsUs); + +/// +/// 数据面的 UDP 包(Fleet 之外的线格式): +/// magic(2) version(1) type(1) session_id(8) stream_id(4) seq(4) send_ts_us(8) | padding | mac(16), +/// mac = HMAC-SHA256(session_key, 前面全部字节) 截断到 16 字节。所有整数小端。 +/// 接收方对不合法的包静默丢弃并计数,不回复(防放大)。 +/// +public static class UdpPacket +{ + public const ushort Magic = 0x4D4E; // "MN" + public const byte Version = 1; + public const int HeaderBytes = 28; + public const int MacBytes = 16; + public const int MinBytes = HeaderBytes + MacBytes; + public const int MaxBytes = 1472; + + /// 检查长度、magic、version、type 并解析头部(不校验 MAC)。 + public static bool TryParseHeader(ReadOnlySpan packet, out UdpHeader header) + { + header = default; + if (packet.Length is < MinBytes or > MaxBytes + || BinaryPrimitives.ReadUInt16LittleEndian(packet) != Magic + || packet[2] != Version + || packet[3] is < (byte)UdpPacketType.EchoRequest or > (byte)UdpPacketType.Data) + { + return false; + } + + header = new UdpHeader( + (UdpPacketType)packet[3], + BinaryPrimitives.ReadInt64LittleEndian(packet[4..]), + BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]), + BinaryPrimitives.ReadUInt32LittleEndian(packet[16..]), + BinaryPrimitives.ReadInt64LittleEndian(packet[20..])); + return true; + } + + /// 写入整个包(长度就是 的长度),填充为零,末尾是 MAC。 + public static void Write(Span destination, in UdpHeader header, ReadOnlySpan key) + { + if (destination.Length is < MinBytes or > MaxBytes) + { + throw new ArgumentOutOfRangeException(nameof(destination), "packet size must be between 44 and 1472 bytes"); + } + + destination.Clear(); + BinaryPrimitives.WriteUInt16LittleEndian(destination, Magic); + destination[2] = Version; + destination[3] = (byte)header.Type; + BinaryPrimitives.WriteInt64LittleEndian(destination[4..], header.SessionId); + BinaryPrimitives.WriteUInt32LittleEndian(destination[12..], header.StreamId); + BinaryPrimitives.WriteUInt32LittleEndian(destination[16..], header.Seq); + BinaryPrimitives.WriteInt64LittleEndian(destination[20..], header.SendTsUs); + Seal(destination, key); + } + + /// 重新计算并写入末尾的 MAC(回复包复用请求包的缓冲区时使用)。 + public static void Seal(Span packet, ReadOnlySpan key) + { + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, packet[..^MacBytes], full); + full[..MacBytes].CopyTo(packet[^MacBytes..]); + } + + public static bool VerifyMac(ReadOnlySpan packet, ReadOnlySpan key) + { + if (packet.Length < MinBytes) + { + return false; + } + + Span full = stackalloc byte[32]; + HMACSHA256.HashData(key, packet[..^MacBytes], full); + return CryptographicOperations.FixedTimeEquals(full[..MacBytes], packet[^MacBytes..]); + } +} diff --git a/backend/shared/tests/Monitor.Measurement.Tests/MeasurementTests.cs b/backend/shared/tests/Monitor.Measurement.Tests/MeasurementTests.cs new file mode 100644 index 0000000..f0875a9 --- /dev/null +++ b/backend/shared/tests/Monitor.Measurement.Tests/MeasurementTests.cs @@ -0,0 +1,378 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Measurement.Tests; + +public sealed class MeasurementTests +{ + private static JsonElement Json(string raw) => JsonDocument.Parse(raw).RootElement.Clone(); + + private static TaskSpec Line(string kind, params (string Key, string Raw)[] parameters) => new() + { + Id = new TaskId("tsk_" + kind), + Scope = "line", + Kind = kind, + Params = parameters.ToDictionary(p => p.Key, p => Json(p.Raw)), + }; + + private static BudgetGuard Guard(NodeLimits? limits = null) => new(limits ?? new NodeLimits { AllowedUdpPorts = [7443], AllowedTcpPorts = [7444] }, BlockCatalog.AgentKinds); + + [Fact] + public void BudgetGuard_AcceptsDefaults_RejectsUnknownKindsBadParamsAndForeignPorts() + { + BudgetGuard guard = Guard(); + double mbps = 0; + long pps = 0; + Assert.True(guard.Check(Line("udp_echo"), ref mbps, ref pps).Accepted); + Assert.Equal(RejectReasons.Unsupported, guard.Check(Line("no_such_kind"), ref mbps, ref pps).Reason); + Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("rate_hz", "100000")), ref mbps, ref pps).Reason); + Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("nope", "1")), ref mbps, ref pps).Reason); + Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("udp_echo", ("port", "9999")), ref mbps, ref pps).Reason); + } + + [Fact] + public void BudgetGuard_AccumulatesTheBudget_AndRejectsWhatDoesNotFit() + { + BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedUdpPorts = [7443] }); + double mbps = 0; + long pps = 0; + Assert.True(guard.CheckPhase("udp_train", new Dictionary { ["mbps"] = Json("10") }).Accepted); + Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("udp_train", new Dictionary { ["mbps"] = Json("20") }).Reason); + + Assert.True(guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Accepted); + Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Reason); + } + + [Fact] + public void BudgetGuard_TargetTasks_AreDeniedUnlessTheAddressIsInAnAllowedRange() + { + Assert.False(Guard().TargetAllowed("1.1.1.1")); + + BudgetGuard some = Guard(new NodeLimits { AllowedTargetCidrs = ["192.0.2.0/24", "2001:db8::/32"] }); + Assert.True(some.TargetAllowed("192.0.2.17")); + Assert.True(some.TargetAllowed("192.0.2.17:443")); + Assert.True(some.TargetAllowed("2001:db8::1")); + Assert.False(some.TargetAllowed("192.0.3.1")); + Assert.False(some.TargetAllowed("example.com")); + Assert.False(some.TargetAllowed(null)); + } + + // ---- RowStore ---- + + [Fact] + public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder() + { + StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!; + var probe = new RowStore(msg, Side.Probe, 64); + Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Probe).Select(c => c.Name)); + Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Agent).Select(c => c.Name)); + + for (int i = 0; i < 4; i++) + { + long key = probe.Append(); + probe.Set(key, probe.ColumnIndex("size"), 64); + probe.Set(key, probe.ColumnIndex("t1"), 1000 + i); + } + + probe.Set(2, probe.ColumnIndex("t4"), 5000); + RawStream cut = probe.Cut(1, 4, includeKey: true); + Assert.Equal(3, cut.Count); + Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name)); + Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values); + Assert.Equal([false, true, false], cut.Find("t4")!.Valid!); + + Assert.Throws(() => probe.ColumnIndex("t2")); + } + + [Fact] + public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne() + { + var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 16); + store.SetRow(3, [30, 31]); + Assert.True(store.TryGet(3, 0, out long t2)); + Assert.Equal(30, t2); + + store.SetRow(3 + 16, [40, 41]); // same slot, newer key + Assert.False(store.Contains(3)); + Assert.False(store.TryGet(3, 0, out _)); + Assert.True(store.TryGet(19, 1, out long t3)); + Assert.Equal(41, t3); + } + + // ---- 声明驱动的合并 ---- + + private sealed class Source : IRecordSource + { + public Dictionary Stores { get; } = []; + + public RowStore? Records(uint streamId, string streamName) => Stores.GetValueOrDefault(streamName); + } + + private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows) + { + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + foreach ((uint seq, long t1, long? t4) in rows) + { + msg.Add(seq, 64, t1, t4); + } + + return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { ProbeOffsetUs = 1000 } }, [msg.Build()]); + } + + [Fact] + public void FragmentMerger_ProbeDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty() + { + var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64); + agent.SetRow(0, [2000, 2050]); + agent.SetRow(2, [4000, 4040]); + var source = new Source { Stores = { ["msg"] = agent } }; + RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null), (2, 3000, 5000)); + + Result> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source); + RawStream msg = Assert.Single(merged.Value); + Assert.Equal(["seq", "size", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name)); + Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i))); + Assert.Equal(2050, msg.Find("t3")!.Values[0]); + Assert.False(msg.Find("t4")!.IsValid(1)); + } + + [Fact] + public void FragmentMerger_ProbeDriven_WithoutAnyAgentRecords_KeepsTheProbeRows() + { + RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null)); + RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value); + Assert.Equal(2, msg.Count); + Assert.False(msg.Find("t2")!.IsValid(0)); + Assert.Equal(3000, msg.Find("t4")!.Values[0]); + } + + [Fact] + public void FragmentMerger_RefusesAFragmentWithoutTheProbeColumns() + { + var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")])); + bad.Add(0); + var fragment = new RawChunk(new RawChunkHeader { Kind = "udp_echo" }, [bad.Build()]); + Assert.False(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).IsSuccess); + } + + [Fact] + public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost() + { + var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Agent, 64); + for (uint seq = 0; seq < 5; seq++) + { + agent.SetRow(seq, [1000, 100 + seq]); + } + + var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv")])); + pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost + var header = new RawChunkHeader { Kind = "udp_train" }; + header.Params[FragmentKeys.SeqFrom] = Json("0"); + header.Params[FragmentKeys.SeqTo] = Json("5"); + var source = new Source { Stores = { ["pkt"] = agent } }; + + RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(header, [pkt.Build()]), 1, source).Value); + Assert.Equal(5, merged.Count); + Assert.Equal(["seq", "size", "t_send", "t_recv"], merged.Columns.Select(c => c.Name)); + RawColumn recv = merged.Find("t_recv")!; + Assert.Equal([false, true, false, true, false], Enumerable.Range(0, 5).Select(recv.IsValid)); + Assert.Equal(5000, recv.Values[1]); + Assert.Equal(103, merged.Find("t_send")!.Values[3]); + + // A one-way fragment that does not declare its sequence range is invalid + Assert.False(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(new RawChunkHeader { Kind = "udp_train" }, [pkt.Build()]), 1, source).IsSuccess); + } + + [Fact] + public void FragmentMerger_StreamsWithoutAgentColumns_PassThroughInDeclaredOrder() + { + var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsProbe("ts")])); + conn.Add(0, 0, 100); + var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]); + + RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.Connect, fragment, 1, new Source()).Value); + Assert.Equal(["attempt", "ts", "tcp_us", "tls_us", "outcome"], merged.Columns.Select(c => c.Name)); + Assert.False(merged.Find("tcp_us")!.IsValid(0)); + } + + // ---- 两侧各自记录完整行的流(内核采样)---- + + private static RawStream InfoRows(StreamSpec spec, params (long Ts, long Side, long Retrans)[] rows) + { + var builder = new StreamBuilder(spec); + foreach ((long ts, long side, long retrans) in rows) + { + builder.Add(ts, side, 40_000, 5_000, 10, retrans, 100, 1_000); + } + + return builder.Build(); + } + + [Fact] + public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce() + { + StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!; + Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Agent).Count); // each side owns every column + var agent = new RowStore(info, Side.Agent, 64); + var recorder = new Source { Stores = { ["tcp_info"] = agent } }; + agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000]); + agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100]); + + var msg = EchoFragment((0, 1000, 3000)); + var fragment = new RawChunk(msg.Header, [msg.Streams[0], InfoRows(info, (100, 0, 1))]); + + IReadOnlyList first = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value; + RawStream merged = first.Single(x => x.Name == "tcp_info"); + Assert.Equal(3, merged.Count); + Assert.Equal([0L, 1L, 1L], merged.Find("side")!.Values); + Assert.Equal([1L, 7L, 8L], merged.Find("retrans_total")!.Values); + + // The agent's rows were consumed: the next chunk carries only the probe's own rows + IReadOnlyList second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value; + Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count); + } + + [Fact] + public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheProbeHasNone() + { + StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!; + var agent = new RowStore(info, Side.Agent, 64); + agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000]); + + // A probe on a platform without kernel sampling sends the echo rows only + RawChunk fragment = EchoFragment((0, 1000, 3000)); + IReadOnlyList merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value; + RawStream rows = merged.Single(x => x.Name == "tcp_info"); + Assert.Equal(1, rows.Count); + Assert.Equal(7, rows.Find("retrans_total")!.Values[0]); + } + + [Fact] + public void TcpInfo_OnLinux_ReadsTheKernelCountersOfALiveConnection() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "TCP_INFO is Linux only"); + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + using var client = new System.Net.Sockets.TcpClient(); + client.Connect((System.Net.IPEndPoint)listener.LocalEndpoint); + using System.Net.Sockets.TcpClient server = listener.AcceptTcpClient(); + client.GetStream().Write(new byte[1000]); + Assert.True(TcpInfo.TrySample(client.Client, out TcpInfoSample sample)); + Assert.True(sample.Cwnd > 0); + Assert.True(sample.SegsOut > 0); + } + + // ---- 回显行的交出时机 ---- + + [Fact] + public void EchoRows_Tcp_WaitsForTheReplyInsteadOfCallingASeveralSecondStallALoss() + { + var rows = new EchoRows(Kinds.TcpEcho, 64, EchoRows.TcpGraceUs, settleOnReply: true); + uint a = rows.Add(64, 0); + uint b = rows.Add(64, 100_000); + uint c = rows.Add(64, 200_000); + rows.Reply(a, 1_000); + rows.Reply(c, 9_000_000); // the third echo came back first in wall-clock terms, the second one is stuck behind a stall + + // 10 s later: row b has no reply yet. TCP does not lose data, so it is not given up on and it holds back the rows behind it + RawStream first = rows.Take(10_000_000, all: false)!; + Assert.Equal([0L], first.Find("seq")!.Values); + Assert.Null(rows.Take(10_000_000, all: false)); + + // The stall ends and the reply arrives with a multi-second RTT: it is recorded, not lost + rows.Reply(b, 8_000_000); + RawStream rest = rows.Take(10_000_000, all: false)!; + Assert.Equal([1L, 2L], rest.Find("seq")!.Values); + Assert.Equal([true, true], rest.Find("t4")!.Valid!); + Assert.Equal(8_000_000, rest.Find("t4")!.Values[0]); + + // A connection that is really gone: after the long grace the row is delivered with the reply missing + uint d = rows.Add(64, 20_000_000); + Assert.Null(rows.Take(40_000_000, all: false)); + RawStream gone = rows.Take(51_000_000, all: false)!; + Assert.Equal([(long)d], gone.Find("seq")!.Values); + Assert.Equal([false], gone.Find("t4")!.Valid!); + } + + [Fact] + public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds() + { + var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.UdpGraceUs, settleOnReply: false); + rows.Add(64, 0); + rows.Add(64, 1_000_000); + RawStream cut = rows.Take(2_500_000, all: false)!; + Assert.Equal([0L], cut.Find("seq")!.Values); + Assert.Equal([false], cut.Find("t4")!.Valid!); // never replied: lost + Assert.Equal([1L], rows.Take(0, all: true)!.Find("seq")!.Values); + } + + // ---- 积木目录 ---- + + [Fact] + public void BlockCatalog_EveryKindHasADeclaration_AndEveryListenerKeepsTheRecordsItsStreamsNeed() + { + var env = new BlockEnv { Clock = new MonoClock(TimeProvider.System), Log = NullNodeLog.Instance, Session = () => null }; + foreach (BlockPair pair in BlockCatalog.All) + { + KindSpec kind = Kinds.Find(pair.Kind)!; + Assert.NotNull(kind); + Assert.Contains(pair.Kind, BlockCatalog.AgentKinds); + + if (pair.Listener is null) + { + Assert.True(pair.RunsAsTarget, $"{pair.Kind} has no listener, so it can only be a target task"); + continue; + } + + Assert.Contains(pair.Kind, BlockCatalog.ProbeKinds); + BlockSpec spec = new(kind, ParamResolver.Resolve(kind.Params, null).Values!, 1, 7443, new FragmentScope(null, null, null)); + IBlock listener = pair.Listener(env, spec); + + // 声明了连接键、Agent 一侧有列的流:监听方必须为它保留按键记录,否则合并不出完整的行 + foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Agent).Count > 0)) + { + Assert.NotNull(listener.Records(stream.Name)); + } + } + } + + [Fact] + public void BlockSpec_ConfigSignatureIgnoresTheStreamId_ButSignatureDoesNot() + { + BlockSpec Create(string json, uint stream = 1, int port = 7443) => BlockSpec.Create( + "udp_echo", + JsonDocument.Parse(json).RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()), + stream, + port, + new FragmentScope(null, null, null))!; + + BlockSpec baseline = Create("""{"rate_hz":10,"size":64}"""); + Assert.Equal(baseline.Signature, Create("""{"size":64,"rate_hz":10}""").Signature); + Assert.NotEqual(baseline.Signature, Create("""{"rate_hz":20,"size":64}""").Signature); + Assert.NotEqual(baseline.Signature, Create("""{"rate_hz":10,"size":64}""", stream: 2).Signature); + Assert.Equal(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", stream: 2).ConfigSignature); + Assert.NotEqual(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", port: 9000).ConfigSignature); + Assert.Null(BlockSpec.Create("udp_echo", new Dictionary { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null))); + } + + // ---- 起止时间 ---- + + [Fact] + public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime() + { + var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64); + agent.SetRow(0, [2000, 2050]); + RawChunk fragment = EchoFragment((0, 1000, 3000)); + IReadOnlyList streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value; + + var anchor = new ClockAnchor(AgentMonoUs: 1_000_000, UtcMs: 1_700_000_000_000); + RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid())); + + // t1 = 1000 us (Probe clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor + Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs); + Assert.Equal(7443, chunk.Header.Port); + Assert.False(chunk.Header.Partial); + Assert.Equal(1_000_000, chunk.Header.Clock.AgentMonoUs); + } +} diff --git a/backend/shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj b/backend/shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj new file mode 100644 index 0000000..e067df9 --- /dev/null +++ b/backend/shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + false + + + + + + + + + diff --git a/backend/shared/tests/Monitor.Protocol.Tests/CatalogTests.cs b/backend/shared/tests/Monitor.Protocol.Tests/CatalogTests.cs new file mode 100644 index 0000000..2a98537 --- /dev/null +++ b/backend/shared/tests/Monitor.Protocol.Tests/CatalogTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Protocol.Tests; + +public sealed class CatalogTests +{ + private static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement.Clone(); + + [Fact] + public void EveryKind_HasBoundedParams_ValidDefaults_AndUniqueNames() + { + Assert.Equal(Kinds.All.Count, Kinds.All.Select(k => k.Id).Distinct().Count()); + foreach (KindSpec kind in Kinds.All) + { + ParamResolution defaults = ParamResolver.Resolve(kind.Params, null); + Assert.True(defaults.IsValid, $"{kind.Id}: defaults must be valid"); + Assert.Equal(kind.Params.Count, kind.Params.Select(p => p.Name).Distinct().Count()); + Assert.True(kind.BudgetBitsPerSecond(defaults.Values!) >= 0); + foreach (ParamSpec spec in kind.Params) + { + // 每个参数的默认值必须能通过自己的校验(上下限在构造时已写明) + Assert.True(spec.TryNormalize(spec.DefaultValue, out _, out string error), $"{kind.Id}.{spec.Name}: {error}"); + } + + foreach (StreamSpec stream in kind.Streams) + { + Assert.Equal(stream.Columns.Count, stream.Columns.Select(c => c.Name).Distinct().Count()); + } + } + } + + [Fact] + public void DefaultLineTemplates_StayWithinTheLineBudget() + { + long total = 0; + foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho }) + { + total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!); + } + + Assert.InRange(total, 1, ProtocolLimits.MaxLineBudgetBitsPerSecond); + } + + [Fact] + public void Resolve_FillsDefaults_AndReportsEveryProblem() + { + var input = new Dictionary + { + ["rate_hz"] = Json("500"), + ["size"] = Json("\"big\""), + ["bogus"] = Json("1"), + }; + ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input); + + Assert.False(result.IsValid); + Assert.Contains("between 1 and 200", result.Errors["rate_hz"]); + Assert.Equal("must be an integer", result.Errors["size"]); + Assert.Contains("not a parameter", result.Errors["bogus"]); + Assert.False(result.Errors.ContainsKey("port")); + } + + [Fact] + public void Resolve_AcceptsBoundariesAndRejectsJustOutside() + { + Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("1") }).IsValid); + Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("200") }).IsValid); + Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("0") }).IsValid); + Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("201") }).IsValid); + Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary { ["rate_hz"] = Json("1.5") }).IsValid); + Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary { ["direction"] = Json("\"sideways\"") }).IsValid); + Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary { ["tls"] = Json("1") }).IsValid); + } + + [Fact] + public void Kinds_AreUnknownWhenNotDeclared() + { + Assert.Null(Kinds.Find("nope")); + } + + [Theory] + [InlineData("continuous", true)] + [InlineData("once", true)] + [InlineData("every:60", true)] + [InlineData("every:59", false)] + [InlineData("every:", false)] + [InlineData("every:-5", false)] + [InlineData("hourly", false)] + public void Schedule_Validation(string schedule, bool valid) + { + Assert.Equal(valid, TaskSchedule.Validate(schedule).IsSuccess); + } + + [Fact] + public void TaskSet_RoundTripsThroughTheJsonContext() + { + var set = new TaskSet + { + Version = 7, + Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary { ["rate_hz"] = Json("10") } }], + Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }], + }; + string text = JsonSerializer.Serialize(set, MonitorJsonContext.Default.TaskSet); + TaskSet back = JsonSerializer.Deserialize(text, MonitorJsonContext.Default.TaskSet)!; + + Assert.Contains("\"id\":\"tsk_1\"", text); + Assert.Equal(7, back.Version); + Assert.Equal("tsk_1", back.Tasks[0].Id.Value); + Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64()); + Assert.Equal("prb_1", back.Peers[0].ProbeId.Value); + } + + [Fact] + public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults() + { + AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MonitorJsonContext.Default.AgentEnrollMeta)!; + Assert.Equal(7443, meta.ProbeTcpPort); + Assert.Equal(["udp_echo"], meta.Kinds); + } + + [Fact] + public void ErrorCodes_AreSnakeCase() + { + foreach (System.Reflection.FieldInfo field in typeof(MonitorErrors).GetFields()) + { + string value = ((ErrorCode)field.GetValue(null)!).Value; + Assert.Matches("^[a-z]+(_[a-z]+)*$", value); + } + } +} diff --git a/backend/shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj b/backend/shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj new file mode 100644 index 0000000..644ddc0 --- /dev/null +++ b/backend/shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj @@ -0,0 +1,14 @@ + + + Exe + false + + + + + + + + + + diff --git a/backend/shared/tests/Monitor.Protocol.Tests/RawChunkCodecTests.cs b/backend/shared/tests/Monitor.Protocol.Tests/RawChunkCodecTests.cs new file mode 100644 index 0000000..6d65147 --- /dev/null +++ b/backend/shared/tests/Monitor.Protocol.Tests/RawChunkCodecTests.cs @@ -0,0 +1,199 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Protocol.Tests; + +public sealed class RawChunkCodecTests +{ + private static readonly NodeId s_agent = new("agt_test"); + + private static RawChunk SampleChunk() + { + RawChunk chunk = ChunkFactory.Echo( + s_agent, "udp_echo", 1_700_000_000_000, 6, 100, + i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000)); + chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001")); + return chunk; + } + + [Fact] + public void RoundTrip_PreservesEveryValue_IncludingMissingOnes() + { + RawChunk original = SampleChunk(); + RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value; + + Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId); + Assert.Equal("udp_echo", decoded.Header.Kind); + Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs); + RawStream before = original.Find("msg")!; + RawStream after = decoded.Find("msg")!; + Assert.Equal(before.Count, after.Count); + foreach (RawColumn column in before.Columns) + { + RawColumn other = after.Find(column.Name)!; + Assert.Equal(column.Role, other.Role); + for (int row = 0; row < before.Count; row++) + { + Assert.Equal(column.IsValid(row), other.IsValid(row)); + if (column.IsValid(row)) + { + Assert.Equal(column.Values[row], other.Values[row]); + } + } + } + + Assert.False(after.Find("t2")!.IsValid(2)); + Assert.False(after.Find("t4")!.IsValid(4)); + } + + [Fact] + public void RoundTrip_HandlesTypeExtremes_AndNegativeDeltas() + { + var spec = new StreamSpec("x", [ + Col.U8("a"), Col.U16("b"), Col.U32("c"), Col.U64("d"), Col.Seq("e"), + new ColumnSpec("f", ColumnType.I32), new ColumnSpec("g", ColumnType.I64, ColumnEnc.Delta, true), + ]); + var builder = new StreamBuilder(spec) + .Add(byte.MaxValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, 5, int.MinValue, long.MinValue / 2) + .Add(0, 0, 0, 0, 2, int.MaxValue, null) + .Add(1, 1, 1, 1, 9, -1, long.MaxValue / 2); + var header = new RawChunkHeader { Kind = "x", AgentId = s_agent }; + RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(new RawChunk(header, [builder.Build()]))).Value; + RawStream s = decoded.Find("x")!; + + Assert.Equal(uint.MaxValue, s.Find("c")!.Values[0]); + Assert.Equal(long.MaxValue, s.Find("d")!.Values[0]); + Assert.Equal(new long[] { 5, 2, 9 }, s.Find("e")!.Values); + Assert.Equal(int.MinValue, s.Find("f")!.Values[0]); + Assert.Equal(long.MinValue / 2, s.Find("g")!.Values[0]); + Assert.False(s.Find("g")!.IsValid(1)); + Assert.Equal(long.MaxValue / 2, s.Find("g")!.Values[2]); + } + + [Fact] + public void GoldenBody_LayoutIsStable() + { + // 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。 + RawChunk chunk = SampleChunk(); + chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 }; + byte[] body = RawChunkCodec.EncodeBody(chunk); + uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body); + string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength)); + string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength)); + + Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header); + Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header); + Assert.Equal(GoldenColumns, columns); + } + + private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818"; + + [Fact] + public void Decode_RejectsBadMagicVersionCrcAndTruncation() + { + byte[] good = RawChunkCodec.Encode(SampleChunk()); + + byte[] badMagic = (byte[])good.Clone(); + badMagic[0] = (byte)'X'; + byte[] badVersion = (byte[])good.Clone(); + badVersion[4] = 99; + byte[] badCrc = (byte[])good.Clone(); + badCrc[^1] ^= 0xFF; + + Assert.Equal(MonitorErrors.RawCorrupt, RawChunkCodec.Decode(badMagic).Error.Code); + Assert.Contains("version", RawChunkCodec.Decode(badVersion).Error.Message); + Assert.Contains("crc", RawChunkCodec.Decode(badCrc).Error.Message); + Assert.False(RawChunkCodec.Decode(good.AsSpan(0, good.Length / 2)).IsSuccess); + Assert.False(RawChunkCodec.Decode([]).IsSuccess); + Assert.False(RawChunkCodec.Decode(good.AsSpan(0, 8)).IsSuccess); + } + + [Fact] + public void Decode_RejectsDecompressionBombs() + { + byte[] zeros = new byte[ProtocolLimits.MaxDecodedChunkBytes + 1024]; + using var compressed = new MemoryStream(); + using (var brotli = new BrotliStream(compressed, CompressionLevel.Fastest, leaveOpen: true)) + { + brotli.Write(zeros); + } + + byte[] payload = compressed.ToArray(); + Assert.True(payload.Length < 1_000_000, "the bomb must be small on the wire"); + Assert.False(RawChunkCodec.Decode(Wrap(payload)).IsSuccess); + } + + [Fact] + public void Decode_RejectsHeaderLengthBeyondData_AndTrailingBytes() + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 1_000_000); + Assert.Contains("header length", RawChunkCodec.Decode(Wrap(Brotli(body))).Error.Message); + + byte[] valid = RawChunkCodec.EncodeBody(SampleChunk()); + byte[] withTrailing = [.. valid, 1, 2, 3]; + Assert.Contains("trailing", RawChunkCodec.Decode(Wrap(Brotli(withTrailing))).Error.Message); + } + + [Fact] + public void Decode_RejectsOutOfRangeValues_AndTooManyRecords() + { + // u8 列里写入 300 + var spec = new StreamSpec("x", [new ColumnSpec("a", ColumnType.I32)]); + RawChunk chunk = new(new RawChunkHeader { Kind = "x", AgentId = s_agent }, [new StreamBuilder(spec).Add(300).Build()]); + byte[] body = RawChunkCodec.EncodeBody(chunk); + string json = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)BinaryPrimitives.ReadUInt32LittleEndian(body))); + string patched = json.Replace("\"type\":\"i32\"", "\"type\":\"u8\""); + byte[] patchedJson = System.Text.Encoding.UTF8.GetBytes(patched); + byte[] rebuilt = new byte[4 + patchedJson.Length + (body.Length - 4 - json.Length)]; + BinaryPrimitives.WriteUInt32LittleEndian(rebuilt, (uint)patchedJson.Length); + patchedJson.CopyTo(rebuilt, 4); + body.AsSpan(4 + json.Length).CopyTo(rebuilt.AsSpan(4 + patchedJson.Length)); + Assert.Contains("out of range", RawChunkCodec.Decode(Wrap(Brotli(rebuilt))).Error.Message); + + string tooMany = json.Replace("\"count\":1", $"\"count\":{ProtocolLimits.MaxRecordsPerChunk + 1}"); + byte[] tooManyJson = System.Text.Encoding.UTF8.GetBytes(tooMany); + byte[] big = new byte[4 + tooManyJson.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(big, (uint)tooManyJson.Length); + tooManyJson.CopyTo(big, 4); + Assert.Contains("invalid size", RawChunkCodec.Decode(Wrap(Brotli(big))).Error.Message); + } + + [Fact] + public void Encode_IsSmallerThanTheNaiveRepresentation() + { + // 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节 + RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000); + Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4); + } + + [Fact] + public void Crc32c_MatchesTheStandardCheckValue() + { + Assert.Equal(0xE3069283u, Crc32c.Compute("123456789"u8)); + } + + private static byte[] Brotli(byte[] data) + { + using var output = new MemoryStream(); + using (var brotli = new BrotliStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + brotli.Write(data); + } + + return output.ToArray(); + } + + private static byte[] Wrap(byte[] payload) + { + byte[] result = new byte[9 + payload.Length]; + "MNRC"u8.CopyTo(result); + result[4] = ProtocolVersion.Container; + BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload)); + payload.CopyTo(result, 9); + return result; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Accumulators/Accumulator.cs b/backend/src/Monitor.Backend.Analysis/Accumulators/Accumulator.cs new file mode 100644 index 0000000..06077e3 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Accumulators/Accumulator.cs @@ -0,0 +1,241 @@ +namespace Monitor.Backend.Analysis; + +public enum AccKind : byte +{ + Counter = 1, + Ratio = 2, + Sum = 3, + Min = 4, + Max = 5, + Histogram = 6, + RunLength = 7, + Series = 8, +} + +/// +/// 所有指标的中间状态只能是下面几种累加器之一。合并规则只写一次, +/// 存储、降采样、按时间范围聚合都通用处理。 +/// +public abstract class Accumulator +{ + public abstract AccKind Kind { get; } + + /// 加入一个观测值。含义由累加器决定:Counter 加 ,Ratio 的 (a, b) 是 (分子, 分母),Histogram 记录 a。 + public abstract void Add(double a, double b = 0); + + /// 合并同类型的另一个累加器(精确合并,不是平均)。 + public abstract void Merge(Accumulator other); + + public abstract Accumulator Clone(); + + /// 样本数,用于置信说明。 + public abstract ulong SampleCount { get; } + + public static Accumulator Create(AccKind kind) => kind switch + { + AccKind.Counter => new CounterAcc(), + AccKind.Ratio => new RatioAcc(), + AccKind.Sum => new SumAcc(), + AccKind.Min => new MinAcc(), + AccKind.Max => new MaxAcc(), + AccKind.Histogram => new HistogramAcc(), + AccKind.RunLength => new RunLengthAcc(), + AccKind.Series => new SeriesAcc(), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + protected T Cast(Accumulator other) + where T : Accumulator => + other as T ?? throw new InvalidOperationException($"cannot merge {other.Kind} into {Kind}"); +} + +public sealed class CounterAcc : Accumulator +{ + public ulong Value { get; set; } + + public override AccKind Kind => AccKind.Counter; + + public override ulong SampleCount => Value; + + public override void Add(double a, double b = 0) => Value += (ulong)Math.Max(0, a); + + public override void Merge(Accumulator other) => Value += Cast(other).Value; + + public override Accumulator Clone() => new CounterAcc { Value = Value }; +} + +public sealed class RatioAcc : Accumulator +{ + public ulong Numerator { get; set; } + + public ulong Denominator { get; set; } + + public override AccKind Kind => AccKind.Ratio; + + public override ulong SampleCount => Denominator; + + /// 百分比(0–100);没有样本时为 null。 + public double? Percent => Denominator == 0 ? null : 100.0 * Numerator / Denominator; + + public override void Add(double a, double b = 0) + { + Numerator += (ulong)Math.Max(0, a); + Denominator += (ulong)Math.Max(0, b); + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + Numerator += o.Numerator; + Denominator += o.Denominator; + } + + public override Accumulator Clone() => new RatioAcc { Numerator = Numerator, Denominator = Denominator }; +} + +public sealed class SumAcc : Accumulator +{ + public double Sum { get; set; } + + public ulong Count { get; set; } + + public override AccKind Kind => AccKind.Sum; + + public override ulong SampleCount => Count; + + public double? Mean => Count == 0 ? null : Sum / Count; + + public override void Add(double a, double b = 0) + { + Sum += a; + Count++; + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + Sum += o.Sum; + Count += o.Count; + } + + public override Accumulator Clone() => new SumAcc { Sum = Sum, Count = Count }; +} + +public sealed class MinAcc : Accumulator +{ + public double Value { get; set; } = double.PositiveInfinity; + + public ulong Count { get; set; } + + public override AccKind Kind => AccKind.Min; + + public override ulong SampleCount => Count; + + public double? Result => Count == 0 ? null : Value; + + public override void Add(double a, double b = 0) + { + Value = Math.Min(Value, a); + Count++; + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + Value = Math.Min(Value, o.Value); + Count += o.Count; + } + + public override Accumulator Clone() => new MinAcc { Value = Value, Count = Count }; +} + +public sealed class MaxAcc : Accumulator +{ + public double Value { get; set; } = double.NegativeInfinity; + + public ulong Count { get; set; } + + public override AccKind Kind => AccKind.Max; + + public override ulong SampleCount => Count; + + public double? Result => Count == 0 ? null : Value; + + public override void Add(double a, double b = 0) + { + Value = Math.Max(Value, a); + Count++; + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + Value = Math.Max(Value, o.Value); + Count += o.Count; + } + + public override Accumulator Clone() => new MaxAcc { Value = Value, Count = Count }; +} + +/// 连续段长度(例如连续丢包):只保留次数、总和和最大值,合并是精确的。 +public sealed class RunLengthAcc : Accumulator +{ + public ulong Runs { get; set; } + + public double Total { get; set; } + + public double Max { get; set; } + + public override AccKind Kind => AccKind.RunLength; + + public override ulong SampleCount => Runs; + + public double? Mean => Runs == 0 ? null : Total / Runs; + + public override void Add(double a, double b = 0) + { + Runs++; + Total += a; + Max = Math.Max(Max, a); + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + Runs += o.Runs; + Total += o.Total; + Max = Math.Max(Max, o.Max); + } + + public override Accumulator Clone() => new RunLengthAcc { Runs = Runs, Total = Total, Max = Max }; +} + +/// 固定分辨率的时间序列(仅用于测试结果):Add(a = 序号, b = 值),合并为拼接。 +public sealed class SeriesAcc : Accumulator +{ + public List Points { get; } = []; + + public override AccKind Kind => AccKind.Series; + + public override ulong SampleCount => (ulong)Points.Count; + + public override void Add(double a, double b = 0) + { + int index = (int)a; + while (Points.Count <= index) + { + Points.Add(double.NaN); + } + + Points[index] = b; + } + + public override void Merge(Accumulator other) => Points.AddRange(Cast(other).Points); + + public override Accumulator Clone() + { + var copy = new SeriesAcc(); + copy.Points.AddRange(Points); + return copy; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Accumulators/AccumulatorCodec.cs b/backend/src/Monitor.Backend.Analysis/Accumulators/AccumulatorCodec.cs new file mode 100644 index 0000000..b0c987a --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Accumulators/AccumulatorCodec.cs @@ -0,0 +1,311 @@ +using System.Buffers.Binary; +using System.Text; +using Fleet.Core; + +namespace Monitor.Backend.Analysis; + +/// 一个时间桶里所有指标的累加器:指标 id → 累加器。 +public sealed class MetricSet +{ + private readonly Dictionary _items = new(StringComparer.Ordinal); + + public IReadOnlyDictionary Items => _items; + + public int Count => _items.Count; + + public Accumulator GetOrAdd(string metricId, AccKind kind) + { + if (!_items.TryGetValue(metricId, out Accumulator? acc)) + { + acc = Accumulator.Create(kind); + _items[metricId] = acc; + } + + return acc; + } + + public bool TryGet(string metricId, out Accumulator acc) => _items.TryGetValue(metricId, out acc!); + + /// 逐个指标精确合并。 + public void Merge(MetricSet other) + { + foreach ((string id, Accumulator acc) in other._items) + { + if (_items.TryGetValue(id, out Accumulator? mine)) + { + if (mine.Kind == acc.Kind) + { + mine.Merge(acc); + } + } + else + { + _items[id] = acc.Clone(); + } + } + } + + public void Set(string metricId, Accumulator acc) => _items[metricId] = acc; + + public MetricSet Clone() + { + var copy = new MetricSet(); + foreach ((string id, Accumulator acc) in _items) + { + copy._items[id] = acc.Clone(); + } + + return copy; + } +} + +/// +/// 累加器的二进制编码:存储层只把它当作不透明的字节。 +/// varint 数量 | { varint id 长度 | id | u8 kind | varint 长度 | 状态 }… +/// +public static class AccumulatorCodec +{ + public static byte[] Encode(MetricSet set) + { + using var stream = new MemoryStream(); + VarInt.Write(stream, (ulong)set.Count); + foreach ((string id, Accumulator acc) in set.Items.OrderBy(i => i.Key, StringComparer.Ordinal)) + { + byte[] idBytes = Encoding.UTF8.GetBytes(id); + VarInt.Write(stream, (ulong)idBytes.Length); + stream.Write(idBytes); + stream.WriteByte((byte)acc.Kind); + byte[] state = EncodeState(acc); + VarInt.Write(stream, (ulong)state.Length); + stream.Write(state); + } + + return stream.ToArray(); + } + + public static Result Decode(ReadOnlySpan data) + { + var set = new MetricSet(); + if (!VarInt.TryRead(data, out ulong count, out int used)) + { + return Bad(); + } + + data = data[used..]; + for (ulong i = 0; i < count; i++) + { + if (!VarInt.TryRead(data, out ulong idLength, out used) || (ulong)(data.Length - used) < idLength) + { + return Bad(); + } + + data = data[used..]; + string id = Encoding.UTF8.GetString(data[..(int)idLength]); + data = data[(int)idLength..]; + if (data.Length < 1) + { + return Bad(); + } + + var kind = (AccKind)data[0]; + data = data[1..]; + if (!VarInt.TryRead(data, out ulong stateLength, out used) || (ulong)(data.Length - used) < stateLength) + { + return Bad(); + } + + data = data[used..]; + Accumulator? acc = DecodeState(kind, data[..(int)stateLength]); + if (acc is null) + { + return Bad(); + } + + set.Set(id, acc); + data = data[(int)stateLength..]; + } + + return Result.Ok(set); + } + + private static Result Bad() => + Result.Fail(FleetErrors.Internal, "accumulator data is corrupt"); + + public static byte[] EncodeState(Accumulator acc) + { + using var stream = new MemoryStream(); + switch (acc) + { + case CounterAcc c: + VarInt.Write(stream, c.Value); + break; + case RatioAcc r: + VarInt.Write(stream, r.Numerator); + VarInt.Write(stream, r.Denominator); + break; + case SumAcc s: + WriteDouble(stream, s.Sum); + VarInt.Write(stream, s.Count); + break; + case MinAcc m: + WriteDouble(stream, m.Value); + VarInt.Write(stream, m.Count); + break; + case MaxAcc x: + WriteDouble(stream, x.Value); + VarInt.Write(stream, x.Count); + break; + case RunLengthAcc rl: + VarInt.Write(stream, rl.Runs); + WriteDouble(stream, rl.Total); + WriteDouble(stream, rl.Max); + break; + case HistogramAcc h: + VarInt.Write(stream, h.Count); + WriteDouble(stream, h.Sum); + WriteDouble(stream, h.Min); + WriteDouble(stream, h.Max); + VarInt.Write(stream, (ulong)h.NonEmptyBuckets); + int previous = 0; + foreach ((int bucket, ulong n) in h.Buckets()) + { + VarInt.Write(stream, (ulong)(bucket - previous)); + VarInt.Write(stream, n); + previous = bucket; + } + + break; + case SeriesAcc se: + VarInt.Write(stream, (ulong)se.Points.Count); + foreach (double p in se.Points) + { + WriteDouble(stream, p); + } + + break; + default: + throw new InvalidOperationException("unknown accumulator"); + } + + return stream.ToArray(); + } + + public static Accumulator? DecodeState(AccKind kind, ReadOnlySpan data) + { + var reader = new Reader(data); + switch (kind) + { + case AccKind.Counter: + return reader.ReadVarInt(out ulong v) ? new CounterAcc { Value = v } : null; + case AccKind.Ratio: + return reader.ReadVarInt(out ulong num) && reader.ReadVarInt(out ulong den) + ? new RatioAcc { Numerator = num, Denominator = den } + : null; + case AccKind.Sum: + return reader.ReadDouble(out double sum) && reader.ReadVarInt(out ulong sn) ? new SumAcc { Sum = sum, Count = sn } : null; + case AccKind.Min: + return reader.ReadDouble(out double min) && reader.ReadVarInt(out ulong mn) ? new MinAcc { Value = min, Count = mn } : null; + case AccKind.Max: + return reader.ReadDouble(out double max) && reader.ReadVarInt(out ulong xn) ? new MaxAcc { Value = max, Count = xn } : null; + case AccKind.RunLength: + return reader.ReadVarInt(out ulong runs) && reader.ReadDouble(out double total) && reader.ReadDouble(out double rmax) + ? new RunLengthAcc { Runs = runs, Total = total, Max = rmax } + : null; + case AccKind.Histogram: + { + if (!reader.ReadVarInt(out ulong count) || !reader.ReadDouble(out double hsum) || !reader.ReadDouble(out double hmin) + || !reader.ReadDouble(out double hmax) || !reader.ReadVarInt(out ulong buckets) || buckets > (ulong)HistogramAcc.BucketCount) + { + return null; + } + + var list = new List<(int, ulong)>((int)buckets); + int bucket = 0; + for (ulong i = 0; i < buckets; i++) + { + if (!reader.ReadVarInt(out ulong delta) || !reader.ReadVarInt(out ulong n)) + { + return null; + } + + bucket += (int)delta; + if (bucket >= HistogramAcc.BucketCount) + { + return null; + } + + list.Add((bucket, n)); + } + + var hist = new HistogramAcc(); + hist.Restore(list, count, hsum, hmin, hmax); + return hist; + } + + case AccKind.Series: + { + if (!reader.ReadVarInt(out ulong n) || n > 10_000_000) + { + return null; + } + + var series = new SeriesAcc(); + for (ulong i = 0; i < n; i++) + { + if (!reader.ReadDouble(out double p)) + { + return null; + } + + series.Points.Add(p); + } + + return series; + } + + default: + return null; + } + } + + private static void WriteDouble(MemoryStream stream, double value) + { + Span bytes = stackalloc byte[8]; + BinaryPrimitives.WriteDoubleLittleEndian(bytes, value); + stream.Write(bytes); + } + + private ref struct Reader + { + private ReadOnlySpan _data; + + public Reader(ReadOnlySpan data) + { + _data = data; + } + + public bool ReadVarInt(out ulong value) + { + if (!VarInt.TryRead(_data, out value, out int used)) + { + return false; + } + + _data = _data[used..]; + return true; + } + + public bool ReadDouble(out double value) + { + if (_data.Length < 8) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadDoubleLittleEndian(_data); + _data = _data[8..]; + return true; + } + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Accumulators/HistogramAcc.cs b/backend/src/Monitor.Backend.Analysis/Accumulators/HistogramAcc.cs new file mode 100644 index 0000000..4f58669 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Accumulators/HistogramAcc.cs @@ -0,0 +1,164 @@ +namespace Monitor.Backend.Analysis; + +/// +/// 固定对数桶直方图:100 µs ~ 30 s,每 2 倍 8 个桶(相对误差 ≤ 4.5%)。 +/// 稀疏存储(只保留非空桶),因为 1 秒热层里每个桶只有几个样本。数值单位是毫秒。 +/// +public sealed class HistogramAcc : Accumulator +{ + public const double MinValueMs = 0.1; + public const double MaxValueMs = 30_000; + public const int BucketsPerOctave = 8; + + /// 桶 0 是下溢桶 [0, 0.1),桶 i≥1 是 [0.1·2^((i−1)/8), 0.1·2^(i/8))。 + public static readonly int BucketCount = 1 + (int)Math.Ceiling(BucketsPerOctave * Math.Log2(MaxValueMs / MinValueMs)); + + private int[] _index = new int[4]; + private ulong[] _counts = new ulong[4]; + private int _used; + + public override AccKind Kind => AccKind.Histogram; + + public ulong Count { get; private set; } + + public double Sum { get; private set; } + + public double Min { get; private set; } = double.PositiveInfinity; + + public double Max { get; private set; } = double.NegativeInfinity; + + public override ulong SampleCount => Count; + + public double? Mean => Count == 0 ? null : Sum / Count; + + public int NonEmptyBuckets => _used; + + public static int BucketOf(double valueMs) + { + if (!(valueMs >= MinValueMs)) + { + return 0; + } + + int bucket = 1 + (int)Math.Floor(BucketsPerOctave * Math.Log2(valueMs / MinValueMs)); + return Math.Min(bucket, BucketCount - 1); + } + + public static double LowerBound(int bucket) => bucket == 0 ? 0 : MinValueMs * Math.Pow(2, (bucket - 1) / (double)BucketsPerOctave); + + public static double UpperBound(int bucket) => MinValueMs * Math.Pow(2, bucket / (double)BucketsPerOctave); + + public override void Add(double a, double b = 0) + { + if (double.IsNaN(a) || a < 0) + { + return; + } + + AddToBucket(BucketOf(a), 1); + Count++; + Sum += a; + Min = Math.Min(Min, a); + Max = Math.Max(Max, a); + } + + public override void Merge(Accumulator other) + { + var o = Cast(other); + for (int i = 0; i < o._used; i++) + { + AddToBucket(o._index[i], o._counts[i]); + } + + Count += o.Count; + Sum += o.Sum; + Min = Math.Min(Min, o.Min); + Max = Math.Max(Max, o.Max); + } + + public override Accumulator Clone() + { + var copy = new HistogramAcc + { + _index = (int[])_index.Clone(), + _counts = (ulong[])_counts.Clone(), + _used = _used, + Count = Count, + Sum = Sum, + Min = Min, + Max = Max, + }; + return copy; + } + + /// 分位数:取累计计数首次达到 q·N 的桶的几何中点,并限制在实际的最小/最大值之内。 + public double? Quantile(double q) + { + if (Count == 0) + { + return null; + } + + double target = Math.Clamp(q, 0, 1) * Count; + ulong cumulative = 0; + for (int i = 0; i < _used; i++) + { + cumulative += _counts[i]; + if (cumulative >= target && cumulative > 0) + { + int bucket = _index[i]; + double estimate = bucket == 0 ? Min : Math.Sqrt(LowerBound(bucket) * UpperBound(bucket)); + return Math.Clamp(estimate, Min, Max); + } + } + + return Max; + } + + /// 按桶遍历(桶号升序)。 + public IEnumerable<(int Bucket, ulong Count)> Buckets() + { + for (int i = 0; i < _used; i++) + { + yield return (_index[i], _counts[i]); + } + } + + /// 用于反序列化:直接设置状态。 + internal void Restore(IReadOnlyList<(int Bucket, ulong Count)> buckets, ulong count, double sum, double min, double max) + { + _used = 0; + foreach ((int bucket, ulong n) in buckets) + { + AddToBucket(bucket, n); + } + + Count = count; + Sum = sum; + Min = min; + Max = max; + } + + private void AddToBucket(int bucket, ulong n) + { + int pos = Array.BinarySearch(_index, 0, _used, bucket); + if (pos >= 0) + { + _counts[pos] += n; + return; + } + + pos = ~pos; + if (_used == _index.Length) + { + Array.Resize(ref _index, _used * 2); + Array.Resize(ref _counts, _used * 2); + } + + Array.Copy(_index, pos, _index, pos + 1, _used - pos); + Array.Copy(_counts, pos, _counts, pos + 1, _used - pos); + _index[pos] = bucket; + _counts[pos] = n; + _used++; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Clock/ClockDriftFit.cs b/backend/src/Monitor.Backend.Analysis/Clock/ClockDriftFit.cs new file mode 100644 index 0000000..838ffac --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Clock/ClockDriftFit.cs @@ -0,0 +1,79 @@ +namespace Monitor.Backend.Analysis; + +/// +/// 估计两端的时钟偏移和线性漂移:单向时延 x(t) = 偏移 + 漂移·t + 真实的时延变化。 +/// 最小二乘拟合出直线,残差再减去残差的低分位(时延“地板”),就是去除偏移和漂移之后的抖动。 +/// +public static class ClockDriftFit +{ + public const int MinPoints = 8; + + /// 拟合 y = a + b·x,x 先居中以保证数值稳定。样本不足或 x 全部相同时返回 false。 + public static bool TryFit(ReadOnlySpan x, ReadOnlySpan y, out double intercept, out double slope, out double xMean) + { + intercept = 0; + slope = 0; + xMean = 0; + int n = x.Length; + if (n < 2) + { + return false; + } + + double sumX = 0; + double sumY = 0; + for (int i = 0; i < n; i++) + { + sumX += x[i]; + sumY += y[i]; + } + + xMean = sumX / n; + double yMean = sumY / n; + double sxx = 0; + double sxy = 0; + for (int i = 0; i < n; i++) + { + double dx = x[i] - xMean; + sxx += dx * dx; + sxy += dx * (y[i] - yMean); + } + + if (sxx <= 0) + { + return false; + } + + slope = sxy / sxx; + intercept = yMean - (slope * xMean); + return true; + } + + /// + /// 去除偏移和漂移后的时延变化(非负)。样本少于 时返回 null(不产生观测值)。 + /// + public static double[]? Jitter(ReadOnlySpan timesUs, ReadOnlySpan delaysUs) + { + int n = timesUs.Length; + if (n < MinPoints || !TryFit(timesUs, delaysUs, out double intercept, out double slope, out double xMean)) + { + return null; + } + + double[] residual = new double[n]; + for (int i = 0; i < n; i++) + { + residual[i] = delaysUs[i] - (intercept + (slope * (timesUs[i] - xMean))); + } + + double[] sorted = (double[])residual.Clone(); + Array.Sort(sorted); + double floor = sorted[(int)(0.05 * (n - 1))]; + for (int i = 0; i < n; i++) + { + residual[i] = Math.Max(0, residual[i] - floor); + } + + return residual; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Engine/ChangePoint.cs b/backend/src/Monitor.Backend.Analysis/Engine/ChangePoint.cs new file mode 100644 index 0000000..e5e3e67 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Engine/ChangePoint.cs @@ -0,0 +1,75 @@ +namespace Monitor.Backend.Analysis; + +/// 变点检测(均值漂移):找到使前后两段均值差最显著的位置,用来判断 QoS 从什么时候开始限速。 +public static class ChangePoint +{ + /// + /// 返回变点的下标(后一段的第一个点),没有显著变点时返回 -1。 + /// 显著性:前后两段的均值差 ≥ ,并且每段至少 个点。 + /// NaN(缺失)的点会被忽略。 + /// + public static int Detect(IReadOnlyList values, double minShift, int minSegment = 3) + { + int n = values.Count; + if (n < 2 * minSegment) + { + return -1; + } + + double[] prefix = new double[n + 1]; + int[] counts = new int[n + 1]; + for (int i = 0; i < n; i++) + { + bool valid = !double.IsNaN(values[i]); + prefix[i + 1] = prefix[i] + (valid ? values[i] : 0); + counts[i + 1] = counts[i] + (valid ? 1 : 0); + } + + int best = -1; + double bestScore = 0; + for (int k = minSegment; k <= n - minSegment; k++) + { + int leftN = counts[k]; + int rightN = counts[n] - counts[k]; + if (leftN < minSegment || rightN < minSegment) + { + continue; + } + + double shift = Math.Abs(((prefix[n] - prefix[k]) / rightN) - (prefix[k] / leftN)); + double score = shift * Math.Sqrt((double)leftN * rightN / (leftN + rightN)); + if (shift >= minShift && score > bestScore) + { + bestScore = score; + best = k; + } + } + + return best; + } + + /// + /// 第一次显著跳变:第一个比之前所有点的最大值还高出 的点的下标;没有则返回 -1。 + /// 用于码率阶梯:“从哪一档开始限速”是第一次出现丢包跳变的那一档,而不是均值差最大的分割点。 + /// + public static int FirstJump(IReadOnlyList values, double minJump) + { + double max = double.NegativeInfinity; + for (int i = 0; i < values.Count; i++) + { + if (double.IsNaN(values[i])) + { + continue; + } + + if (i > 0 && !double.IsNegativeInfinity(max) && values[i] - max >= minJump) + { + return i; + } + + max = Math.Max(max, values[i]); + } + + return -1; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Engine/ChunkView.cs b/backend/src/Monitor.Backend.Analysis/Engine/ChunkView.cs new file mode 100644 index 0000000..ad7d536 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Engine/ChunkView.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +/// 算子把观测值交给这里;窗口评估器负责按时间分桶。 +public interface IObservationSink +{ + void Emit(long utcMs, double a, double b = 0); +} + +/// +/// 原始块的列视图:按列名读取,并把 Probe / Agent 各自的单调时钟换算成 UTC。 +/// Agent 时钟 → UTC 使用块头的锚点;Probe 时钟 → Agent 时钟使用估计的偏移。 +/// +public sealed class ChunkView +{ + public ChunkView(RawChunk chunk) + { + Chunk = chunk; + } + + public RawChunk Chunk { get; } + + public RawChunkHeader Header => Chunk.Header; + + public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.AgentMonoUs, 1000); + + public long ProbeToUtcMs(long probeUs) => AgentToUtcMs(probeUs + Header.Clock.ProbeOffsetUs); + + public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsProbe ? ProbeToUtcMs(value) : AgentToUtcMs(value); + + public double ParamNumber(string name, double fallback) => + Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback; + + private static long FloorDiv(long a, long b) => (a / b) - ((a % b != 0 && (a < 0) != (b < 0)) ? 1 : 0); +} diff --git a/backend/src/Monitor.Backend.Analysis/Engine/MetricDef.cs b/backend/src/Monitor.Backend.Analysis/Engine/MetricDef.cs new file mode 100644 index 0000000..d257f0e --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Engine/MetricDef.cs @@ -0,0 +1,206 @@ +using System.Security.Cryptography; +using System.Text; +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +public enum Unit +{ + None, + Ms, + Pct, + Count, + PerMin, + PerHour, + Mbps, + Packets, + Score, +} + +public enum Better +{ + None, + Lower, + Higher, +} + +public enum Stat +{ + P50, + P90, + P95, + P99, + Mean, + Max, + Min, + Pct, + Count, + PerMin, + PerHour, + P999, + + /// 长尾比:p99 / p50(从同一个直方图导出,所以窗口合并之后仍然精确)。 + Tail, +} + +/// 算子:把一个流里的原始行变成观测值。算子都是纯函数,每个都有边界用例测试。 +public abstract class MetricOp +{ + /// 算子的名称和参数,参与指标指纹;改变语义时必须同时改变它。 + public abstract string Signature { get; } + + public abstract void Evaluate(ChunkView view, RawStream stream, IObservationSink sink); +} + +/// 导出值:指标id.统计量,例如 rtt.p95。已发布的 id 不能改名,只能新增。 +public sealed record MetricExport(string Id, Stat Stat, Unit Unit, double? Good = null, double? Poor = null); + +/// 一行声明 = 一个指标:输入(kind.stream)+ 算子 + 累加器 + 导出。 +public sealed class MetricDef +{ + public MetricDef(string id, AccKind kind, IReadOnlyList from, MetricOp op, string label, string description, Better better, IReadOnlyList exports, int version = 1) + { + Id = id; + Kind = kind; + From = from; + Op = op; + Label = label; + Description = description; + Better = better; + Exports = exports; + Version = version; + Fingerprint = ComputeFingerprint(); + } + + public string Id { get; } + + public AccKind Kind { get; } + + /// kind.stream;* 表示所有具有该 stream 的 Kind。 + public IReadOnlyList From { get; } + + public MetricOp Op { get; } + + public string Label { get; } + + public string Description { get; } + + public Better Better { get; } + + public IReadOnlyList Exports { get; } + + public int Version { get; } + + /// 由声明内容和算子签名计算得出,变化时触发回填。 + public string Fingerprint { get; } + + public bool Matches(string kind, string stream) + { + foreach (string source in From) + { + int dot = source.IndexOf('.', StringComparison.Ordinal); + if (source.AsSpan(dot + 1).SequenceEqual(stream) && (source.AsSpan(0, dot).SequenceEqual("*") || source.AsSpan(0, dot).SequenceEqual(kind))) + { + return true; + } + } + + return false; + } + + /// 从累加器导出一个值;没有样本时返回 null。 用于速率类导出。 + public static double? Export(Accumulator acc, MetricExport export, double durationSeconds) + { + switch (acc) + { + case HistogramAcc h: + return export.Stat switch + { + Stat.P50 => h.Quantile(0.5), + Stat.P90 => h.Quantile(0.9), + Stat.P95 => h.Quantile(0.95), + Stat.P99 => h.Quantile(0.99), + Stat.P999 => h.Quantile(0.999), + Stat.Tail => h.Quantile(0.5) is > 0 and double p50 && h.Quantile(0.99) is { } p99 ? p99 / p50 : null, + Stat.Mean => h.Mean, + Stat.Max => h.Count == 0 ? null : h.Max, + Stat.Min => h.Count == 0 ? null : h.Min, + _ => null, + }; + case RatioAcc r: + return export.Stat == Stat.Pct ? r.Percent : null; + case CounterAcc c: + return export.Stat switch + { + Stat.Count => c.Value, + Stat.PerMin => durationSeconds > 0 ? c.Value / (durationSeconds / 60.0) : null, + Stat.PerHour => durationSeconds > 0 ? c.Value / (durationSeconds / 3600.0) : null, + _ => null, + }; + case MaxAcc m: + return m.Result; + case MinAcc n: + return n.Result; + case SumAcc s: + return export.Stat == Stat.Mean ? s.Mean : s.Count == 0 ? null : s.Sum; + case RunLengthAcc rl: + return export.Stat switch + { + Stat.Max => rl.Runs == 0 ? null : rl.Max, + Stat.Mean => rl.Mean, + _ => null, + }; + default: + return null; + } + } + + private string ComputeFingerprint() + { + var text = new StringBuilder() + .Append('v').Append(Version).Append('|').Append(Id).Append('|').Append(Kind).Append('|') + .AppendJoin(',', From).Append('|').Append(Op.Signature).Append('|'); + foreach (MetricExport e in Exports) + { + text.Append(e.Id).Append(':').Append(e.Stat).Append(';'); + } + + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString())))[..16]; + } +} + +/// 声明指标的简写。 +public static class Metric +{ + public static MetricDef Histogram(string id, string[] from, MetricOp op, string label, string description, Unit unit, Stat[] exports, Better better = Better.Lower, double? good = null, double? poor = null) => + new(id, AccKind.Histogram, from, op, label, description, better, [.. exports.Select(s => s == Stat.Tail ? new MetricExport(id + "." + StatName(s), s, Unit.None) : new MetricExport(id + "." + StatName(s), s, unit, good, poor))]); + + public static MetricDef Ratio(string id, string[] from, MetricOp op, string label, string description, Better better = Better.Lower, double? good = null, double? poor = null, string? exportId = null) => + new(id, AccKind.Ratio, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Pct, Unit.Pct, good, poor)]); + + public static MetricDef Counter(string id, string[] from, MetricOp op, string label, string description, MetricExport export, Better better = Better.Lower) => + new(id, AccKind.Counter, from, op, label, description, better, [export]); + + public static MetricDef Max(string id, string[] from, MetricOp op, string label, string description, Unit unit, string? exportId = null, Better better = Better.Lower, double? good = null, double? poor = null) => + new(id, AccKind.Max, from, op, label, description, better, [new MetricExport(exportId ?? id, Stat.Max, unit, good, poor)]); + + public static MetricDef RunLength(string id, string[] from, MetricOp op, string label, string description, params MetricExport[] exports) => + new(id, AccKind.RunLength, from, op, label, description, Better.Lower, exports); + + public static string StatName(Stat stat) => stat switch + { + Stat.P50 => "p50", + Stat.P90 => "p90", + Stat.P95 => "p95", + Stat.P99 => "p99", + Stat.Mean => "mean", + Stat.Max => "max", + Stat.Min => "min", + Stat.Pct => "pct", + Stat.Count => "count", + Stat.PerMin => "per_min", + Stat.P999 => "p999", + Stat.Tail => "tail", + _ => "per_h", + }; +} diff --git a/backend/src/Monitor.Backend.Analysis/Engine/TestEvaluator.cs b/backend/src/Monitor.Backend.Analysis/Engine/TestEvaluator.cs new file mode 100644 index 0000000..14fede2 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Engine/TestEvaluator.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +public sealed class TestStepResult +{ + public int Index { get; set; } + + public double TargetMbps { get; set; } + + public double LossPct { get; set; } + + public double? JitterP95Ms { get; set; } + + public double DeliveredMbps { get; set; } +} + +public sealed class TestResult +{ + /// 导出值:与持续指标同一套 id,另有 hy2.* 等测试专用的值。 + public Dictionary Metrics { get; } = new(StringComparer.Ordinal); + + /// 逐步或逐秒的曲线,例如码率–丢包曲线。 + public Dictionary Series { get; } = new(StringComparer.Ordinal); + + public List Steps { get; } = []; + + public List Caveats { get; } = []; +} + +/// +/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。 +/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。 +/// +public static class TestEvaluator +{ + public const double SustainableLossPct = 5; + public const double SustainableLossLoosePct = 10; + public const double QosShiftPct = 5; + + public static TestResult Evaluate(string plan, IReadOnlyList chunks) + { + var result = new TestResult(); + EvaluateMetrics(chunks, result); + if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id)) + { + EvaluateLadder(chunks, result); + } + + if (chunks.Any(c => c.Header.Partial)) + { + result.Caveats.Add("partial_data"); + } + + if (chunks.Any(c => c.Header.Port is not (0 or 443))) + { + result.Caveats.Add("port_differs_from_service"); + } + + _ = plan; + return result; + } + + private static void EvaluateMetrics(IReadOnlyList chunks, TestResult result) + { + var buckets = new SortedDictionary(); + long span = 24L * 3600 * 1000; + foreach (RawChunk chunk in chunks) + { + WindowEvaluator.Default.Evaluate(chunk, span, buckets); + } + + var merged = new MetricSet(); + foreach (MetricSet set in buckets.Values) + { + merged.Merge(set); + } + + double seconds = chunks.Sum(c => c.Header.DurationMs) / 1000.0; + foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports()) + { + if (merged.TryGet(def.Id, out Accumulator acc) && MetricDef.Export(acc, export, seconds) is { } value) + { + result.Metrics[export.Id] = value; + } + } + } + + private static void EvaluateLadder(IReadOnlyList chunks, TestResult result) + { + var steps = new List(); + var perSecond = new SortedDictionary(); + int index = 0; + + // 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块): + // 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。 + foreach (IGrouping phase in chunks.Where(c => c.Header.Kind == Kinds.UdpTrain.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key)) + { + RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)]; + if (parts.Length == 0) + { + continue; + } + + var merged = new MetricSet(); + long bytes = 0; + foreach (RawChunk chunk in parts) + { + var buckets = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet { "train.loss", "train.jitter", "train.rate" }); + foreach (MetricSet set in buckets.Values) + { + merged.Merge(set); + } + + bytes += ReceivedBytes(chunk.Find("pkt")!); + + var seconds = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet { "train.rate" }); + foreach ((long start, MetricSet set) in seconds) + { + if (set.TryGet("train.rate", out Accumulator rate) && rate is HistogramAcc rh && rh.Mean is { } mean) + { + perSecond[start] = mean; + } + } + } + + var step = new TestStepResult + { + Index = index++, + TargetMbps = TargetMbps(parts[0].Header), + }; + if (merged.TryGet("train.loss", out Accumulator loss) && loss is RatioAcc ratio) + { + step.LossPct = ratio.Percent ?? 0; + } + + if (merged.TryGet("train.jitter", out Accumulator jitter) && jitter is HistogramAcc jh) + { + step.JitterP95Ms = jh.Quantile(0.95); + } + + // 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集) + double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0); + step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0; + steps.Add(step); + } + + if (steps.Count == 0) + { + return; + } + + result.Steps.AddRange(steps); + result.Series["step_mbps"] = [.. steps.Select(s => s.TargetMbps)]; + result.Series["loss_pct"] = [.. steps.Select(s => s.LossPct)]; + result.Series["delivered_mbps"] = [.. steps.Select(s => s.DeliveredMbps)]; + result.Series["jitter_p95_ms"] = [.. steps.Select(s => s.JitterP95Ms ?? double.NaN)]; + + TestStepResult? sustainable = steps.Where(s => s.LossPct <= SustainableLossPct).MaxBy(s => s.TargetMbps); + TestStepResult? loose = steps.Where(s => s.LossPct <= SustainableLossLoosePct).MaxBy(s => s.TargetMbps); + result.Metrics["hy2.sustainable_mbps"] = sustainable?.TargetMbps ?? 0; + result.Metrics["hy2.sustainable10_mbps"] = loose?.TargetMbps ?? 0; + result.Metrics["hy2.effective_mbps"] = steps.Max(s => s.DeliveredMbps); + + // 限速开始:丢包率随码率阶梯出现显著跳变的那一档。 + int onset = ChangePoint.FirstJump([.. steps.Select(s => s.LossPct)], QosShiftPct); + if (onset >= 0) + { + result.Metrics["hy2.qos_onset_mbps"] = steps[onset].TargetMbps; + } + + if (perSecond.Count >= 6) + { + double[] rates = [.. perSecond.Values]; + double peak = rates.Max(); + int second = ChangePoint.Detect(rates, Math.Max(1, peak * 0.2)); + if (second >= 0 && rates[second..].Average() < rates[..second].Average()) + { + result.Metrics["hy2.qos_onset_s"] = second; + } + + result.Series["delivered_mbps_per_s"] = rates; + } + } + + private static double TargetMbps(RawChunkHeader header) => + header.Params.TryGetValue("mbps", out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0; + + /// + /// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。 + /// 没有时间戳就返回 null。 + /// + private static double? SendSpanSeconds(RawChunk[] parts) + { + long min = long.MaxValue; + long max = long.MinValue; + long count = 0; + foreach (RawChunk chunk in parts) + { + RawStream? pkt = chunk.Find("pkt"); + RawColumn? send = pkt?.Find("t_send"); + if (pkt is null || send is null) + { + continue; + } + + ChunkClock clock = chunk.Header.Clock; + for (int i = 0; i < pkt.Count; i++) + { + if (!send.IsValid(i)) + { + continue; + } + + long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs); + min = Math.Min(min, utcUs); + max = Math.Max(max, utcUs); + count++; + } + } + + if (count < 2 || max <= min) + { + return null; + } + + return (max - min) / 1_000_000.0 * count / (count - 1); + } + + private static long ReceivedBytes(RawStream pkt) + { + RawColumn? recv = pkt.Find("t_recv"); + RawColumn? size = pkt.Find("size"); + if (recv is null || size is null) + { + return 0; + } + + long bytes = 0; + for (int i = 0; i < pkt.Count; i++) + { + if (recv.IsValid(i)) + { + bytes += size.Values[i]; + } + } + + return bytes; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Engine/WindowEvaluator.cs b/backend/src/Monitor.Backend.Analysis/Engine/WindowEvaluator.cs new file mode 100644 index 0000000..d6f41b0 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Engine/WindowEvaluator.cs @@ -0,0 +1,71 @@ +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +/// +/// 把原始块按时间分桶算成累加器。流式路径(1s 桶)和持久路径(分钟窗口)调用同一份代码, +/// 所以实时值与历史值使用的是同一套口径。 +/// +public sealed class WindowEvaluator +{ + private readonly IReadOnlyList _metrics; + + public WindowEvaluator(IReadOnlyList metrics) + { + _metrics = metrics; + } + + public static WindowEvaluator Default { get; } = new(MetricRegistry.All); + + /// + /// 评估一个块,结果合并进 (键是桶起点的 UTC 毫秒)。 + /// 不为空时只计算这些指标(用于回填新增或变更的指标)。 + /// + public void Evaluate(RawChunk chunk, long bucketMs, IDictionary buckets, ISet? only = null) + { + var view = new ChunkView(chunk); + foreach (RawStream stream in chunk.Streams) + { + foreach (MetricDef metric in _metrics) + { + if ((only is not null && !only.Contains(metric.Id)) || !metric.Matches(chunk.Header.Kind, stream.Name)) + { + continue; + } + + metric.Op.Evaluate(view, stream, new BucketSink(metric, bucketMs, buckets)); + } + } + } + + private sealed class BucketSink : IObservationSink + { + private readonly MetricDef _metric; + private readonly long _bucketMs; + private readonly IDictionary _buckets; + + public BucketSink(MetricDef metric, long bucketMs, IDictionary buckets) + { + _metric = metric; + _bucketMs = bucketMs; + _buckets = buckets; + } + + public void Emit(long utcMs, double a, double b = 0) + { + long start = Math.DivRem(utcMs, _bucketMs, out long rem) * _bucketMs; + if (rem < 0) + { + start -= _bucketMs; + } + + if (!_buckets.TryGetValue(start, out MetricSet? set)) + { + set = new MetricSet(); + _buckets[start] = set; + } + + set.GetOrAdd(_metric.Id, _metric.Kind).Add(a, b); + } + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Metrics/LinkMetrics.cs b/backend/src/Monitor.Backend.Analysis/Metrics/LinkMetrics.cs new file mode 100644 index 0000000..d76d8b0 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Metrics/LinkMetrics.cs @@ -0,0 +1,41 @@ +namespace Monitor.Backend.Analysis; + +/// 线路基础指标(udp_echo / icmp_echo 的回显流)。新增指标 = 加一行声明。 +public static class LinkMetrics +{ + private static readonly string[] s_udpMsg = ["udp_echo.msg"]; + private static readonly string[] s_udpOrIcmpMsg = ["udp_echo.msg", "icmp_echo.msg"]; + + public static readonly MetricDef Rtt = Metric.Histogram( + "rtt", s_udpOrIcmpMsg, Ops.Rtt, "RTT", "Round-trip time with the Agent processing time removed.", Unit.Ms, + [Stat.P50, Stat.P95, Stat.P99, Stat.Mean], good: 50, poor: 150); + + public static readonly MetricDef LossUp = Metric.Ratio( + "loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the Probe and the Agent.", good: 0.5, poor: 3); + + public static readonly MetricDef LossDown = Metric.Ratio( + "loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the Probe, among packets that reached the Agent.", good: 0.5, poor: 3); + + public static readonly MetricDef LossTotal = Metric.Ratio( + "loss.total", s_udpOrIcmpMsg, Ops.Loss(Dir.Total), "Loss (total)", "Packets whose reply never came back.", good: 0.5, poor: 3); + + public static readonly MetricDef JitterUp = Metric.Histogram( + "jitter.up", s_udpMsg, Ops.OwdVar(Dir.Up), "Jitter (up)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20); + + public static readonly MetricDef JitterDown = Metric.Histogram( + "jitter.down", s_udpMsg, Ops.OwdVar(Dir.Down), "Jitter (down)", "One-way delay variation, offset and drift removed.", Unit.Ms, [Stat.P95], good: 3, poor: 20); + + public static readonly MetricDef LossRun = Metric.RunLength( + "loss.run", s_udpMsg, Ops.LossRuns, "Loss runs", "Lengths of consecutive lost packets.", + new MetricExport("loss.run_max", Stat.Max, Unit.Packets, 2, 10), + new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets)); + + public static readonly MetricDef Reorder = Metric.Counter( + "reorder", s_udpMsg, Ops.Reorder, "Reordered", "Replies that arrived after a higher sequence number.", new MetricExport("reorder", Stat.Count, Unit.Count)); + + public static readonly MetricDef Dup = Metric.Counter( + "dup", s_udpMsg, Ops.Dup, "Duplicates", "Replies received more than once.", new MetricExport("dup", Stat.Count, Unit.Count)); + + public static readonly MetricDef SamplesUdp = Metric.Counter( + "n.udp_echo", s_udpMsg, Ops.RowCount, "udp_echo samples", "Number of udp_echo records.", new MetricExport("n.udp_echo", Stat.Count, Unit.Count), Better.None); +} diff --git a/backend/src/Monitor.Backend.Analysis/Metrics/MetricRegistry.cs b/backend/src/Monitor.Backend.Analysis/Metrics/MetricRegistry.cs new file mode 100644 index 0000000..140bd8a --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Metrics/MetricRegistry.cs @@ -0,0 +1,74 @@ +namespace Monitor.Backend.Analysis; + +public sealed record PresetDef(string Id, string Label, IReadOnlyList MetricIds); + +/// +/// 指标注册表:存储、降采样、API、目录都由它驱动。新增指标时,在对应的 Metrics 文件里加一行声明, +/// 再把它放进这里的 (显式注册,AOT 安全,不使用反射)。 +/// +public static class MetricRegistry +{ + public static readonly IReadOnlyList All = + [ + LinkMetrics.Rtt, LinkMetrics.LossUp, LinkMetrics.LossDown, LinkMetrics.LossTotal, + LinkMetrics.JitterUp, LinkMetrics.JitterDown, LinkMetrics.LossRun, LinkMetrics.Reorder, LinkMetrics.Dup, LinkMetrics.SamplesUdp, + TcpMetrics.Rtt, TcpMetrics.Slow, TcpMetrics.Stall, TcpMetrics.StallMax, TcpMetrics.Retrans, TcpMetrics.Srtt, TcpMetrics.BulkRate, TcpMetrics.BulkPeak, TcpMetrics.SamplesTcp, + SessionMetrics.ConnectSuccess, SessionMetrics.ConnectTcp, SessionMetrics.ConnectTls, + SessionMetrics.FailTimeout, SessionMetrics.FailReset, SessionMetrics.FailTls, + SessionMetrics.HoldResets, SessionMetrics.HoldLifetime, + SessionMetrics.TrainLoss, SessionMetrics.TrainJitter, SessionMetrics.TrainRate, SessionMetrics.SamplesConnect, + ]; + + /// 指标分组:前端按分组展示,新增指标只需要把它加进分组。 + public static readonly IReadOnlyList Presets = + [ + new("udp", "UDP", ["loss.up", "loss.down", "loss.total", "rtt.p50", "rtt.p95", "rtt.p99", "jitter.up.p95", "jitter.down.p95", "loss.run_max", "reorder", "dup"]), + new("tcp", "TCP", ["tcp.rtt.p50", "tcp.rtt.p95", "tcp.rtt.p99", "tcp.rtt.p999", "tcp.rtt.max", "tcp.rtt.tail", "tcp.slow", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans", "tcp.srtt.p95"]), + new("connect", "Connect", ["connect.success", "connect.tcp_ms.p95", "connect.tls_ms.p95", "connect.fail.timeout", "connect.fail.rst", "connect.fail.tls"]), + new("session", "Session", ["hold.resets.per_h", "hold.lifetime_max"]), + new("scores", "Scores", ["score.base", "score.mc", "score.hy2", "score.reality"]), + ]; + + private static readonly Dictionary s_byId = All.ToDictionary(m => m.Id, StringComparer.Ordinal); + + private static readonly Dictionary s_byExport = BuildExportIndex(); + + public static MetricDef? Find(string metricId) => s_byId.GetValueOrDefault(metricId); + + /// 按导出 id(例如 rtt.p95)查找。 + public static bool TryFindExport(string exportId, out MetricDef def, out MetricExport export) + { + if (s_byExport.TryGetValue(exportId, out (MetricDef Def, MetricExport Export) found)) + { + def = found.Def; + export = found.Export; + return true; + } + + def = null!; + export = null!; + return false; + } + + public static PresetDef? FindPreset(string id) => Presets.FirstOrDefault(p => p.Id == id); + + public static IEnumerable<(MetricDef Def, MetricExport Export)> AllExports() => + All.SelectMany(def => def.Exports.Select(export => (def, export))); + + private static Dictionary BuildExportIndex() + { + var index = new Dictionary(StringComparer.Ordinal); + foreach (MetricDef def in All) + { + foreach (MetricExport export in def.Exports) + { + if (!index.TryAdd(export.Id, (def, export))) + { + throw new InvalidOperationException($"duplicate export id {export.Id}"); + } + } + } + + return index; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Metrics/SessionMetrics.cs b/backend/src/Monitor.Backend.Analysis/Metrics/SessionMetrics.cs new file mode 100644 index 0000000..0a164a2 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Metrics/SessionMetrics.cs @@ -0,0 +1,46 @@ +namespace Monitor.Backend.Analysis; + +/// 连接建立、长连接、UDP 压测(udp_train)的指标。 +public static class SessionMetrics +{ + private static readonly string[] s_conn = ["connect.conn"]; + private static readonly string[] s_hold = ["hold.hold"]; + private static readonly string[] s_train = ["udp_train.pkt"]; + + public static readonly MetricDef ConnectSuccess = new( + "connect.success", AccKind.Ratio, s_conn, Ops.OutcomeRatio(ConnectOutcome.Ok), "Connect success", "Share of connection attempts that completed.", Better.Higher, + [new MetricExport("connect.success", Stat.Pct, Unit.Pct, 99, 90)]); + + public static readonly MetricDef ConnectTcp = Metric.Histogram( + "connect.tcp_ms", s_conn, Ops.PhaseMs("tcp_us"), "TCP connect time", "Time to establish the TCP connection.", Unit.Ms, [Stat.P95], good: 100, poor: 400); + + public static readonly MetricDef ConnectTls = Metric.Histogram( + "connect.tls_ms", s_conn, Ops.PhaseMs("tls_us"), "TLS handshake time", "Time for the TLS handshake after TCP is up.", Unit.Ms, [Stat.P95], good: 200, poor: 800); + + public static readonly MetricDef FailTimeout = Metric.Counter( + "connect.fail.timeout", s_conn, Ops.OutcomeCount(ConnectOutcome.Timeout), "Connect timeouts", "Attempts that timed out.", new MetricExport("connect.fail.timeout", Stat.Count, Unit.Count)); + + public static readonly MetricDef FailReset = Metric.Counter( + "connect.fail.rst", s_conn, Ops.OutcomeCount(ConnectOutcome.Reset), "Connect resets", "Attempts answered with a reset.", new MetricExport("connect.fail.rst", Stat.Count, Unit.Count)); + + public static readonly MetricDef FailTls = Metric.Counter( + "connect.fail.tls", s_conn, Ops.OutcomeCount(ConnectOutcome.TlsAlert), "TLS failures", "Attempts that ended in a TLS alert.", new MetricExport("connect.fail.tls", Stat.Count, Unit.Count)); + + public static readonly MetricDef HoldResets = Metric.Counter( + "hold.resets", s_hold, Ops.HoldEvents(HoldEvent.Reset), "Held connection resets", "Times the held connection was reset.", new MetricExport("hold.resets.per_h", Stat.PerHour, Unit.PerHour, 0.5, 4)); + + public static readonly MetricDef HoldLifetime = Metric.Max( + "hold.lifetime", s_hold, Ops.HoldLifetime, "Longest connection lifetime", "Longest observed lifetime of the held connection (ms).", Unit.Ms, "hold.lifetime_max", Better.Higher); + + public static readonly MetricDef TrainLoss = Metric.Ratio( + "train.loss", s_train, Ops.TrainLoss, "Train loss", "Loss of a fixed-rate UDP stream.", good: 1, poor: 10); + + public static readonly MetricDef TrainJitter = Metric.Histogram( + "train.jitter", s_train, Ops.TrainJitter, "Train jitter", "One-way delay variation of a fixed-rate UDP stream.", Unit.Ms, [Stat.P95], good: 3, poor: 20); + + public static readonly MetricDef TrainRate = Metric.Histogram( + "train.rate", s_train, Ops.TrainRate, "Train rate", "Delivered rate of a fixed-rate UDP stream per second.", Unit.Mbps, [Stat.P50, Stat.Mean], Better.Higher); + + public static readonly MetricDef SamplesConnect = Metric.Counter( + "n.connect", s_conn, Ops.RowCount, "connect samples", "Number of connection attempts.", new MetricExport("n.connect", Stat.Count, Unit.Count), Better.None); +} diff --git a/backend/src/Monitor.Backend.Analysis/Metrics/TcpMetrics.cs b/backend/src/Monitor.Backend.Analysis/Metrics/TcpMetrics.cs new file mode 100644 index 0000000..83851a2 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Metrics/TcpMetrics.cs @@ -0,0 +1,43 @@ +namespace Monitor.Backend.Analysis; + +/// TCP 指标:回显的 RTT 与卡顿、内核采样的重传、批量传输的吞吐。 +public static class TcpMetrics +{ + private static readonly string[] s_tcpMsg = ["tcp_echo.msg"]; + private static readonly string[] s_tcpInfo = ["tcp_echo.tcp_info", "tcp_bulk.tcp_info"]; + private static readonly string[] s_bulkBytes = ["tcp_bulk.bytes"]; + + public static readonly MetricDef Rtt = Metric.Histogram( + "tcp.rtt", s_tcpMsg, Ops.Rtt, "TCP RTT", "Application-level echo round trip over TCP (includes retransmission delay).", Unit.Ms, + [Stat.P50, Stat.P95, Stat.P99, Stat.P999, Stat.Max, Stat.Tail], good: 60, poor: 200); + + public static readonly MetricDef Slow = Metric.Ratio( + "tcp.slow", s_tcpMsg, Ops.SlowShare(minSamples: 20), "TCP long-tail echoes", + "Share of echoes slower than the long-tail threshold: max(1.8 x baseline, baseline + max(15 ms, 0.35 x baseline)), where the baseline is the median RTT of the same 10 s chunk.", + good: 1, poor: 5); + + public static readonly MetricDef Stall = Metric.Counter( + "tcp.stall", s_tcpMsg, Ops.Gap(GapOutput.Count, "rate_hz", thresholdMs: 100), "TCP stalls", + "Arrival gaps longer than the expected interval plus 100 ms (head-of-line blocking).", + new MetricExport("tcp.stall.per_min", Stat.PerMin, Unit.PerMin, 0.5, 5)); + + public static readonly MetricDef StallMax = Metric.Max( + "tcp.stall_max", s_tcpMsg, Ops.Gap(GapOutput.MaxMs, "rate_hz", thresholdMs: 100), "Longest TCP stall", "Longest stall in the period.", Unit.Ms, good: 200, poor: 1000); + + public static readonly MetricDef Retrans = Metric.Ratio( + "tcp.retrans", s_tcpInfo, Ops.KernelDelta("retrans_total", "segs_out"), "TCP retransmissions", "Retransmitted segments as a share of segments sent (kernel counters).", good: 0.5, poor: 3); + + public static readonly MetricDef Srtt = Metric.Histogram( + "tcp.srtt", s_tcpInfo, Ops.Column("srtt_us", 0.001), "Kernel srtt", "Smoothed RTT sampled from the kernel.", Unit.Ms, [Stat.P95], good: 60, poor: 200); + + public static readonly MetricDef BulkRate = Metric.Histogram( + "tcp.bulk.mbps", s_bulkBytes, Ops.BulkRate, "Bulk throughput", "Aggregate throughput of the bulk transfer streams.", Unit.Mbps, + [Stat.P50, Stat.Mean, Stat.Max], Better.Higher); + + public static readonly MetricDef BulkPeak = Metric.Histogram( + "tcp.bulk.peak", s_bulkBytes, Ops.BulkPeak, "Peak bulk throughput", "Best sustained 1 s throughput of the bulk transfer: how fast this line can go.", Unit.Mbps, + [Stat.Max], Better.Higher); + + public static readonly MetricDef SamplesTcp = Metric.Counter( + "n.tcp_echo", s_tcpMsg, Ops.RowCount, "tcp_echo samples", "Number of tcp_echo records.", new MetricExport("n.tcp_echo", Stat.Count, Unit.Count), Better.None); +} diff --git a/backend/src/Monitor.Backend.Analysis/Models/Models.cs b/backend/src/Monitor.Backend.Analysis/Models/Models.cs new file mode 100644 index 0000000..506b574 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Models/Models.cs @@ -0,0 +1,193 @@ +namespace Monitor.Backend.Analysis; + +/// 线路基础质量:RTT、丢包、抖动、连续丢包。 +public sealed class BaseModel : ServiceModelBase +{ + private static readonly Curve s_rtt = new((30, 100), (80, 80), (150, 50), (300, 10), (500, 0)); + private static readonly Curve s_loss = new((0, 100), (0.5, 90), (1, 75), (3, 40), (10, 0)); + private static readonly Curve s_jitter = new((2, 100), (8, 85), (20, 50), (80, 0)); + private static readonly Curve s_run = new((1, 100), (3, 80), (10, 30), (30, 0)); + + public override string Id => "base"; + + public override string Title => "Line quality"; + + public override int Version => 1; + + public override IReadOnlyList Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"]; + + public override ModelScore Score(MetricView view) => + Combine( + view, + [ + Dim(view, "rtt.p95", "RTT p95", 3, s_rtt), + Dim(view, "loss.total", "Packet loss", 4, s_loss), + Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter), + Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter), + Dim(view, "loss.run_max", "Longest loss run", 1, s_run), + ], + estimated: false); +} + +/// +/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。 +/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。 +/// +public sealed class McModel : ServiceModelBase +{ + private static readonly Curve s_rtt = new((40, 100), (100, 80), (200, 45), (400, 5), (600, 0)); + private static readonly Curve s_stallRate = new((0, 100), (1, 90), (5, 60), (20, 20), (60, 0)); + private static readonly Curve s_stallMax = new((150, 100), (500, 60), (2000, 10), (5000, 0)); + private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0)); + + public override string Id => "mc"; + + public override string Title => "Minecraft"; + + public override int Version => 1; + + public override IReadOnlyList Needs { get; } = ["tcp.rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"]; + + public override ModelScore Score(MetricView view) + { + bool hasTcp = view.Has("tcp.rtt.p95") || view.Has("tcp.stall.per_min"); + if (hasTcp) + { + return Combine( + view, + [ + Dim(view, "tcp.rtt.p95", "TCP RTT p95", 3, s_rtt), + Dim(view, "tcp.stall.per_min", "Stalls per minute", 3, s_stallRate), + Dim(view, "tcp.stall_max", "Longest stall", 2, s_stallMax), + Dim(view, "tcp.retrans", "Retransmissions", 2, s_retrans), + ], + estimated: false); + } + + if (view.Get("loss.total") is not { } lossPct || view.Get("rtt.p95") is not { } rttMs) + { + return Combine(view, [], estimated: true); + } + + // 一个丢失的包至少造成一次约 max(200 ms, 2·RTT) 的卡顿;约一半能被快速重传恢复。 + const double messagesPerMinute = 1200; + double stallsPerMin = lossPct / 100 * messagesPerMinute * 0.5; + double stallMs = lossPct > 0 ? Math.Max(200, 2 * rttMs) * (1 + (view.Get("loss.run_max") ?? 1) / 3) : 0; + var estimate = new MetricView(new Dictionary + { + ["tcp.rtt.p95"] = rttMs + (lossPct > 0 ? Math.Max(200, 2 * rttMs) * lossPct / 100 * 5 : 0), + ["tcp.stall.per_min"] = stallsPerMin, + ["tcp.stall_max"] = stallMs, + ["tcp.retrans"] = lossPct, + }) + { + MeasuredOnServicePort = view.MeasuredOnServicePort, + }; + return Combine( + estimate, + [ + Dim(estimate, "tcp.rtt.p95", "TCP RTT p95 (estimated)", 3, s_rtt), + Dim(estimate, "tcp.stall.per_min", "Stalls per minute (estimated)", 3, s_stallRate), + Dim(estimate, "tcp.stall_max", "Longest stall (estimated)", 2, s_stallMax), + Dim(estimate, "tcp.retrans", "Retransmissions (estimated)", 2, s_retrans), + ], + estimated: true, + "estimated_from_udp"); + } +} + +/// +/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分; +/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。 +/// +public sealed class Hy2Model : ServiceModelBase +{ + private static readonly Curve s_sustainable = new((0, 0), (10, 30), (25, 60), (50, 80), (100, 100)); + private static readonly Curve s_qosOnset = new((10, 20), (25, 50), (50, 75), (100, 95)); + private static readonly Curve s_loss = new((0, 100), (1, 85), (3, 60), (8, 20), (15, 0)); + private static readonly Curve s_jitter = new((3, 100), (10, 80), (30, 40), (100, 0)); + private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0)); + + public override string Id => "hy2"; + + public override string Title => "Hysteria2"; + + public override int Version => 1; + + public override IReadOnlyList Needs { get; } = ["hy2.sustainable_mbps", "hy2.effective_mbps", "hy2.qos_onset_mbps", "loss.total", "jitter.down.p95", "rtt.p95"]; + + public override ModelScore Score(MetricView view) + { + var dimensions = new List(); + bool hasTest = view.Has("hy2.sustainable_mbps"); + if (hasTest) + { + dimensions.Add(Dim(view, "hy2.sustainable_mbps", "Sustainable rate (loss ≤ 5%)", 4, s_sustainable)); + dimensions.Add(Dim(view, "hy2.effective_mbps", "Effective throughput", 2, s_sustainable)); + dimensions.Add(view.Get("hy2.qos_onset_mbps") is { } onset + ? new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, onset, s_qosOnset.Evaluate(onset)) + : new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, null, 100)); + } + + dimensions.Add(Dim(view, "loss.total", "Packet loss (trend)", hasTest ? 1.5 : 4, s_loss)); + dimensions.Add(Dim(view, "jitter.down.p95", "Jitter (down)", hasTest ? 0.5 : 1.5, s_jitter)); + dimensions.Add(Dim(view, "rtt.p95", "RTT p95", hasTest ? 0.5 : 1.5, s_rtt)); + return Combine(view, dimensions, estimated: !hasTest, hasTest ? [] : ["no_load_test"]); + } +} + +/// REALITY(TLS 伪装的 TCP 代理):握手成功率与耗时、长连接稳定性、批量吞吐与重传。 +public sealed class RealityModel : ServiceModelBase +{ + private static readonly Curve s_success = new((80, 10), (95, 60), (99, 90), (100, 100)); + private static readonly Curve s_tls = new((150, 100), (400, 80), (1000, 40), (3000, 0)); + private static readonly Curve s_resets = new((0, 100), (1, 80), (4, 40), (12, 0)); + private static readonly Curve s_bulk = new((10, 30), (100, 60), (400, 85), (800, 100)); + private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0)); + private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0)); + + public override string Id => "reality"; + + public override string Title => "REALITY"; + + public override int Version => 1; + + public override IReadOnlyList Needs { get; } = + ["connect.success", "connect.tls_ms.p95", "hold.resets.per_h", "tcp.bulk.peak.max", "tcp.retrans", "tcp.rtt.p95"]; + + public override ModelScore Score(MetricView view) + { + bool hasSession = view.Has("connect.success"); + return Combine( + view, + [ + Dim(view, "connect.success", "Connect success", 3, s_success), + Dim(view, "connect.tls_ms.p95", "TLS handshake p95", 2, s_tls), + Dim(view, "hold.resets.per_h", "Held connection resets", 2, s_resets), + Dim(view, "tcp.bulk.peak.max", "Peak bulk throughput", 2, s_bulk), + Dim(view, "tcp.retrans", "Retransmissions", 1, s_retrans), + Dim(view, "tcp.rtt.p95", "TCP RTT p95", 1, s_rtt), + ], + estimated: !hasSession, + hasSession ? [] : ["no_connect_data"]); + } +} + +public static class ModelRegistry +{ + public static readonly IReadOnlyList All = [new BaseModel(), new McModel(), new Hy2Model(), new RealityModel()]; + + public static IServiceModel? Find(string id) => All.FirstOrDefault(m => m.Id == id); + + /// 计算全部模型的评分(score.<模型id>)。 + public static Dictionary ScoreAll(MetricView view) + { + var scores = new Dictionary(StringComparer.Ordinal); + foreach (IServiceModel model in All) + { + scores[model.Id] = model.Score(view); + } + + return scores; + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Models/ServiceModel.cs b/backend/src/Monitor.Backend.Analysis/Models/ServiceModel.cs new file mode 100644 index 0000000..d37b2b7 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Models/ServiceModel.cs @@ -0,0 +1,122 @@ +namespace Monitor.Backend.Analysis; + +/// 模型的输入:一个时间段内的导出值(例如 rtt.p95)。没有数据的指标不存在。 +public sealed class MetricView +{ + private readonly IReadOnlyDictionary _values; + + public MetricView(IReadOnlyDictionary values) + { + _values = values; + } + + /// 测量端口与被推算服务的常用端口(443)一致时为 true。默认端口 7443/7444 下总是 false。 + public bool MeasuredOnServicePort { get; init; } + + public IReadOnlyDictionary Values => _values; + + public bool Has(string id) => _values.ContainsKey(id); + + public double? Get(string id) => _values.TryGetValue(id, out double v) ? v : null; +} + +public sealed record ModelDimension(string Id, string Label, double Weight, double? Value, double? Score); + +/// 评分 0–100;没有任何可用维度时为 null。 +public sealed record ModelScore(string ModelId, int Version, double? Score, IReadOnlyList Dimensions, IReadOnlyList Caveats, bool Estimated); + +/// +/// 服务体验模型:只读取指标,不读取原始列;在查询时计算,改动评分公式后立即对全部历史生效。 +/// 评分函数需要有单调性测试(指标变差时,分数不能升高)。 +/// +public interface IServiceModel +{ + string Id { get; } + + string Title { get; } + + int Version { get; } + + /// 需要的导出值 id(用于目录展示和判断数据是否足够)。 + IReadOnlyList Needs { get; } + + ModelScore Score(MetricView view); +} + +/// 分段线性评分曲线:点按 x 升序排列,超出两端时取端点的值。 +public sealed class Curve +{ + private readonly (double X, double Y)[] _points; + + public Curve(params (double X, double Y)[] points) + { + _points = points; + } + + public double Evaluate(double x) + { + if (x <= _points[0].X) + { + return _points[0].Y; + } + + for (int i = 1; i < _points.Length; i++) + { + if (x <= _points[i].X) + { + (double x0, double y0) = _points[i - 1]; + (double x1, double y1) = _points[i]; + return y0 + ((y1 - y0) * (x - x0) / (x1 - x0)); + } + } + + return _points[^1].Y; + } +} + +public abstract class ServiceModelBase : IServiceModel +{ + protected static readonly string[] BaseCaveats = ["traffic_shape_only"]; + + public abstract string Id { get; } + + public abstract string Title { get; } + + public abstract int Version { get; } + + public abstract IReadOnlyList Needs { get; } + + public abstract ModelScore Score(MetricView view); + + protected static ModelDimension Dim(MetricView view, string id, string label, double weight, Curve curve) => + view.Get(id) is { } value ? new ModelDimension(id, label, weight, value, curve.Evaluate(value)) : new ModelDimension(id, label, weight, null, null); + + /// 按权重对可用的维度加权平均(缺失的维度不参与)。 + protected ModelScore Combine(MetricView view, List dimensions, bool estimated, params string[] extraCaveats) + { + double weight = 0; + double sum = 0; + foreach (ModelDimension d in dimensions) + { + if (d.Score is { } score) + { + weight += d.Weight; + sum += d.Weight * score; + } + } + + var caveats = new List(BaseCaveats); + if (!view.MeasuredOnServicePort) + { + caveats.Add("port_differs_from_service"); + } + + caveats.AddRange(extraCaveats); + if (estimated) + { + caveats.Add("estimated"); + } + + return new ModelScore(Id, Version, weight > 0 ? Math.Round(sum / weight, 1) : null, dimensions, caveats, estimated); + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj b/backend/src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj new file mode 100644 index 0000000..a546806 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj @@ -0,0 +1,8 @@ + + + true + + + + + diff --git a/backend/src/Monitor.Backend.Analysis/Ops/EchoOps.cs b/backend/src/Monitor.Backend.Analysis/Ops/EchoOps.cs new file mode 100644 index 0000000..7ac94c4 --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Ops/EchoOps.cs @@ -0,0 +1,446 @@ +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +public enum Dir +{ + Up, + Down, + Total, +} + +public enum GapOutput +{ + Count, + MaxMs, +} + +/// 回显流(stream msg)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。 +internal readonly struct EchoColumns +{ + public EchoColumns(RawStream stream) + { + Count = stream.Count; + Seq = stream.Find("seq"); + T1 = stream.Find("t1"); + T2 = stream.Find("t2"); + T3 = stream.Find("t3"); + T4 = stream.Find("t4"); + } + + public int Count { get; } + + public RawColumn? Seq { get; } + + public RawColumn? T1 { get; } + + public RawColumn? T2 { get; } + + public RawColumn? T3 { get; } + + public RawColumn? T4 { get; } + + public bool HasBasics => T1 is not null && T4 is not null; + + /// 行 i 的发送时刻(UTC 毫秒)。 + public long SentUtcMs(ChunkView view, int i) => view.ColumnToUtcMs(T1!, T1!.Values[i]); + + public bool IsUpLost(int i) => T2 is not null && !T2.IsValid(i); + + public bool IsReplyLost(int i) => T4 is null || !T4.IsValid(i); + + /// 按到达时间(t4)排序后的行号,只含收到回复的行。 + public int[] ArrivalOrder() + { + var rows = new List(Count); + for (int i = 0; i < Count; i++) + { + if (!IsReplyLost(i)) + { + rows.Add(i); + } + } + + int[] order = [.. rows]; + long[] keys = new long[order.Length]; + for (int i = 0; i < order.Length; i++) + { + keys[i] = T4!.Values[order[i]]; + } + + Array.Sort(keys, order); + return order; + } +} + +internal sealed class RttOp : MetricOp +{ + public override string Signature => "rtt/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics) + { + return; + } + + for (int i = 0; i < c.Count; i++) + { + if (c.IsReplyLost(i) || !c.T1!.IsValid(i)) + { + continue; + } + + long rttUs = c.T4!.Values[i] - c.T1.Values[i]; + if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i)) + { + rttUs -= c.T3.Values[i] - c.T2.Values[i]; + } + + if (rttUs >= 0) + { + sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0); + } + } + } +} + +/// +/// 长尾回显:每个有回复的回显一个观测(是否"慢")。阈值以同一个块(10 秒)里 RTT 的中位数为基线: +/// max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))。基线取自块内数据,所以每个观测只依赖自己所在的块,窗口合并仍然精确; +/// 块里的样本少于 minSamples 时基线不可靠,不产生观测。TCP 的长尾通常是丢包后的重传(RTT 聚在 RTO 附近)或队头阻塞。 +/// +internal sealed class SlowShareOp : MetricOp +{ + private readonly int _minSamples; + + public SlowShareOp(int minSamples) + { + _minSamples = minSamples; + } + + public override string Signature => $"slow_share/{_minSamples}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics) + { + return; + } + + var rtts = new List<(int Row, double Ms)>(c.Count); + for (int i = 0; i < c.Count; i++) + { + if (c.IsReplyLost(i) || !c.T1!.IsValid(i)) + { + continue; + } + + long rttUs = c.T4!.Values[i] - c.T1.Values[i]; + if (c.T2 is not null && c.T3 is not null && c.T2.IsValid(i) && c.T3.IsValid(i)) + { + rttUs -= c.T3.Values[i] - c.T2.Values[i]; + } + + if (rttUs >= 0) + { + rtts.Add((i, rttUs / 1000.0)); + } + } + + if (rtts.Count < _minSamples) + { + return; + } + + double[] sorted = [.. rtts.Select(r => r.Ms).Order()]; + double baseline = sorted[sorted.Length / 2]; + double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35)); + foreach ((int row, double ms) in rtts) + { + sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1); + } + } +} + +internal sealed class LossOp : MetricOp +{ + private readonly Dir _dir; + + public LossOp(Dir dir) + { + _dir = dir; + } + + public override string Signature => "loss/" + _dir + "/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics || (_dir != Dir.Total && c.T2 is null)) + { + return; + } + + for (int i = 0; i < c.Count; i++) + { + if (!c.T1!.IsValid(i)) + { + continue; + } + + switch (_dir) + { + case Dir.Up: + sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1); + break; + case Dir.Down: + // 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。 + if (!c.IsUpLost(i)) + { + sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1); + } + + break; + default: + sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1); + break; + } + } + } +} + +/// 去除偏移和线性漂移后的单向时延变化:上行用 t2−t1,下行用 t4−t3。 +internal sealed class OwdVarOp : MetricOp +{ + private readonly Dir _dir; + + public OwdVarOp(Dir dir) + { + _dir = dir; + } + + public override string Signature => "owd_var/" + _dir + "/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + RawColumn? a = _dir == Dir.Up ? c.T1 : c.T3; + RawColumn? b = _dir == Dir.Up ? c.T2 : c.T4; + if (c.T1 is null || a is null || b is null) + { + return; + } + + var times = new List(c.Count); + var delays = new List(c.Count); + var utc = new List(c.Count); + for (int i = 0; i < c.Count; i++) + { + if (!c.T1.IsValid(i) || !a.IsValid(i) || !b.IsValid(i)) + { + continue; + } + + times.Add(c.T1.Values[i]); + delays.Add(b.Values[i] - a.Values[i]); + utc.Add(c.SentUtcMs(view, i)); + } + + JitterEmitter.Emit(times, delays, utc, sink); + } +} + +internal static class JitterEmitter +{ + public static void Emit(List timesUs, List delaysUs, List utcMs, IObservationSink sink) + { + double[]? jitter = ClockDriftFit.Jitter([.. timesUs], [.. delaysUs]); + if (jitter is null) + { + return; + } + + for (int i = 0; i < jitter.Length; i++) + { + sink.Emit(utcMs[i], jitter[i] / 1000.0); + } + } +} + +/// 连续丢失段的长度(按发送顺序)。 +internal sealed class LossRunsOp : MetricOp +{ + public override string Signature => "loss_runs/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics) + { + return; + } + + int run = 0; + long runStart = 0; + for (int i = 0; i < c.Count; i++) + { + if (!c.T1!.IsValid(i)) + { + continue; + } + + if (c.IsReplyLost(i)) + { + if (run == 0) + { + runStart = c.SentUtcMs(view, i); + } + + run++; + } + else if (run > 0) + { + sink.Emit(runStart, run); + run = 0; + } + } + + if (run > 0) + { + sink.Emit(runStart, run); + } + } +} + +/// 乱序:按到达顺序,序号比之前见过的最大序号还小。重复的序号不算乱序。 +internal sealed class ReorderOp : MetricOp +{ + public override string Signature => "reorder/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics || c.Seq is null) + { + return; + } + + var seen = new HashSet(); + long maxSeq = long.MinValue; + foreach (int row in c.ArrivalOrder()) + { + long seq = c.Seq.Values[row]; + if (!seen.Add(seq)) + { + continue; + } + + if (seq < maxSeq) + { + sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1); + } + + maxSeq = Math.Max(maxSeq, seq); + } + } +} + +/// 重复:同一个序号收到不止一次。 +internal sealed class DupOp : MetricOp +{ + public override string Signature => "dup/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics || c.Seq is null) + { + return; + } + + var seen = new HashSet(); + foreach (int row in c.ArrivalOrder()) + { + if (!seen.Add(c.Seq.Values[row])) + { + sink.Emit(view.ColumnToUtcMs(c.T4!, c.T4!.Values[row]), 1); + } + } + } +} + +/// 卡顿:到达间隔超过“期望间隔 + 阈值”。 +internal sealed class GapOp : MetricOp +{ + private readonly GapOutput _output; + private readonly string _rateParam; + private readonly double _thresholdMs; + + public GapOp(GapOutput output, string rateParam, double thresholdMs) + { + _output = output; + _rateParam = rateParam; + _thresholdMs = thresholdMs; + } + + public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + var c = new EchoColumns(stream); + if (!c.HasBasics) + { + return; + } + + double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1)); + double expectedMs = 1000.0 / rate; + int[] order = c.ArrivalOrder(); + for (int i = 1; i < order.Length; i++) + { + double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0; + if (gapMs - expectedMs <= _thresholdMs) + { + continue; + } + + long at = view.ColumnToUtcMs(c.T4, c.T4.Values[order[i]]); + sink.Emit(at, _output == GapOutput.Count ? 1 : gapMs); + } + } +} + +/// 每一行一个观测值,用于统计样本量。 +internal sealed class RowCountOp : MetricOp +{ + public override string Signature => "row_count/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? time = null; + foreach (RawColumn column in stream.Columns) + { + if (column.Role != ColumnRole.None) + { + time = column; + break; + } + } + + if (time is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + if (time.IsValid(i)) + { + sink.Emit(view.ColumnToUtcMs(time, time.Values[i]), 1); + } + } + } +} diff --git a/backend/src/Monitor.Backend.Analysis/Ops/Ops.cs b/backend/src/Monitor.Backend.Analysis/Ops/Ops.cs new file mode 100644 index 0000000..4ee101d --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Ops/Ops.cs @@ -0,0 +1,47 @@ +namespace Monitor.Backend.Analysis; + +/// 算子工厂:Ops.Rtt、Ops.Loss(Dir.Up) 等,供 MetricDef 声明使用。 +public static class Ops +{ + public static MetricOp Rtt { get; } = new RttOp(); + + public static MetricOp SlowShare(int minSamples) => new SlowShareOp(minSamples); + + public static MetricOp Loss(Dir dir) => new LossOp(dir); + + public static MetricOp OwdVar(Dir dir) => new OwdVarOp(dir); + + public static MetricOp LossRuns { get; } = new LossRunsOp(); + + public static MetricOp Reorder { get; } = new ReorderOp(); + + public static MetricOp Dup { get; } = new DupOp(); + + public static MetricOp Gap(GapOutput output, string expectedFrom, double thresholdMs) => new GapOp(output, expectedFrom, thresholdMs); + + public static MetricOp RowCount { get; } = new RowCountOp(); + + public static MetricOp KernelDelta(string field, string over) => new KernelDeltaOp(field, over); + + public static MetricOp Column(string column, double scale) => new ColumnValueOp(column, scale); + + public static MetricOp BulkRate { get; } = new BulkRateOp(); + + public static MetricOp BulkPeak { get; } = new BulkRateOp(windowMs: 1000); + + public static MetricOp PhaseMs(string column) => new PhaseMsOp(column); + + public static MetricOp OutcomeRatio(ConnectOutcome code) => new OutcomeOp(code, asRatio: true); + + public static MetricOp OutcomeCount(ConnectOutcome code) => new OutcomeOp(code, asRatio: false); + + public static MetricOp HoldEvents(HoldEvent holdEvent) => new HoldEventOp(holdEvent); + + public static MetricOp HoldLifetime { get; } = new HoldLifetimeOp(); + + public static MetricOp TrainLoss { get; } = new TrainLossOp(); + + public static MetricOp TrainJitter { get; } = new TrainJitterOp(); + + public static MetricOp TrainRate { get; } = new TrainRateOp(); +} diff --git a/backend/src/Monitor.Backend.Analysis/Ops/SessionOps.cs b/backend/src/Monitor.Backend.Analysis/Ops/SessionOps.cs new file mode 100644 index 0000000..fb521fe --- /dev/null +++ b/backend/src/Monitor.Backend.Analysis/Ops/SessionOps.cs @@ -0,0 +1,385 @@ +using Monitor.Protocol; + +namespace Monitor.Backend.Analysis; + +/// tcp_info 流的辅助:side 为 0 表示 Probe 一侧记录(Probe 时钟),否则为 Agent 一侧。 +internal static class TcpInfoTime +{ + public static long ToUtcMs(ChunkView view, RawColumn ts, RawColumn? side, int row) => + side is not null && side.Values[row] == 0 ? view.ProbeToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]); +} + +/// 两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 side 分别计算。 +internal sealed class KernelDeltaOp : MetricOp +{ + private readonly string _field; + private readonly string _over; + + public KernelDeltaOp(string field, string over) + { + _field = field; + _over = over; + } + + public override string Signature => $"kernel_delta/{_field}/{_over}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? side = stream.Find("side"); + RawColumn? field = stream.Find(_field); + RawColumn? over = stream.Find(_over); + if (ts is null || field is null || over is null) + { + return; + } + + var last = new Dictionary(); + for (int i = 0; i < stream.Count; i++) + { + long key = side?.Values[i] ?? 0; + if (last.TryGetValue(key, out (long Field, long Over) previous)) + { + long dField = Math.Max(0, field.Values[i] - previous.Field); + long dOver = Math.Max(0, over.Values[i] - previous.Over); + if (dOver > 0) + { + sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), dField, dOver); + } + } + + last[key] = (field.Values[i], over.Values[i]); + } + } +} + +/// 直接取某一列的值并乘以系数(例如 srtt_us × 0.001 得到毫秒)。 +internal sealed class ColumnValueOp : MetricOp +{ + private readonly string _column; + private readonly double _scale; + + public ColumnValueOp(string column, double scale) + { + _column = column; + _scale = scale; + } + + public override string Signature => $"column/{_column}/{_scale}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? side = stream.Find("side"); + RawColumn? column = stream.Find(_column); + if (ts is null || column is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + if (column.IsValid(i)) + { + sink.Emit(TcpInfoTime.ToUtcMs(view, ts, side, i), column.Values[i] * _scale); + } + } + } +} + +/// +/// 批量传输的吞吐:每个采样间隔内所有连接的字节增量之和,换算成 Mbit/s。 +/// windowMs 大于采样间隔时,输出的是滑动窗口(只在窗口填满之后)的平均吞吐:取最大值就是"持续了这么久的最好吞吐", +/// 不受慢启动和单个间隔抖动的影响。 +/// +internal sealed class BulkRateOp : MetricOp +{ + private readonly int _windowMs; + + public BulkRateOp(int windowMs = 0) + { + _windowMs = windowMs; + } + + public override string Signature => $"bulk_rate/{_windowMs}/2"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? conn = stream.Find("conn"); + RawColumn? bytes = stream.Find("bytes"); + if (ts is null || conn is null || bytes is null) + { + return; + } + + double sampleMs = Math.Max(1, view.ParamNumber("sample_ms", 100)); + long binUs = (long)(sampleMs * 1000); + var last = new Dictionary(); + var bins = new SortedDictionary(); + for (int i = 0; i < stream.Count; i++) + { + long key = conn.Values[i]; + if (last.TryGetValue(key, out long previous) && bytes.Values[i] >= previous) + { + long bin = ts.Values[i] / binUs; + bins[bin] = bins.GetValueOrDefault(bin) + (bytes.Values[i] - previous); + } + + last[key] = bytes.Values[i]; + } + + if (bins.Count == 0) + { + return; + } + + long width = Math.Max(1, (long)Math.Round(_windowMs / sampleMs)); + long first = bins.Keys.First(); + foreach ((long bin, _) in bins) + { + if (bin - first + 1 < width) + { + continue; // 窗口还没填满 + } + + double total = 0; + for (long b = bin - width + 1; b <= bin; b++) + { + total += bins.GetValueOrDefault(b); + } + + double mbps = total * 8 / (width * sampleMs / 1000.0) / 1_000_000; + sink.Emit(view.ProbeToUtcMs(bin * binUs), mbps); + } + } +} + +public enum ConnectOutcome +{ + Ok = 0, + Timeout = 1, + Reset = 2, + TlsAlert = 3, + Other = 4, +} + +/// 连接结果:作为比率(分子是命中的次数)或计数。 +internal sealed class OutcomeOp : MetricOp +{ + private readonly ConnectOutcome _code; + private readonly bool _asRatio; + + public OutcomeOp(ConnectOutcome code, bool asRatio) + { + _code = code; + _asRatio = asRatio; + } + + public override string Signature => $"outcome/{_code}/{(_asRatio ? "ratio" : "count")}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? outcome = stream.Find("outcome"); + if (ts is null || outcome is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + bool hit = outcome.Values[i] == (long)_code; + long at = view.ColumnToUtcMs(ts, ts.Values[i]); + if (_asRatio) + { + sink.Emit(at, hit ? 1 : 0, 1); + } + else if (hit) + { + sink.Emit(at, 1); + } + } + } +} + +/// 阶段耗时(TCP 建连、TLS 握手):只统计该阶段有值的尝试。 +internal sealed class PhaseMsOp : MetricOp +{ + private readonly string _column; + + public PhaseMsOp(string column) + { + _column = column; + } + + public override string Signature => $"phase_ms/{_column}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? column = stream.Find(_column); + if (ts is null || column is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + if (column.IsValid(i)) + { + sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), column.Values[i] / 1000.0); + } + } + } +} + +public enum HoldEvent +{ + Open = 0, + Heartbeat = 1, + Reset = 2, + Close = 3, +} + +internal sealed class HoldEventOp : MetricOp +{ + private readonly HoldEvent _event; + + public HoldEventOp(HoldEvent holdEvent) + { + _event = holdEvent; + } + + public override string Signature => $"hold_event/{_event}/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? ev = stream.Find("event"); + if (ts is null || ev is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + if (ev.Values[i] == (long)_event) + { + sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), 1); + } + } + } +} + +/// 连接存活时长:在重置或正常关闭时记录。 +internal sealed class HoldLifetimeOp : MetricOp +{ + public override string Signature => "hold_lifetime/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? ts = stream.Find("ts"); + RawColumn? ev = stream.Find("event"); + RawColumn? lifetime = stream.Find("lifetime_ms"); + if (ts is null || ev is null || lifetime is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + if (ev.Values[i] is (long)HoldEvent.Reset or (long)HoldEvent.Close) + { + sink.Emit(view.ColumnToUtcMs(ts, ts.Values[i]), lifetime.Values[i]); + } + } + } +} + +/// 单向 UDP 流(udp_train)的丢包:接收端没有记录到 t_recv 即为丢失。 +internal sealed class TrainLossOp : MetricOp +{ + public override string Signature => "train_loss/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? send = stream.Find("t_send"); + RawColumn? recv = stream.Find("t_recv"); + if (send is null || recv is null) + { + return; + } + + for (int i = 0; i < stream.Count; i++) + { + sink.Emit(view.ColumnToUtcMs(send, send.Values[i]), recv.IsValid(i) ? 0 : 1, 1); + } + } +} + +/// 单向时延变化:t_recv(Probe) − t_send(Agent),去除偏移和漂移。 +internal sealed class TrainJitterOp : MetricOp +{ + public override string Signature => "train_jitter/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? send = stream.Find("t_send"); + RawColumn? recv = stream.Find("t_recv"); + if (send is null || recv is null) + { + return; + } + + var times = new List(stream.Count); + var delays = new List(stream.Count); + var utc = new List(stream.Count); + for (int i = 0; i < stream.Count; i++) + { + if (!recv.IsValid(i)) + { + continue; + } + + times.Add(send.Values[i]); + delays.Add(recv.Values[i] - send.Values[i]); + utc.Add(view.ColumnToUtcMs(send, send.Values[i])); + } + + JitterEmitter.Emit(times, delays, utc, sink); + } +} + +/// 每秒实际收到的速率(Mbit/s)。 +internal sealed class TrainRateOp : MetricOp +{ + public override string Signature => "train_rate/1"; + + public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink) + { + RawColumn? recv = stream.Find("t_recv"); + RawColumn? size = stream.Find("size"); + if (recv is null || size is null) + { + return; + } + + var bins = new SortedDictionary(); + for (int i = 0; i < stream.Count; i++) + { + if (!recv.IsValid(i)) + { + continue; + } + + long second = view.ColumnToUtcMs(recv, recv.Values[i]) / 1000; + bins[second] = bins.GetValueOrDefault(second) + size.Values[i]; + } + + foreach ((long second, long bytes) in bins) + { + sink.Emit(second * 1000, bytes * 8 / 1_000_000.0); + } + } +} diff --git a/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs new file mode 100644 index 0000000..e0f20c2 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/AgentEndpoints.cs @@ -0,0 +1,213 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。 +public static class AgentEndpoints +{ + private const string AgentsTag = "admin-agents"; + private const string TokensTag = "admin-join-tokens"; + private const string ProbeTag = "admin-probe"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/agents", ListAsync) + .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403); + group.MapGet("/agents/{id}", GetAsync) + .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404); + group.MapPatch("/agents/{id}", PatchAsync) + .Accepts("application/merge-patch+json", "application/json") + .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) + .Returns().ProducesProblems(400, 401, 403, 404, 409, 412); + group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct))) + .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct))) + .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct))) + .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct))) + .Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag) + .Returns().ProducesProblems(401, 403, 404); + group.MapDelete("/agents/{id}", DeleteAsync) + .Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + + group.MapGet("/join-tokens", ListTokensAsync) + .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/join-tokens", CreateTokenAsync) + .Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) + .Api("join_tokens_revoke", "Revoke a join token", TokensTag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + + group.MapGet("/probes", ListProbesAsync) + .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/probes", CreateProbeAsync) + .Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + group.MapGet("/probes/{id}", GetProbeAsync) + .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404); + group.MapPatch("/probes/{id}", PatchProbeAsync) + .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409); + group.MapPost("/probes/{id}/rotate", RotateProbeAsync) + .Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag) + .Returns().ProducesProblems(401, 403, 404, 409); + group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) + .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag) + .Returns().ProducesProblems(401, 403, 404); + group.MapDelete("/probes/{id}", DeleteProbeAsync) + .Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync) + .Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409); + } + + private static async Task ListAsync(AgentService agents, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await agents.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoAgentDto); + + private static async Task GetAsync(HttpContext http, AgentService agents, string id, CancellationToken ct) + { + AgentView? view = await agents.GetAsync(new NodeId(id), ct); + if (view is null) + { + return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found"); + } + + Concurrency.SetETag(http, view.Agent.RowVersion); + return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); + } + + private static async Task PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) + { + Result result = await agents.PatchAsync( + new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct); + if (!result.TryGet(out AgentView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + Concurrency.SetETag(http, view.Agent.RowVersion); + return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); + } + + private static async Task TransitionAsync(HttpContext http, string id, Func>> action) + { + Result result = await action(new NodeId(id), http.RequestAborted); + if (!result.TryGet(out AgentView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + Concurrency.SetETag(http, view.Agent.RowVersion); + return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); + } + + private static async Task DeleteAsync(AgentService agents, string id, CancellationToken ct) + { + await agents.DeleteAsync(new NodeId(id), ct); + return TypedResults.NoContent(); + } + + private static async Task ListTokensAsync(JoinTokenService tokens, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto); + + private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options) + { + string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + Result result = await tokens.CreateAsync( + new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted); + if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + http.Response.Headers.Location = "/api/admin/join-tokens"; + return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, created.InstallCommand), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, CancellationToken ct) + { + await tokens.RevokeAsync(id, ct); + return TypedResults.NoContent(); + } + + private static async Task ListProbesAsync(ProbeService probes, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto); + + private static async Task CreateProbeAsync(HttpContext http, ProbeService probes, CreateProbeRequest? request, CancellationToken ct) + { + Result result = await probes.CreateAsync(request?.Name, ct); + if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value; + return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) => + await probes.GetAsync(id, ct) is { } view + ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) + : Problems.Create(http, MonitorErrors.ProbeNotFound, "probe not found"); + + private static async Task PatchProbeAsync(HttpContext http, ProbeService probes, PatchProbeRequest request, string id, CancellationToken ct) + { + if (request.Name is null) + { + return Problems.From(http, ValidationError.Single("name", "is required")); + } + + Result result = await probes.RenameAsync(id, request.Name, ct); + return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); + } + + private static async Task RotateProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) + { + Result result = await probes.RotateAsync(id, ct); + if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto); + } + + private static async Task RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) + { + Result result = await probes.RevokeAsync(id, ct); + return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); + } + + private static async Task DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct) + { + await probes.DeleteAsync(id, ct); + return TypedResults.NoContent(); + } + + private static async Task DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct) + { + Result result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct); + if (!result.TryGet(out bool dropped, out FleetError? error)) + { + return Problems.From(http, error); + } + + return dropped ? TypedResults.NoContent() : Problems.Create(http, MonitorErrors.ProbeOffline, "this probe has no session with this agent"); + } + + private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) => + new( + file.ProbeId.Value, file.PrivateKey, [.. file.Bootstrap.Select(b => new BootstrapAgentDto(b.AgentId.Value, b.Host, b.TcpPort, b.Spki))], + new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps)); +} diff --git a/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs new file mode 100644 index 0000000..5c8bbcf --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/AuthEndpoints.cs @@ -0,0 +1,80 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 登录、退出、当前身份。登录接口限流,失败一律返回 401,不区分“用户不存在”和“密码错误”。 +public static class AuthEndpoints +{ + private const string Tag = "admin-auth"; + + /// 登录不需要认证:挂在没有守卫的分组上。 + public static void MapLogin(RouteGroupBuilder open) + { + open.MapPost("/auth/login", LoginAsync) + .Api("auth_login", "Sign in with a username and password", Tag) + .Returns().ProducesProblems(400, 401, 429); + } + + public static void MapSession(RouteGroupBuilder guarded) + { + guarded.MapPost("/auth/logout", LogoutAsync) + .Api("auth_logout", "Sign out and clear the session cookie", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401); + guarded.MapGet("/auth/me", Me) + .Api("auth_me", "The current identity", Tag) + .Returns().ProducesProblems(401); + } + + private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options) + { + string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!limiter.TryAcquire(source, out int retryAfter)) + { + return Problems.RetryAfter(http, new FleetError(MonitorErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); + } + + Result result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted); + if (!result.TryGet(out LoginResult? login, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Cookies.Append( + PrincipalResolver.SessionCookie, + login.SessionToken, + new CookieOptions + { + HttpOnly = true, + Secure = options.SecureCookies, + SameSite = SameSiteMode.Strict, + Path = "/", + Expires = DateTimeOffset.FromUnixTimeMilliseconds(login.ExpiresAtMs), + }); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs)), ApiJsonContext.Default.LoginResponse); + } + + private static async Task LogoutAsync(HttpContext http, AuthService auth) + { + if (http.Request.Cookies.TryGetValue(PrincipalResolver.SessionCookie, out string? session)) + { + await auth.LogoutAsync(session, http.RequestAborted); + } + + http.Response.Cookies.Delete(PrincipalResolver.SessionCookie, new CookieOptions { Path = "/" }); + return TypedResults.NoContent(); + } + + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult Me(HttpContext http) + { + Principal principal = http.GetPrincipal(); + return TypedResults.Json( + new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken), + ApiJsonContext.Default.MeDto); + } +} diff --git a/backend/src/Monitor.Backend.Api/Admin/DataEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/DataEndpoints.cs new file mode 100644 index 0000000..d85927a --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/DataEndpoints.cs @@ -0,0 +1,105 @@ +using System.Text; +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 原始数据检索与下载、事件、指标重算进度。 +public static class DataEndpoints +{ + private const string Tag = "admin-data"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/raw/chunks", ListChunksAsync) + .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().ProducesProblems(400, 401, 403); + group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync) + .Api("raw_chunks_get", "Download a raw chunk (application/vnd.monitor.rawchunk)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "application/vnd.monitor.rawchunk").ProducesProblems(401, 403, 404, 410); + group.MapGet("/events", ListEventsAsync) + .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403); + group.MapGet("/analysis/recompute", RecomputeAsync) + .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403); + } + + private static async Task ListChunksAsync( + HttpContext http, RawBrowserService raw, string? agent_id, string? kind, string? test_id, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor) + { + int size = Math.Clamp(limit ?? 50, 1, 500); + long? after = null; + if (cursor is not null) + { + if (!long.TryParse(cursor, out long parsed)) + { + return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor")); + } + + after = parsed; + } + + var query = new RawQuery( + agent_id is null ? null : new NodeId(agent_id), null, kind, test_id is null ? null : new TestId(test_id), + from?.ToUnixTimeMilliseconds(), to?.ToUnixTimeMilliseconds(), after, size + 1); + IReadOnlyList metas = await raw.ListAsync(query, http.RequestAborted); + bool more = metas.Count > size; + IReadOnlyList page = [.. metas.Take(size)]; + + // 游标按 start_ts 前进:下一页从最后一项的 start_ts + 1 开始(同一毫秒的多个块在同一页内给出) + string? next = more ? (page[^1].StartTs + 1).ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + return TypedResults.Json(new PageDto([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoRawChunkMetaDto); + } + + private static async Task DownloadChunkAsync(HttpContext http, RawBrowserService raw, string chunk_id) + { + if (!Guid.TryParse(chunk_id, out Guid id)) + { + return Problems.Create(http, MonitorErrors.ChunkNotFound, "chunk not found"); + } + + Result<(RawChunkMeta Meta, byte[] Bytes)> result = await raw.GetAsync(new ChunkId(id), http.RequestAborted); + if (!result.TryGet(out (RawChunkMeta Meta, byte[] Bytes) found, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "private, max-age=3600"; + return TypedResults.File(found.Bytes, "application/vnd.monitor.rawchunk", chunk_id + ".mnrc"); + } + + private static async Task ListEventsAsync( + HttpContext http, EventService events, TimeProvider time, string? agent_id, string? kind, DateTimeOffset? from, DateTimeOffset? to, int? limit, string? cursor) + { + long now = time.GetUtcNow().ToUnixTimeMilliseconds(); + long toMs = to?.ToUnixTimeMilliseconds() ?? now + 1; + long fromMs = from?.ToUnixTimeMilliseconds() ?? toMs - Steps.Day; + long? before = null; + if (cursor is not null) + { + if (!long.TryParse(cursor, out long parsed)) + { + return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor")); + } + + before = parsed; + } + + int size = Math.Clamp(limit ?? 50, 1, 500); + IReadOnlyList rows = await events.ListAsync(agent_id is null ? null : new NodeId(agent_id), kind, fromMs, toMs, before, size + 1, http.RequestAborted); + bool more = rows.Count > size; + IReadOnlyList page = [.. rows.Take(size)]; + string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + return TypedResults.Json(new PageDto([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoEventDto); + } + + private static async Task RecomputeAsync(OpsService ops, CancellationToken ct) + { + RecomputeProgress p = ops.RecomputeProgress; + return TypedResults.Json( + new RecomputeDto(p.State, p.Metrics, p.WindowsDone, p.WindowsTotal, Mappers.Time(p.FromMs), Mappers.Time(p.ToMs), await ops.QueueLengthAsync(ct)), + ApiJsonContext.Default.RecomputeDto); + } +} diff --git a/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs new file mode 100644 index 0000000..957199b --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/SystemEndpoints.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// API 令牌、站点设置与保留期、用户。令牌明文只在创建的响应里出现一次。 +public static class SystemEndpoints +{ + private const string TokensTag = "admin-api-tokens"; + private const string SettingsTag = "admin-settings"; + private const string UsersTag = "admin-users"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/api-tokens", ListTokensAsync) + .Api("api_tokens_list", "API tokens", TokensTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/api-tokens", CreateTokenAsync) + .Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + group.MapDelete("/api-tokens/{id}", RevokeTokenAsync) + .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + + group.MapGet("/settings", ListSettingsAsync) + .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403); + group.MapPatch("/settings", PatchSettingsAsync) + .Accepts>("application/merge-patch+json", "application/json") + .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403); + + group.MapGet("/users", ListUsersAsync) + .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/users", CreateUserAsync) + .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + group.MapPatch("/users/{id}", ChangePasswordAsync) + .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403); + } + + private static async Task ListTokensAsync(AuthService auth, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto); + + private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) + { + Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted); + if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new CreatedApiTokenDto(Mappers.ToDto(created.Record), created.Plaintext), ApiJsonContext.Default.CreatedApiTokenDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task RevokeTokenAsync(AuthService auth, string id, CancellationToken ct) + { + await auth.RevokeApiTokenAsync(id, ct); + return TypedResults.NoContent(); + } + + private static async Task ListSettingsAsync(SettingsService settings, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto); + + private static async Task PatchSettingsAsync(HttpContext http, SettingsService settings, Dictionary changes) + { + Result> result = await settings.PatchAsync(changes, http.RequestAborted); + return result.TryGet(out IReadOnlyList? views, out FleetError? error) + ? TypedResults.Json(new PageDto([.. views.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto) + : Problems.From(http, error); + } + + private static async Task ListUsersAsync(AuthService auth, CancellationToken ct) => + TypedResults.Json(new PageDto([.. (await auth.ListUsersAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoUserDto); + + private static async Task CreateUserAsync(HttpContext http, AuthService auth, CreateUserRequest request) + { + Result result = await auth.CreateUserAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted); + if (!result.TryGet(out UserRecord? user, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.Location = "/api/admin/users"; + return TypedResults.Json(Mappers.ToDto(user), ApiJsonContext.Default.UserDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task ChangePasswordAsync(HttpContext http, AuthService auth, ChangePasswordRequest request, string id) + { + Result result = await auth.ChangePasswordAsync(id, request.Password ?? "", http.RequestAborted); + return result.IsSuccess ? TypedResults.NoContent() : Problems.From(http, result.Error); + } +} diff --git a/backend/src/Monitor.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/Monitor.Backend.Api/Admin/TaskEndpoints.cs new file mode 100644 index 0000000..eb30c46 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Admin/TaskEndpoints.cs @@ -0,0 +1,149 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 任务(线路模板、单个 Agent 的覆盖、目标任务)与按需测试。参数按 Kind 的 ParamSpec 校验,并检查带宽预算。 +public static class TaskEndpoints +{ + private const string TasksTag = "admin-tasks"; + private const string TestsTag = "admin-tests"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/tasks", ListAsync) + .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/tasks", CreateAsync) + .Api("tasks_create", "Create a task; params are validated against the kind", TasksTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404); + group.MapGet("/tasks/{id}", GetAsync) + .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404); + group.MapPatch("/tasks/{id}", PatchAsync) + .Accepts("application/merge-patch+json", "application/json") + .Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag) + .Returns().ProducesProblems(400, 401, 403, 404, 412); + group.MapDelete("/tasks/{id}", DeleteAsync) + .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + + group.MapPost("/lines/{line_id}/tests", StartTestAsync) + .Api("tests_start", "Start an on-demand test on a line", TestsTag) + .Returns(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409); + group.MapGet("/lines/{line_id}/tests", ListTestsAsync) + .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403); + group.MapPost("/tests/{id}/cancel", CancelAsync) + .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404); + } + + private static async Task ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct) + { + IEnumerable all = await tasks.ListAsync(ct); + if (agent_id is not null) + { + all = all.Where(t => t.AgentId?.Value == agent_id); + } + + if (scope is not null) + { + all = all.Where(t => t.Scope == scope); + } + + return TypedResults.Json(new PageDto([.. all.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTaskDto); + } + + private static async Task CreateAsync(HttpContext http, TaskService tasks, CreateTaskRequest request, CancellationToken ct) + { + Result result = await tasks.CreateAsync( + new TaskInput( + request.Scope ?? "", request.Kind ?? "", request.Target, request.Params, request.Schedule, request.Enabled, + request.AgentId is null ? null : new NodeId(request.AgentId), request.BaseTaskId is null ? null : new TaskId(request.BaseTaskId)), + ct); + if (!result.TryGet(out TaskRecord? task, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.Location = "/api/admin/tasks/" + task.Id.Value; + Concurrency.SetETag(http, task.RowVersion); + return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task GetAsync(HttpContext http, TaskService tasks, string id, CancellationToken ct) + { + TaskRecord? task = await tasks.GetAsync(new TaskId(id), ct); + if (task is null) + { + return Problems.Create(http, MonitorErrors.TaskNotFound, "task not found"); + } + + Concurrency.SetETag(http, task.RowVersion); + return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto); + } + + private static async Task PatchAsync(HttpContext http, TaskService tasks, PatchTaskRequest request, string id, CancellationToken ct) + { + Result result = await tasks.UpdateAsync(new TaskId(id), new TaskPatch(request.Params, request.Schedule, request.Enabled, request.Target), Concurrency.ExpectedRowVersion(http), ct); + if (!result.TryGet(out TaskRecord? task, out FleetError? error)) + { + return Problems.From(http, error); + } + + Concurrency.SetETag(http, task.RowVersion); + return TypedResults.Json(Mappers.ToDto(task), ApiJsonContext.Default.TaskDto); + } + + private static async Task DeleteAsync(TaskService tasks, string id, CancellationToken ct) + { + await tasks.DeleteAsync(new TaskId(id), ct); + return TypedResults.NoContent(); + } + + private static async Task StartTestAsync(HttpContext http, TestService tests, LineService lines, StartTestRequestDto request, string line_id, CancellationToken ct) + { + Result found = await lines.ResolveAsync(line_id, ct); + if (!found.TryGet(out LineView? line, out FleetError? lineError)) + { + return Problems.From(http, lineError); + } + + Result result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", ct); + if (!result.TryGet(out TestRecord? test, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.Location = "/api/public/tests/" + test.Id.Value; + return TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto, statusCode: StatusCodes.Status202Accepted); + } + + private static async Task ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct) + { + // 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试 + NodeId agent; + ProbeId? probe = null; + if (LineKey.TryParse(line_id, out LineKey key)) + { + agent = key.Agent; + probe = key.Probe; + } + else + { + agent = new NodeId(line_id); + } + + return TypedResults.Json( + new PageDto([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ct)).Select(Mappers.ToDto)], null), + ApiJsonContext.Default.PageDtoTestDto); + } + + private static async Task CancelAsync(HttpContext http, TestService tests, string id, CancellationToken ct) + { + Result result = await tests.CancelAsync(new TestId(id), ct); + return result.TryGet(out TestRecord? test, out FleetError? error) + ? TypedResults.Json(Mappers.ToDto(test), ApiJsonContext.Default.TestDto) + : Problems.From(http, error); + } +} diff --git a/backend/src/Monitor.Backend.Api/ApiModule.cs b/backend/src/Monitor.Backend.Api/ApiModule.cs new file mode 100644 index 0000000..227d89e --- /dev/null +++ b/backend/src/Monitor.Backend.Api/ApiModule.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; + +namespace Monitor.Backend.Api; + +/// +/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 里加一行。 +/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次()。 +/// +public static class ApiModule +{ + public static IServiceCollection AddMonitorApi(this IServiceCollection services) + { + services.AddSingleton(); + services.Configure(options => + { + JsonSerializerOptions json = options.SerializerOptions; + json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + json.DictionaryKeyPolicy = null; + json.DefaultIgnoreCondition = JsonIgnoreCondition.Never; + json.Converters.Add(new Rfc3339Converter()); + json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); + }); + + // 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id + services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + { + int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode; + context.ProblemDetails.Extensions["code"] = DefaultCode(status); + context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier; + if (status >= 500) + { + context.ProblemDetails.Detail = null; + } + }); + + services.AddOpenApi("v1"); + return services; + } + + /// + /// 跨域(可选):只对 /api/public 和 /api/admin 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域), + /// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。 + /// + public static IApplicationBuilder UseMonitorCors(this IApplicationBuilder app, string? origins) + { + string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (allowed.Length == 0) + { + return app; + } + + bool any = allowed.Contains("*"); + return app.Use(async (http, next) => + { + string? origin = http.Request.Headers.Origin; + if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase))) + { + await next(http); + return; + } + + http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin; + http.Response.Headers.Vary = "Origin"; + http.Response.Headers.AccessControlExposeHeaders = "Retry-After"; + if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method")) + { + http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS"; + http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token"; + http.Response.Headers.AccessControlMaxAge = "600"; + http.Response.StatusCode = StatusCodes.Status204NoContent; + return; + } + + await next(http); + }); + } + + /// 认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。 + public static IApplicationBuilder UseMonitorApi(this IApplicationBuilder app) + { + app.UseExceptionHandler(); + app.UseStatusCodePages(); + app.Use(PrincipalResolver.ResolveAsync); + return app; + } + + public static IEndpointRouteBuilder MapMonitorApi(this IEndpointRouteBuilder app) + { + RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync); + QueryEndpoints.Map(publicGroup); + LiveEndpoints.Map(publicGroup); + HostEndpoints.Map(publicGroup); + CatalogEndpoints.Map(publicGroup); + + RouteGroupBuilder admin = app.MapGroup("/api/admin"); + AuthEndpoints.MapLogin(admin); + + RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync); + AuthEndpoints.MapSession(guarded); + AgentEndpoints.Map(guarded); + TaskEndpoints.Map(guarded); + DataEndpoints.Map(guarded); + SystemEndpoints.Map(guarded); + + OpsEndpoints.Map(app); + app.MapOpenApi("/openapi/v1.json"); + return app; + } + + private static string DefaultCode(int status) => status switch + { + 400 => "validation_failed", + 401 => "unauthorized", + 403 => "forbidden", + 404 => "not_found", + 405 => "method_not_allowed", + 413 => "payload_too_large", + 415 => "unsupported_media_type", + 429 => "too_many_requests", + 503 => "storage_unavailable", + _ => "internal_error", + }; +} diff --git a/backend/src/Monitor.Backend.Api/Common/Concurrency.cs b/backend/src/Monitor.Backend.Api/Common/Concurrency.cs new file mode 100644 index 0000000..1916238 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/Concurrency.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using Microsoft.AspNetCore.Http; + +namespace Monitor.Backend.Api; + +/// 并发控制:配置类资源返回 ETag(行版本),PATCH/DELETE 支持 If-Match,不匹配时返回 412。 +internal static class Concurrency +{ + public static string ETag(long rowVersion) => "\"" + rowVersion.ToString(CultureInfo.InvariantCulture) + "\""; + + public static void SetETag(HttpContext http, long rowVersion) => http.Response.Headers.ETag = ETag(rowVersion); + + /// 没有 If-Match(或 *)按“最后写入者获胜”处理,返回 null;写成不合法的值返回 -1(一定不匹配)。 + public static long? ExpectedRowVersion(HttpContext http) + { + string? header = http.Request.Headers.IfMatch; + if (string.IsNullOrWhiteSpace(header) || header.Trim() == "*") + { + return null; + } + + string trimmed = header.Trim().Trim('"'); + return long.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out long version) ? version : -1; + } +} diff --git a/backend/src/Monitor.Backend.Api/Common/EndpointConventions.cs b/backend/src/Monitor.Backend.Api/Common/EndpointConventions.cs new file mode 100644 index 0000000..9158ec2 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/EndpointConventions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Monitor.Backend.Api; + +public static class EndpointConventions +{ + /// 每个端点都必须有 operationId(snake_case,资源_动作)、summary 和 tag。 + public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) => + builder.WithName(operationId).WithSummary(summary).WithTags(tag); + + public static RouteHandlerBuilder Returns(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) => + builder.Produces(status); + + /// 登记该端点可能返回的错误状态(problem+json),让 OpenAPI 文档完整。 + public static RouteHandlerBuilder ProducesProblems(this RouteHandlerBuilder builder, params int[] statuses) + { + foreach (int status in statuses) + { + builder.Produces(status, Problems.ContentType); + } + + return builder; + } +} diff --git a/backend/src/Monitor.Backend.Api/Common/Guards.cs b/backend/src/Monitor.Backend.Api/Common/Guards.cs new file mode 100644 index 0000000..867963b --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/Guards.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +public static class HttpContextPrincipalExtensions +{ + private const string Key = "monitor.principal"; + + public static Principal GetPrincipal(this HttpContext http) => + http.Items.TryGetValue(Key, out object? value) && value is Principal principal ? principal : Principal.Anonymous; + + public static void SetPrincipal(this HttpContext http, Principal principal) => http.Items[Key] = principal; +} + +/// +/// 认证:Authorization: Bearer <api_token> 或会话 Cookie。作为中间件运行,只解析身份;授权由各层的守卫决定。 +/// +public static class PrincipalResolver +{ + public const string SessionCookie = "monitor_session"; + public const string CsrfHeader = "X-CSRF-Token"; + + public static async Task ResolveAsync(HttpContext http, Func next) + { + AuthService auth = http.RequestServices.GetRequiredService(); + Principal? principal = null; + string? header = http.Request.Headers.Authorization; + if (header is not null && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + principal = await auth.AuthenticateTokenAsync(header["Bearer ".Length..].Trim(), http.RequestAborted); + } + else if (http.Request.Cookies.TryGetValue(SessionCookie, out string? session) && !string.IsNullOrEmpty(session)) + { + principal = await auth.AuthenticateSessionAsync(session, http.RequestAborted); + } + + http.SetPrincipal(principal ?? Principal.Anonymous); + await next(); + } +} + +/// 管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 +public static class AdminGuard +{ + public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + HttpContext http = context.HttpContext; + Principal principal = http.GetPrincipal(); + if (!principal.IsAdmin) + { + return principal.CanRead + ? Problems.Create(http, MonitorErrors.Forbidden, "the token scope does not allow this operation") + : Problems.Create(http, MonitorErrors.Unauthorized, "authentication is required"); + } + + if (principal.Kind == PrincipalKind.Session && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method)) + { + string? csrf = http.Request.Headers[PrincipalResolver.CsrfHeader]; + if (csrf is null || principal.CsrfToken is null || !AuthService.FixedTimeEquals(csrf, principal.CsrfToken)) + { + return Problems.Create(http, MonitorErrors.CsrfFailed, "a valid X-CSRF-Token header is required"); + } + } + + return await next(context); + } +} + +/// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 +public static class PublicGuard +{ + public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + HttpContext http = context.HttpContext; + if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) + { + return Problems.Create(http, MonitorErrors.SitePrivate, "this site is private; sign in or use a read-only token"); + } + + return await next(context); + } + + /// 被隐藏(public_visible = false)的线路只对已认证的访问者可见。 + public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead; +} diff --git a/backend/src/Monitor.Backend.Api/Common/Mappers.cs b/backend/src/Monitor.Backend.Api/Common/Mappers.cs new file mode 100644 index 0000000..79e3d7a --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/Mappers.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using Fleet.Server; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 应用层模型 → REST DTO。public 层的 DTO 只暴露可以公开的字段。 +public static class Mappers +{ + public static DateTimeOffset Time(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms); + + public static DateTimeOffset? Time(long? ms) => ms is { } v ? DateTimeOffset.FromUnixTimeMilliseconds(v) : null; + + public static string StatusName(NodeStatus status) => status switch + { + NodeStatus.Active => "active", + NodeStatus.Disabled => "disabled", + _ => "pending", + }; + + public static TestSummaryDto? Summary(TestRecord? test) => + test is null ? null : new TestSummaryDto(test.Id.Value, test.Plan, test.Status, Time(test.RequestedAtMs), Time(test.FinishedAtMs)); + + public static SummaryDto ToDto(SummaryResult summary) => + new(summary.Values, summary.Samples.ToDictionary(s => s.Key, s => checked((long)s.Value)), summary.Coverage); + + public static EventDto ToDto(EventRecord e) => new(e.Id, e.AgentId?.Value, e.Kind, Time(e.AtMs), e.Detail); + + public static OverviewLineDto ToDto(OverviewLine line) => + new( + line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels, + line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest)); + + public static HostDto ToDto(HostSnapshot h) => + new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.UptimeSeconds, Time(h.AtUtcMs)); + + public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs)); + + public static ProbeDto ToDto(ProbeView v) => + new( + v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, + [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs)))]); + + public static SeriesDto ToDto(SeriesResult r, bool live) => + new( + r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded, + r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial), + [.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions); + + public static TestDto ToDto(TestDetail detail) + { + TestRecord t = detail.Test; + return new TestDto( + t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger, + Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result)); + } + + public static TestDto ToDto(TestRecord t) => + new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null); + + public static TestResultDto ToDto(TestResultView r) => + new( + r.Metrics, + r.Scores.ToDictionary(s => s.Key, s => new ScoreDto(s.Value.Score, s.Value.Version, s.Value.Estimated, [.. s.Value.Dimensions.Select(d => new DimensionDto(d.Id, d.Label, d.Weight, d.Value, d.Score))], s.Value.Caveats)), + r.Series, + [.. r.Steps.Select(s => new TestStepDto(s.Index, s.TargetMbps, s.LossPct, s.JitterP95Ms, s.DeliveredMbps))], + r.Caveats); + + public static AgentDto ToDto(AgentView view) + { + AgentRecord a = view.Agent; + LocalLimitsDto? limits = null; + if (a.LocalLimitsJson is { } json) + { + try + { + LocalLimits? parsed = JsonSerializer.Deserialize(json, ApiJsonContext.Default.LocalLimits); + limits = parsed is null ? null : new LocalLimitsDto(parsed.MaxTotalMbps, parsed.MaxTotalPps, parsed.AllowedTargetCidrs, parsed.MaxConcurrentTests); + } + catch (JsonException) + { + // 无法解析就当作没有上报 + } + } + + IReadOnlyList rejected = []; + if (a.RejectedJson is { } rejectedJson) + { + try + { + rejected = [.. (JsonSerializer.Deserialize(rejectedJson, ApiJsonContext.Default.ListRejectedItem) ?? []).Select(r => new RejectedDto(r.Id, r.Reason))]; + } + catch (JsonException) + { + // 同上 + } + } + + HeartbeatDto? heartbeat = view.Heartbeat is { } h + ? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0)) + : null; + return new AgentDto( + a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible, + a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs)); + } + + public static TaskDto ToDto(TaskRecord t) => + new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); + + public static JoinTokenDto ToDto(JoinTokenInfo t) => + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs)); + + public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs)); + + public static RawChunkMetaDto ToDto(RawChunkMeta m) => + new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length); + + public static SettingDto ToDto(SettingView s) => new(s.Key, s.Type, s.Value, s.Default, s.Description); + + public static UserDto ToDto(UserRecord u) => new(u.Id, u.Username, Time(u.CreatedAtMs)); + + // ── 目录 ── + + public static KindDto ToDto(KindSpec k) => + new( + k.Id, k.Title, k.Description, + [.. Enum.GetValues().Where(s => k.Scopes.HasFlag(s)).Select(s => s.ToString().ToLowerInvariant())], + new BlocksDto(k.Blocks.Transport, k.Blocks.Pacer, k.Blocks.Payload, k.Blocks.Behavior, k.Blocks.Samplers), + [.. k.Params.Select(ToDto)], + [.. k.Streams.Select(s => new StreamDto(s.Name, [.. s.Columns.Select(c => new ColumnDto(c.Name, RawChunkCodec.TypeName(c.Type), c.Enc.ToString().ToLowerInvariant(), c.Nullable, RoleName(c.Role)))]))]); + + public static ParamDto ToDto(ParamSpec p) => p switch + { + IntParam i => new ParamDto(i.Name, "int", i.DefaultValue, i.Min, i.Max, i.Unit, null, i.Description), + NumberParam n => new ParamDto(n.Name, "number", n.DefaultValue, n.Min, n.Max, n.Unit, null, n.Description), + BoolParam b => new ParamDto(b.Name, "bool", b.DefaultValue, null, null, null, null, b.Description), + EnumParam e => new ParamDto(e.Name, "enum", e.DefaultValue, null, null, null, e.Values, e.Description), + _ => new ParamDto(p.Name, "unknown", p.DefaultValue, null, null, null, null, p.Description), + }; + + public static PlanDto ToDto(PlanSpec p) => + new(p.Id, p.Title, p.Description, [.. p.Phases.Select(ph => new PhaseDto(ph.Kind, ph.DurationSeconds, JsonSerializer.SerializeToElement(ph.Overrides.ToDictionary(o => o.Key, o => o.Value), ApiJsonContext.Default.SettingsPatch)))]); + + public static MetricDto ToDto(MetricDef def, MetricExport export) => + new( + export.Id, def.Id, def.Kind.ToString().ToLowerInvariant(), Metric.StatName(export.Stat), def.Label, def.Description, + export.Unit.ToString().ToLowerInvariant(), def.Better.ToString().ToLowerInvariant(), export.Good, export.Poor, def.Fingerprint, def.From); + + public static ModelDto ToDto(IServiceModel m) => new(m.Id, m.Title, m.Version, m.Needs); + + private static string? RoleName(ColumnRole role) => role switch + { + ColumnRole.TsProbe => "ts_probe", + ColumnRole.TsAgent => "ts_agent", + ColumnRole.TsLocal => "ts_local", + _ => null, + }; +} diff --git a/backend/src/Monitor.Backend.Api/Common/Problems.cs b/backend/src/Monitor.Backend.Api/Common/Problems.cs new file mode 100644 index 0000000..841ca91 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/Problems.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using Fleet.Core; +using Microsoft.AspNetCore.Http; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 错误码到 HTTP 状态的映射:只在 Api 层做一次。 +public static class ErrorCodeHttpMap +{ + // 必须声明在 s_map 之前:静态字段按文本顺序初始化,BuildMap 会用到它 + public static readonly ErrorCode NotImplemented = new("not_implemented"); + + private static readonly Dictionary s_map = BuildMap(); + + public static int StatusOf(ErrorCode code) => s_map.GetValueOrDefault(code.Value, StatusCodes.Status500InternalServerError); + + public static string TitleOf(int status) => status switch + { + 400 => "Validation failed", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not found", + 409 => "Conflict", + 410 => "Gone", + 412 => "Precondition failed", + 413 => "Payload too large", + 422 => "Unprocessable request", + 429 => "Too many requests", + 501 => "Not implemented", + 503 => "Service unavailable", + _ => "Internal error", + }; + + private static Dictionary BuildMap() + { + var map = new Dictionary(StringComparer.Ordinal); + void Add(int status, params ErrorCode[] codes) + { + foreach (ErrorCode code in codes) + { + map[code.Value] = status; + } + } + + Add(400, MonitorErrors.ValidationFailed, MonitorErrors.BudgetExceeded, FleetErrors.InvalidParams); + Add(401, MonitorErrors.Unauthorized, MonitorErrors.CsrfFailed, FleetErrors.Unauthorized); + Add(403, MonitorErrors.Forbidden, MonitorErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid); + Add( + 404, MonitorErrors.NotFound, MonitorErrors.AgentNotFound, MonitorErrors.ProbeNotFound, MonitorErrors.TaskNotFound, MonitorErrors.TestNotFound, MonitorErrors.ChunkNotFound, + MonitorErrors.TokenNotFound, MonitorErrors.UnknownKind, MonitorErrors.UnknownPlan, FleetErrors.NotFound); + Add(409, MonitorErrors.NameConflict, MonitorErrors.ProbeRevoked, MonitorErrors.ProbeOffline, MonitorErrors.TestBusy, MonitorErrors.AgentNotActive, MonitorErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline); + Add(410, MonitorErrors.RawExpired, MonitorErrors.JoinTokenExpired, FleetErrors.JoinTokenExpired); + Add(412, MonitorErrors.PreconditionFailed); + Add(413, MonitorErrors.PayloadTooLarge); + Add(422, MonitorErrors.ProbeRequired, MonitorErrors.RangeTooLarge, MonitorErrors.InvalidStep, MonitorErrors.InvalidRange, MonitorErrors.UnknownMetric, MonitorErrors.UnknownPreset, MonitorErrors.TooManyMetrics, FleetErrors.Unsupported); + Add(429, MonitorErrors.TooManyRequests, MonitorErrors.TooManyStreams); + Add(503, MonitorErrors.StorageUnavailable, FleetErrors.Timeout); + map[NotImplemented.Value] = 501; + return map; + } + +} + +/// 所有 4xx/5xx 响应都使用 problem+json;5xx 不泄露异常信息,只提供 trace_id。 +public static class Problems +{ + public const string ContentType = "application/problem+json"; + + public static IResult From(HttpContext http, FleetError error) + { + int status = ErrorCodeHttpMap.StatusOf(error.Code); + IReadOnlyDictionary? fields = error is ValidationError v + ? v.Fields.ToDictionary(f => f.Key, f => new[] { f.Value }) + : null; + return Create(http, status, error.Code.Value, status >= 500 ? null : error.Message, fields); + } + + public static IResult Create(HttpContext http, ErrorCode code, string? detail) => + Create(http, ErrorCodeHttpMap.StatusOf(code), code.Value, detail, null); + + public static IResult Create(HttpContext http, int status, string code, string? detail, IReadOnlyDictionary? errors = null) + { + string? trace = Activity.Current?.Id ?? http.TraceIdentifier; + var problem = new ProblemDto("about:blank", ErrorCodeHttpMap.TitleOf(status), status, code, detail, trace, errors); + return TypedResults.Json(problem, ApiJsonContext.Default.ProblemDto, ContentType, status); + } + + public static IResult RetryAfter(HttpContext http, FleetError error, int seconds) + { + http.Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + return From(http, error); + } +} diff --git a/backend/src/Monitor.Backend.Api/Common/QueryHelpers.cs b/backend/src/Monitor.Backend.Api/Common/QueryHelpers.cs new file mode 100644 index 0000000..f6e8ef8 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Common/QueryHelpers.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +internal static class QueryHelpers +{ + /// 查询的统一超时(默认 10 s):超时返回 503,而不是让请求一直挂着。 + public static async Task GuardedAsync(HttpContext http, Func> work) + { + BackendOptions options = http.RequestServices.GetRequiredService(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted); + timeout.CancelAfter(options.QueryTimeout); + try + { + return await work(timeout.Token); + } + catch (OperationCanceledException) when (!http.RequestAborted.IsCancellationRequested) + { + return Problems.Create(http, MonitorErrors.StorageUnavailable, null); + } + } + + /// + /// 缓存策略:已经结束的绝对区间(to 早于当前时间 − 2 分钟)可以永久缓存,并带 ETag;相对时间段不缓存。 + /// 返回非空时表示命中 If-None-Match,直接返回 304。 + /// + public static IResult? ApplyCaching(HttpContext http, QueryRange range, long nowMs, string material) + { + bool closed = !range.IsRelative && range.RequestedToMs < nowMs - (2 * Steps.Minute); + if (!closed) + { + http.Response.Headers.CacheControl = "no-store"; + return null; + } + + string etag = ETagOf(http.Request.Path + http.Request.QueryString.Value + material); + http.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; + http.Response.Headers.ETag = etag; + return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null; + } + + /// 目录:max-age=300,并带 ETag。 + public static IResult? ApplyCatalogCaching(HttpContext http, string material) + { + string etag = ETagOf(http.Request.Path + material); + http.Response.Headers.CacheControl = "public, max-age=300"; + http.Response.Headers.ETag = etag; + return MatchesIfNoneMatch(http, etag) ? TypedResults.StatusCode(StatusCodes.Status304NotModified) : null; + } + + public static string ETagOf(string material) => + "\"" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..32] + "\""; + + private static bool MatchesIfNoneMatch(HttpContext http, string etag) + { + foreach (string? candidate in http.Request.Headers.IfNoneMatch) + { + if (candidate is not null && candidate.Split(',', StringSplitOptions.TrimEntries).Contains(etag)) + { + return true; + } + } + + return false; + } + + /// 线路 id(agent~probe,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。 + public static async ValueTask> FindVisibleLineAsync(HttpContext http, LineService lines, string id, CancellationToken ct) + { + Result found = await lines.ResolveAsync(id, ct); + if (!found.TryGet(out LineView? line, out FleetError? error)) + { + return Result.Fail(error); + } + + return line.Agent.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Agent.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Probe.RevokedAtMs is null + ? found + : Result.Fail(MonitorErrors.AgentNotFound, "line not found"); + } + + /// 找到可以展示的线路:已激活,并且(对外可见,或者访问者已认证)。否则 404(不暴露被隐藏的线路存在与否)。 + public static async ValueTask> FindVisibleAgentAsync(HttpContext http, AgentService agents, string id, CancellationToken ct) + { + AgentView? view = await agents.GetAsync(new NodeId(id), ct); + return view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) + ? Result.Ok(view) + : Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } +} diff --git a/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs new file mode 100644 index 0000000..96fffa0 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Contracts/AdminDtos.cs @@ -0,0 +1,123 @@ +using System.Text.Json; + +namespace Monitor.Backend.Api; + +public sealed record LoginRequest(string Username, string Password); + +public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt); + +public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken); + +public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList AllowedTargetCidrs, int MaxConcurrentTests); + +public sealed record RejectedDto(string Id, string Reason); + +public sealed record HeartbeatDto(int Sessions, long PpsIn, long PpsOut, long DroppedInvalid, long DroppedRateLimited, long QueueBytes, DateTimeOffset At); + +public sealed record AgentDto( + string Id, + string Name, + string Status, + bool Online, + bool ProbeConnected, + IReadOnlyList Probes, + HostDto? Host, + string PublicHost, + int ProbeTcpPort, + IReadOnlyList ProbeUdpPorts, + IReadOnlyList Labels, + bool PublicVisible, + IReadOnlyList Kinds, + string? AgentVersion, + LocalLimitsDto? LocalLimits, + long TaskVersion, + long AppliedTaskVersion, + IReadOnlyList Rejected, + HeartbeatDto? Heartbeat, + int EndpointChanges, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt); + +/// merge-patch:缺省或为 null 的字段不修改。 +public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible); + +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt); + +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours); + +/// 令牌明文只在创建的响应里出现一次。 +public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, string InstallCommand); + +/// Agent 眼中连着的一个 Probe(Agent 上报)。 +public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince); + +/// 某个 Probe 现在连着的一个 Agent。 +public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince); + +public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, IReadOnlyList Connections); + +public sealed record BootstrapAgentDto(string AgentId, string Host, int TcpPort, string Spki); + +public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps); + +/// probe.json:含私钥,只在生成时返回一次。 +public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, IReadOnlyList Bootstrap, ProbeLimitsDto Limits); + +public sealed record CreateProbeRequest(string? Name); + +public sealed record PatchProbeRequest(string? Name); + +public sealed record TaskDto( + string Id, + string Scope, + string? AgentId, + string? BaseTaskId, + string Kind, + string? Target, + IReadOnlyDictionary Params, + string Schedule, + bool Enabled, + DateTimeOffset UpdatedAt); + +public sealed record CreateTaskRequest( + string Scope, + string Kind, + string? Target, + Dictionary? Params, + string? Schedule, + bool? Enabled, + string? AgentId, + string? BaseTaskId); + +public sealed record PatchTaskRequest(Dictionary? Params, string? Schedule, bool? Enabled, string? Target); + +public sealed record StartTestRequestDto(string Plan, Dictionary? Params); + +public sealed record RawChunkMetaDto( + string ChunkId, + string AgentId, + string Series, + string Kind, + string? TaskId, + string? TestId, + int? Phase, + DateTimeOffset Start, + int DurationMs, + bool Partial, + int Bytes); + +public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt); + +public sealed record CreateApiTokenRequest(string Name, string Scope); + +public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext); + +public sealed record SettingDto(string Key, string Type, string Value, string Default, string Description); + +public sealed record UserDto(string Id, string Username, DateTimeOffset CreatedAt); + +public sealed record CreateUserRequest(string Username, string Password); + +public sealed record ChangePasswordRequest(string Password); + +public sealed record RecomputeDto(string State, IReadOnlyList Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength); diff --git a/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs new file mode 100644 index 0000000..1f35807 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Contracts/ApiJsonContext.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Monitor.Backend.Api; + +/// +/// REST 的 JSON 源生成上下文(snake_case,字段始终存在、未知值写 null,枚举与时间的格式见数据表示规则)。 +/// 新增 DTO 时在这里登记一行,不使用反射。 +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = JsonKnownNamingPolicy.Unspecified, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + Converters = [typeof(Rfc3339Converter)], + NumberHandling = JsonNumberHandling.Strict)] +[JsonSerializable(typeof(ProblemDto))] +[JsonSerializable(typeof(HealthDto))] +[JsonSerializable(typeof(IdDto))] +[JsonSerializable(typeof(OverviewDto))] +[JsonSerializable(typeof(SeriesDto))] +[JsonSerializable(typeof(CompareDto))] +[JsonSerializable(typeof(DistributionDto))] +[JsonSerializable(typeof(HeatmapDto))] +[JsonSerializable(typeof(LineDto))] +[JsonSerializable(typeof(TestDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(LiveNodeDto))] +[JsonSerializable(typeof(LiveTestDto))] +[JsonSerializable(typeof(LoginRequest))] +[JsonSerializable(typeof(LoginResponse))] +[JsonSerializable(typeof(MeDto))] +[JsonSerializable(typeof(AgentDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(AgentPatchRequest))] +[JsonSerializable(typeof(JoinTokenDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(CreateJoinTokenRequest))] +[JsonSerializable(typeof(CreatedJoinTokenDto))] +[JsonSerializable(typeof(ProbeIdentityDto))] +[JsonSerializable(typeof(CreateProbeRequest))] +[JsonSerializable(typeof(PatchProbeRequest))] +[JsonSerializable(typeof(ProbeDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(HostSeriesDto))] +[JsonSerializable(typeof(LiveHostDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(TaskDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(CreateTaskRequest))] +[JsonSerializable(typeof(PatchTaskRequest))] +[JsonSerializable(typeof(StartTestRequestDto))] +[JsonSerializable(typeof(RawChunkMetaDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(ApiTokenDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(CreateApiTokenRequest))] +[JsonSerializable(typeof(CreatedApiTokenDto))] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] +[JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(UserDto))] +[JsonSerializable(typeof(CreateUserRequest))] +[JsonSerializable(typeof(ChangePasswordRequest))] +[JsonSerializable(typeof(RecomputeDto))] +[JsonSerializable(typeof(Monitor.Protocol.LocalLimits))] +[JsonSerializable(typeof(List))] +public sealed partial class ApiJsonContext : JsonSerializerContext; diff --git a/backend/src/Monitor.Backend.Api/Contracts/CommonDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/CommonDtos.cs new file mode 100644 index 0000000..0095b20 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Contracts/CommonDtos.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Monitor.Backend.Api; + +/// 时间点统一使用 RFC 3339 UTC,以 Z 结尾,精确到毫秒(唯一例外:列式时序里的时间轴 t 是 Unix 毫秒)。 +public sealed class Rfc3339Converter : JsonConverter +{ + private const string Format = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + DateTimeOffset.Parse(reader.GetString() ?? throw new JsonException("timestamp must be a string"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => + writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture)); +} + +/// RFC 9457 problem+json,附加 code(客户端判断的唯一依据)、trace_id 和 400 时的逐字段 errors。 +public sealed record ProblemDto( + string Type, + string Title, + int Status, + string Code, + string? Detail, + string? TraceId, + IReadOnlyDictionary? Errors); + +public sealed record PageDto(IReadOnlyList Items, string? NextCursor); + +public sealed record IdDto(string Id); + +public sealed record HealthDto(string Status, bool Database, bool RawDirectory); diff --git a/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs new file mode 100644 index 0000000..552006b --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Contracts/PublicDtos.cs @@ -0,0 +1,175 @@ +namespace Monitor.Backend.Api; + +/// 汇总:整个时间段由累加器精确合并得出的值(不是各点的平均)。 +public sealed record SummaryDto(IReadOnlyDictionary Metrics, IReadOnlyDictionary Samples, double Coverage); + +public sealed record EdgePartialDto(bool First, bool Last); + +public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason); + +public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail); + +public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt); + +public sealed record OverviewLineDto( + string Id, + string AgentId, + string ProbeId, + string ProbeName, + string Name, + IReadOnlyList Labels, + bool Online, + bool ProbeConnected, + int EndpointChanges, + SummaryDto Summary, + TestSummaryDto? LastTest); + +public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList Lines); + +/// 列式响应:时间轴 t(Unix 毫秒)只出现一次,values 里每个数组的长度都与 t 相同;没有数据的桶是 null。 +public sealed record SeriesDto( + string LineId, + string AgentId, + string ProbeId, + DateTimeOffset From, + DateTimeOffset To, + string Step, + string Source, + bool Live, + bool Degraded, + IReadOnlyList T, + IReadOnlyDictionary Values, + SummaryDto Summary, + EdgePartialDto EdgePartial, + IReadOnlyList Gaps, + IReadOnlyList Events, + IReadOnlyDictionary Versions); + +public sealed record CompareDto( + string Metric, + DateTimeOffset From, + DateTimeOffset To, + string Step, + string Source, + bool Degraded, + IReadOnlyList T, + IReadOnlyDictionary Values, + IReadOnlyDictionary Summary); + +public sealed record DistributionBucketDto(double Le, long Count); + +public sealed record DistributionDto(string Metric, string Unit, IReadOnlyList Buckets, double? P50, double? P95, double? P99, long Count); + +public sealed record HeatmapDto( + string Metric, + string Step, + string Source, + bool Degraded, + IReadOnlyList T, + IReadOnlyList Edges, + IReadOnlyList> Counts); + +/// 一条线路 = 一对(Agent,Probe);id 是 <agent_id>~<probe_id>。 +public sealed record LineDto( + string Id, + string AgentId, + string ProbeId, + string ProbeName, + string Name, + IReadOnlyList Labels, + bool Online, + bool ProbeConnected, + DateTimeOffset? DataSince, + IReadOnlyList Kinds, + TestSummaryDto? LastTest); + +public sealed record DimensionDto(string Id, string Label, double Weight, double? Value, double? Score); + +public sealed record ScoreDto(double? Score, int Version, bool Estimated, IReadOnlyList Dimensions, IReadOnlyList Caveats); + +public sealed record TestStepDto(int Index, double TargetMbps, double LossPct, double? JitterP95Ms, double DeliveredMbps); + +public sealed record TestResultDto( + IReadOnlyDictionary Metrics, + IReadOnlyDictionary Scores, + IReadOnlyDictionary Series, + IReadOnlyList Steps, + IReadOnlyList Caveats); + +public sealed record TestDto( + string Id, + string AgentId, + string ProbeId, + string Plan, + IReadOnlyList Phases, + string Status, + int? Phase, + string? ErrorCode, + string Trigger, + DateTimeOffset RequestedAt, + DateTimeOffset? StartedAt, + DateTimeOffset? FinishedAt, + TestResultDto? Result); + +// ── 目录(由代码注册表生成) ────────────────────────────────────────── + +public sealed record ParamDto(string Name, string Type, System.Text.Json.JsonElement Default, double? Min, double? Max, string? Unit, IReadOnlyList? Values, string Description); + +public sealed record BlocksDto(string Transport, string Pacer, string Payload, string Behavior, IReadOnlyList Samplers); + +public sealed record ColumnDto(string Name, string Type, string Encoding, bool Nullable, string? Role); + +public sealed record StreamDto(string Name, IReadOnlyList Columns); + +public sealed record KindDto(string Id, string Title, string Description, IReadOnlyList Scopes, BlocksDto Blocks, IReadOnlyList Params, IReadOnlyList Streams); + +public sealed record PhaseDto(string Kind, int DurationS, System.Text.Json.JsonElement Params); + +public sealed record PlanDto(string Id, string Title, string Description, IReadOnlyList Phases); + +public sealed record MetricDto( + string Id, + string DefinitionId, + string Accumulator, + string Stat, + string Label, + string Description, + string Unit, + string Better, + double? Good, + double? Poor, + string Version, + IReadOnlyList From); + +public sealed record PresetDto(string Id, string Label, IReadOnlyList Metrics); + +public sealed record ModelDto(string Id, string Title, int Version, IReadOnlyList Needs); + +public sealed record LiveNodeDto(string Kind, string Id, bool Online, DateTimeOffset At); + +public sealed record LiveTestDto(string TestId, string Status, int? Phase, int Phases); + +/// +/// 主机资源占用(展示页能拿到的全部内容):只有汇总的数字,没有 CPU 型号、主机名、网卡名,也没有任何 IP 地址。 +/// 这些字段 Agent 根本不采集,所以即使这里写漏了也泄漏不出去。 +/// +public sealed record HostDto( + double? CpuPercent, + long? MemUsed, + long? MemTotal, + long? DiskUsed, + long? DiskTotal, + long? NetRxTotal, + long? NetTxTotal, + long? NetRxBps, + long? NetTxBps, + long? UptimeSeconds, + DateTimeOffset At); + +/// 一个 Agent 的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者 Agent 关闭了上报)。 +public sealed record HostItemDto(string AgentId, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host); + +public sealed record LiveHostDto(string AgentId, bool Online, HostDto Host); + +/// 主机资源占用的历史(列式,按步长取平均)。 +public sealed record HostSeriesDto(string AgentId, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList T, IReadOnlyDictionary Values); diff --git a/backend/src/Monitor.Backend.Api/Monitor.Backend.Api.csproj b/backend/src/Monitor.Backend.Api/Monitor.Backend.Api.csproj new file mode 100644 index 0000000..95eddf2 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Monitor.Backend.Api.csproj @@ -0,0 +1,11 @@ + + + Library + true + true + + + + + + diff --git a/backend/src/Monitor.Backend.Api/OpsEndpoints.cs b/backend/src/Monitor.Backend.Api/OpsEndpoints.cs new file mode 100644 index 0000000..1e51dbc --- /dev/null +++ b/backend/src/Monitor.Backend.Api/OpsEndpoints.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 运维端点:/healthz、/metrics(admin 令牌)、安装脚本。 +public static class OpsEndpoints +{ + private const string Tag = "ops"; + + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("/healthz", HealthAsync) + .Api("health_get", "SQLite and the raw data directory are both writable", Tag) + .Returns().ProducesProblems(503); + app.MapGet("/metrics", MetricsAsync) + .Api("metrics_get", "Prometheus metrics (admin token)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); + app.MapGet("/install.sh", (HttpContext http) => NotShipped(http)) + .Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501); + app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http)) + .Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501); + } + + private static async Task HealthAsync(HttpContext http, OpsService ops) + { + StorageHealth health = await ops.HealthAsync(http.RequestAborted); + bool ok = health.Database && health.RawDirectory; + HealthDto dto = new(ok ? "ok" : "unavailable", health.Database, health.RawDirectory); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(dto, ApiJsonContext.Default.HealthDto, statusCode: ok ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); + } + + private static async Task MetricsAsync(HttpContext http, OpsService ops) + { + Principal principal = http.GetPrincipal(); + if (!principal.IsAdmin) + { + return principal.CanRead + ? Problems.Create(http, MonitorErrors.Forbidden, "an admin token is required") + : Problems.Create(http, MonitorErrors.Unauthorized, "an admin token is required"); + } + + return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4"); + } + + /// 安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。 + private static IResult NotShipped(HttpContext http) => + Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)"); +} diff --git a/backend/src/Monitor.Backend.Api/Public/CatalogEndpoints.cs b/backend/src/Monitor.Backend.Api/Public/CatalogEndpoints.cs new file mode 100644 index 0000000..34f1779 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Public/CatalogEndpoints.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 目录:由代码里的注册表生成。新增 Kind、方案、指标、分组、模型后,目录自动更新,前端不需要改。 +public static class CatalogEndpoints +{ + private const string Tag = "catalog"; + + public static void Map(RouteGroupBuilder group) + { + RouteGroupBuilder catalog = group.MapGroup("/catalog"); + catalog.MapGet("/kinds", Kinds) + .Api("catalog_kinds", "Measurement kinds with their building blocks and parameter specs", Tag).Returns>().ProducesProblems(403); + catalog.MapGet("/plans", Plans) + .Api("catalog_plans", "On-demand test plans", Tag).Returns>().ProducesProblems(403); + catalog.MapGet("/metrics", Metrics) + .Api("catalog_metrics", "Metric ids with unit, label, direction, thresholds and version", Tag).Returns>().ProducesProblems(403); + catalog.MapGet("/presets", Presets) + .Api("catalog_presets", "Metric groups the front end can display", Tag).Returns>().ProducesProblems(403); + catalog.MapGet("/models", Models) + .Api("catalog_models", "Service models and the metrics they need", Tag).Returns>().ProducesProblems(403); + } + + private static IResult Kinds(HttpContext http) => + QueryHelpers.ApplyCatalogCaching(http, string.Join(',', Monitor.Protocol.Kinds.All.Select(k => k.Id))) + ?? TypedResults.Json(new PageDto([.. Monitor.Protocol.Kinds.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoKindDto); + + private static IResult Plans(HttpContext http) => + QueryHelpers.ApplyCatalogCaching(http, string.Join(',', Monitor.Backend.Application.Plans.All.Select(p => p.Id))) + ?? TypedResults.Json(new PageDto([.. Monitor.Backend.Application.Plans.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPlanDto); + + private static IResult Metrics(HttpContext http) => + QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.All.Select(m => m.Fingerprint))) + ?? TypedResults.Json( + new PageDto([.. MetricRegistry.AllExports().Select(e => Mappers.ToDto(e.Def, e.Export))], null), + ApiJsonContext.Default.PageDtoMetricDto); + + private static IResult Presets(HttpContext http) => + QueryHelpers.ApplyCatalogCaching(http, string.Join(',', MetricRegistry.Presets.Select(p => p.Id + string.Join('|', p.MetricIds)))) + ?? TypedResults.Json( + new PageDto([.. MetricRegistry.Presets.Select(p => new PresetDto(p.Id, p.Label, p.MetricIds))], null), + ApiJsonContext.Default.PageDtoPresetDto); + + private static IResult Models(HttpContext http) => + QueryHelpers.ApplyCatalogCaching(http, string.Join(',', ModelRegistry.All.Select(m => m.Id + m.Version))) + ?? TypedResults.Json(new PageDto([.. ModelRegistry.All.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoModelDto); +} diff --git a/backend/src/Monitor.Backend.Api/Public/HostEndpoints.cs b/backend/src/Monitor.Backend.Api/Public/HostEndpoints.cs new file mode 100644 index 0000000..1430e74 --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Public/HostEndpoints.cs @@ -0,0 +1,97 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// Agent 的主机资源占用:最新值(实时的部分走 SSE 的 hosts 主题)和历史曲线。只有汇总的数字,没有型号和 IP。 +public static class HostEndpoints +{ + private const string Tag = "public"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/hosts", ListAsync) + .Api("hosts_list", "Latest host resource usage of every visible agent (no hardware model, no addresses)", Tag) + .Returns>().ProducesProblems(403); + group.MapGet("/agents/{agent_id}/host", HistoryAsync) + .Api("hosts_history", "Host resource usage history of one agent (columnar, averaged per step)", Tag) + .Returns().ProducesProblems(403, 404, 422); + } + + private static async Task ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct) + { + var items = new List(); + foreach (AgentView view in await agents.ListAsync(ct)) + { + if (view.Agent.Status != Fleet.Server.NodeStatus.Active || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) + { + continue; + } + + (HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id); + items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host))); + } + + return TypedResults.Json(new PageDto(items, null), ApiJsonContext.Default.PageDtoHostItemDto); + } + + private static async Task HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string agent_id, string? range, CancellationToken ct) + { + AgentView? view = await agents.GetAsync(new NodeId(agent_id), ct); + if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) + { + return Problems.Create(http, MonitorErrors.AgentNotFound, "agent not found"); + } + + if (!RangeResolver.TryParseSpan(range ?? "1h", out long span) || span < Steps.Hour || span > 30 * Steps.Day) + { + return Problems.From(http, new FleetError(MonitorErrors.InvalidRange, "range must look like 1h, 6h, 24h or 7d (between 1 hour and 30 days)")); + } + + long now = time.GetUtcNow().ToUnixTimeMilliseconds(); + long from = now - span; + IReadOnlyList samples = await hosts.HistoryAsync(view.Agent.Id, from, now + 60_000, ct); + + // 最多约 300 个点:按分钟的整数倍取步长,每个步长内取平均 + long stepMs = Math.Max(60_000, (long)Math.Ceiling(span / 300.0 / 60_000) * 60_000); + long first = from - (from % stepMs); + int points = (int)(((now - first) / stepMs) + 1); + var t = new long[points]; + for (int i = 0; i < points; i++) + { + t[i] = first + (i * stepMs); + } + + double?[] Average(Func pick) + { + var sum = new double[points]; + var count = new int[points]; + foreach (HostSample s in samples) + { + int index = (int)((s.AtMs - first) / stepMs); + if (index >= 0 && index < points && pick(s) is { } v) + { + sum[index] += v; + count[index]++; + } + } + + return [.. Enumerable.Range(0, points).Select(i => count[i] == 0 ? (double?)null : Math.Round(sum[i] / count[i], 2))]; + } + + static double? Percent(long? used, long? total) => used is { } u && total is { } tot && tot > 0 ? 100.0 * u / tot : null; + var values = new Dictionary(StringComparer.Ordinal) + { + ["cpu"] = Average(s => s.Cpu), + ["mem"] = Average(s => Percent(s.MemUsed, s.MemTotal)), + ["disk"] = Average(s => Percent(s.DiskUsed, s.DiskTotal)), + ["rx_bps"] = Average(s => s.NetRxBps), + ["tx_bps"] = Average(s => s.NetTxBps), + }; + return TypedResults.Json(new HostSeriesDto(view.Agent.Id.Value, Mappers.Time(first), Mappers.Time(now), Steps.Format(stepMs), t, values), ApiJsonContext.Default.HostSeriesDto); + } +} diff --git a/backend/src/Monitor.Backend.Api/Public/LiveEndpoints.cs b/backend/src/Monitor.Backend.Api/Public/LiveEndpoints.cs new file mode 100644 index 0000000..8c6f2cb --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Public/LiveEndpoints.cs @@ -0,0 +1,367 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// +/// 实时推送(SSE):先推送当前快照,再推送增量(不补发历史)。SSE 只负责“更快地获得变化”, +/// 推送的内容都能由对应的 REST 查询重建。实时延迟取决于原始块的切块节奏(10 s)加上处理时间,典型值 10~12 s。 +/// +public static class LiveEndpoints +{ + private static readonly TimeSpan s_tick = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan s_debounce = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_heartbeat = TimeSpan.FromSeconds(15); + private static readonly TimeSpan s_periodicRefresh = TimeSpan.FromSeconds(10); + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/live", StreamAsync) + .Api("live_stream", "Server-sent events: nodes, overview, series and test topics", "public") + .Produces(StatusCodes.Status200OK, contentType: "text/event-stream") + .ProducesProblems(400, 403, 404, 422, 429); + } + + private static async Task StreamAsync( + HttpContext http, AgentService agents, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, + BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime) + { + StringValues raw = http.Request.Query["topics"]; + Result> parsed = LiveTopic.ParseAll(raw.Where(v => v is not null)!, options.MaxSseTopics); + if (!parsed.TryGet(out IReadOnlyList? topics, out FleetError? error)) + { + return Problems.From(http, error); + } + + // 订阅的线路必须存在并且对访问者可见 + var lineKeys = new Dictionary(StringComparer.Ordinal); + foreach (LiveTopic topic in topics.Where(t => t.Line is not null)) + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lineService, topic.Line!, http.RequestAborted); + if (!found.TryGet(out LineView? resolved, out FleetError? lineError)) + { + return Problems.From(http, lineError); + } + + lineKeys[topic.Raw] = resolved.Key; + } + + foreach (LiveTopic topic in topics.Where(t => t.Preset is not null)) + { + Result selection = MetricSelection.Resolve(null, topic.Preset, options.MaxMetricsPerQuery); + if (!selection.IsSuccess) + { + return Problems.From(http, selection.Error); + } + } + + string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!limiter.TryAcquire(client)) + { + return Problems.Create(http, MonitorErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client"); + } + + counters.SseOpened(); + try + { + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-store"; + http.Response.Headers["X-Accel-Buffering"] = "no"; + await http.Response.Body.FlushAsync(http.RequestAborted); + + var session = new LiveSession(http, agents, lineService, tests, query, hub, options, time, topics, lineKeys); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping); + await session.RunAsync(linked.Token); + } + catch (OperationCanceledException) + { + // 客户端断开或服务正在停止 + } + finally + { + counters.SseClosed(); + limiter.Release(client); + } + + return TypedResults.Empty; + } + + private sealed class LiveSession + { + private readonly HttpContext _http; + private readonly AgentService _agents; + private readonly LineService _lines; + private readonly IReadOnlyDictionary _lineKeys; + private readonly TestService _tests; + private readonly QueryService _query; + private readonly LiveHub _hub; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly IReadOnlyList _topics; + private readonly object _gate = new(); + private readonly Dictionary _lastSent = new(StringComparer.Ordinal); + private readonly HashSet<(NodeId Agent, string Series)> _dirtyLines = []; + private readonly HashSet _dirtyTests = []; + private readonly Queue _nodeSignals = new(); + private readonly Dictionary _hostSignals = []; + private long _pushAtMs; + private long _lastWriteMs; + private long _lastPeriodicMs; + + public LiveSession(HttpContext http, AgentService agents, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys) + { + _http = http; + _agents = agents; + _lines = lines; + _lineKeys = lineKeys; + _tests = tests; + _query = query; + _hub = hub; + _options = options; + _time = time; + _topics = topics; + } + + public async Task RunAsync(CancellationToken ct) + { + using LiveSubscription subscription = _hub.Subscribe(); + Task drain = DrainAsync(subscription, ct); + long now = Now(); + _lastWriteMs = now; + _lastPeriodicMs = now; + await PushSnapshotAsync(ct); + + using var timer = new PeriodicTimer(s_tick, _time); + while (await timer.WaitForNextTickAsync(ct)) + { + await FlushPendingAsync(ct); + } + + await drain; + } + + private async Task DrainAsync(LiveSubscription subscription, CancellationToken ct) + { + try + { + await foreach (LiveSignal signal in subscription.Reader.ReadAllAsync(ct)) + { + lock (_gate) + { + switch (signal) + { + case NodeSignal node when _topics.Any(t => t.Kind == LiveTopicKind.Nodes): + _nodeSignals.Enqueue(node); + break; + case HostSignal host when _topics.Any(t => t.Kind == LiveTopicKind.Hosts): + _hostSignals[host.AgentId] = host; + break; + case ChunkSignal chunk: + _dirtyLines.Add((chunk.AgentId, chunk.Series)); + _pushAtMs = _pushAtMs == 0 ? Now() + (long)s_debounce.TotalMilliseconds : _pushAtMs; + break; + case TestSignal test when _topics.Any(t => t.Test == test.TestId): + _dirtyTests.Add(test.TestId); + break; + } + } + } + } + catch (OperationCanceledException) + { + // 连接结束 + } + } + + private async Task FlushPendingAsync(CancellationToken ct) + { + long now = Now(); + NodeSignal[] nodes; + HostSignal[] hostSignals; + TestId[] testIds; + (NodeId Agent, string Series)[] dirtyLines = []; + bool refreshAll = now - _lastPeriodicMs >= (long)s_periodicRefresh.TotalMilliseconds; + lock (_gate) + { + nodes = [.. _nodeSignals]; + _nodeSignals.Clear(); + hostSignals = [.. _hostSignals.Values]; + _hostSignals.Clear(); + testIds = [.. _dirtyTests]; + _dirtyTests.Clear(); + if (_pushAtMs != 0 && now >= _pushAtMs) + { + dirtyLines = [.. _dirtyLines]; + _dirtyLines.Clear(); + _pushAtMs = 0; + } + } + + foreach (HostSignal host in hostSignals) + { + AgentView? view = await _agents.GetAsync(host.AgentId, ct); + if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active) + { + await WriteAsync("host", new LiveHostDto(host.AgentId.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); + } + } + + foreach (NodeSignal node in nodes) + { + AgentView? view = await _agents.GetAsync(new NodeId(node.Id), ct); + if (view is not null && IsVisible(view)) + { + await WriteAsync("node", new LiveNodeDto(node.Kind, node.Id, node.Online, Mappers.Time(node.AtMs)), ApiJsonContext.Default.LiveNodeDto, ct); + } + } + + foreach (TestId testId in testIds) + { + await PushTestAsync(testId, ct); + } + + if (dirtyLines.Length > 0) + { + foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Agent, _lineKeys[t.Raw].Series)))) + { + await PushSeriesAsync(topic, ct); + } + + foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Overview)) + { + await PushOverviewAsync(topic, ct); + } + + _lastPeriodicMs = now; + } + else if (refreshAll) + { + // 没有新块也定期刷新(例如线路断了之后汇总需要更新) + foreach (LiveTopic topic in _topics.Where(t => t.Kind is LiveTopicKind.Overview or LiveTopicKind.Series)) + { + await (topic.Kind == LiveTopicKind.Overview ? PushOverviewAsync(topic, ct) : PushSeriesAsync(topic, ct)); + } + + _lastPeriodicMs = now; + } + + if (Now() - _lastWriteMs >= (long)s_heartbeat.TotalMilliseconds) + { + await WriteRawAsync(": keepalive\n\n", ct); + } + } + + private async Task PushSnapshotAsync(CancellationToken ct) + { + foreach (LiveTopic topic in _topics) + { + switch (topic.Kind) + { + case LiveTopicKind.Nodes: + foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v))) + { + await WriteAsync("node", new LiveNodeDto("agent", view.Agent.Id.Value, view.Online, Mappers.Time(Now())), ApiJsonContext.Default.LiveNodeDto, ct); + } + + break; + case LiveTopicKind.Hosts: + foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null)) + { + await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct); + } + + break; + case LiveTopicKind.Overview: + await PushOverviewAsync(topic, ct); + break; + case LiveTopicKind.Series: + await PushSeriesAsync(topic, ct); + break; + case LiveTopicKind.Test: + await PushTestAsync(topic.Test!.Value, ct); + break; + } + } + } + + private async Task PushOverviewAsync(LiveTopic topic, CancellationToken ct) + { + MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value; + IReadOnlyList visible = await _lines.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(a), ct); + Result result = await _query.OverviewAsync(visible, new RangeRequest(topic.Range, null, null, null, null), selection, ct); + if (result.TryGet(out OverviewResult? overview, out _)) + { + var dto = new OverviewDto( + topic.Range!, Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs), + overview.Plan.Name, overview.Plan.Degraded, [.. overview.Lines.Select(Mappers.ToDto)]); + await WriteAsync("overview", dto, ApiJsonContext.Default.OverviewDto, ct); + } + } + + private async Task PushSeriesAsync(LiveTopic topic, CancellationToken ct) + { + MetricSelection selection = MetricSelection.Resolve(null, topic.Preset, _options.MaxMetricsPerQuery).Value; + string step = Steps.Format(topic.StepMs); + long now = Now(); + long from = _lastSent.TryGetValue(topic.Raw, out long last) ? last : now - 20_000; + + Result latest = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), null, step, null), selection, ct); + Result summary = await _query.SeriesAsync(_lineKeys[topic.Raw], new RangeRequest("15m", null, null, step, null), selection, ct); + if (!latest.TryGet(out SeriesResult? series, out _) || !summary.TryGet(out SeriesResult? whole, out _)) + { + return; + } + + if (series.T.Count > 0) + { + _lastSent[topic.Raw] = series.T[^1]; + } + + SeriesDto dto = Mappers.ToDto(series, live: true) with { Summary = Mappers.ToDto(whole.Summary) }; + await WriteAsync("series", dto, ApiJsonContext.Default.SeriesDto, ct); + } + + private async Task PushTestAsync(TestId testId, CancellationToken ct) + { + TestDetail? detail = await _tests.GetAsync(testId, ct); + if (detail is null) + { + return; + } + + AgentView? view = await _agents.GetAsync(detail.Test.AgentId, ct); + if (view is null || !IsVisible(view)) + { + return; + } + + await WriteAsync("test", new LiveTestDto(testId.Value, detail.Test.Status, detail.Test.Phase, detail.Test.PhaseCount), ApiJsonContext.Default.LiveTestDto, ct); + } + + private bool IsVisible(AgentView view) => view.Agent.PublicVisible || PublicGuard.CanSeeHidden(_http); + + private async Task WriteAsync(string name, T payload, JsonTypeInfo typeInfo, CancellationToken ct) + { + string json = JsonSerializer.Serialize(payload, typeInfo); + await WriteRawAsync($"event: {name}\ndata: {json}\n\n", ct); + } + + private async Task WriteRawAsync(string text, CancellationToken ct) + { + await _http.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(text), ct); + await _http.Response.Body.FlushAsync(ct); + _lastWriteMs = Now(); + } + + private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds(); + } +} diff --git a/backend/src/Monitor.Backend.Api/Public/LiveTopics.cs b/backend/src/Monitor.Backend.Api/Public/LiveTopics.cs new file mode 100644 index 0000000..d483eff --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Public/LiveTopics.cs @@ -0,0 +1,136 @@ +using System.Collections.Concurrent; +using Fleet.Core; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +internal enum LiveTopicKind +{ + Nodes, + Hosts, + Overview, + Series, + Test, +} + +/// +/// 订阅主题:nodes、hosts(Agent 主机资源占用的实时推送)、overview:{range}:{preset}、series:{line_id}:{step}:{preset}(步长 ≤ 1m)、test:{test_id}。 +/// preset 可以是逗号分隔的多个分组,所以不含冒号的片段会并入前一个主题的 preset。 +/// +internal sealed record LiveTopic(LiveTopicKind Kind, string Raw, string? Range, long StepMs, string? Preset, string? Line, TestId? Test) +{ + public static Result> ParseAll(IEnumerable values, int maxTopics) + { + var tokens = new List(); + foreach (string value in values) + { + foreach (string token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!token.Contains(':', StringComparison.Ordinal) && token is not ("nodes" or "hosts") && tokens.Count > 0 + && (tokens[^1].StartsWith("overview:", StringComparison.Ordinal) || tokens[^1].StartsWith("series:", StringComparison.Ordinal))) + { + tokens[^1] += "," + token; + } + else + { + tokens.Add(token); + } + } + } + + if (tokens.Count == 0) + { + return ValidationError.Fail>("topics", "at least one topic is required"); + } + + if (tokens.Count > maxTopics) + { + return Result>.Fail(MonitorErrors.TooManyStreams, $"at most {maxTopics} topics per connection"); + } + + var topics = new List(); + foreach (string token in tokens.Distinct(StringComparer.Ordinal)) + { + Result parsed = Parse(token); + if (!parsed.TryGet(out LiveTopic? topic, out FleetError? error)) + { + return Result>.Fail(error); + } + + topics.Add(topic); + } + + return Result>.Ok(topics); + } + + private static Result Parse(string token) + { + string[] parts = token.Split(':', 4); + switch (parts[0]) + { + case "hosts" when parts.Length == 1: + return Result.Ok(new LiveTopic(LiveTopicKind.Hosts, token, null, 0, null, null, null)); + case "nodes" when parts.Length == 1: + return Result.Ok(new LiveTopic(LiveTopicKind.Nodes, token, null, 0, null, null, null)); + case "overview" when parts.Length == 3: + if (!RangeResolver.TryParseSpan(parts[1], out _)) + { + return ValidationError.Fail("topics", $"{token}: range must look like 5m, 6h or 7d"); + } + + return Result.Ok(new LiveTopic(LiveTopicKind.Overview, token, parts[1], 0, parts[2], null, null)); + case "series" when parts.Length == 4: + if (!Steps.TryParse(parts[2], out long step) || step > Steps.Minute) + { + return Result.Fail(MonitorErrors.InvalidStep, $"{token}: series topics support steps up to 1m; refetch coarser steps over REST"); + } + + return Result.Ok(new LiveTopic(LiveTopicKind.Series, token, null, step, parts[3], parts[1], null)); + case "test" when parts.Length == 2: + return Result.Ok(new LiveTopic(LiveTopicKind.Test, token, null, 0, null, null, new TestId(parts[1]))); + default: + return ValidationError.Fail("topics", $"unknown topic {token}"); + } + } +} + +/// 每个客户端最多同时 4 个 SSE 连接。 +public sealed class SseLimiter +{ + private readonly ConcurrentDictionary _open = new(); + private readonly BackendOptions _options; + + public SseLimiter(BackendOptions options) + { + _options = options; + } + + public bool TryAcquire(string client) + { + while (true) + { + int current = _open.GetOrAdd(client, 0); + if (current >= _options.MaxSsePerClient) + { + return false; + } + + if (_open.TryUpdate(client, current + 1, current)) + { + return true; + } + } + } + + public void Release(string client) + { + while (_open.TryGetValue(client, out int current)) + { + if (current <= 1 ? _open.TryRemove(new KeyValuePair(client, current)) : _open.TryUpdate(client, current - 1, current)) + { + return; + } + } + } +} diff --git a/backend/src/Monitor.Backend.Api/Public/QueryEndpoints.cs b/backend/src/Monitor.Backend.Api/Public/QueryEndpoints.cs new file mode 100644 index 0000000..dcadd8d --- /dev/null +++ b/backend/src/Monitor.Backend.Api/Public/QueryEndpoints.cs @@ -0,0 +1,244 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Api; + +/// 展示数据的查询端点:参数解析、校验与缓存头,业务都在 Application.Query。 +public static class QueryEndpoints +{ + private const string Tag = "public"; + private const string DefaultOverviewPreset = "udp,tcp,scores"; + private const string DefaultSeriesPreset = "udp,tcp"; + + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/overview", OverviewAsync) + .Api("overview_get", "Ranking of all lines over a time range", Tag) + .Returns().ProducesProblems(403, 422, 429, 503); + group.MapGet("/lines/{line_id}", LineAsync) + .Api("lines_get", "One line", Tag) + .Returns().ProducesProblems(403, 404); + group.MapGet("/lines/{line_id}/series", SeriesAsync) + .Api("lines_series", "Time series of one line (columnar)", Tag) + .Returns().ProducesProblems(403, 404, 422, 429, 503); + group.MapGet("/compare", CompareAsync) + .Api("lines_compare", "One metric across several lines, aligned on one time axis", Tag) + .Returns().ProducesProblems(403, 404, 422, 429, 503); + group.MapGet("/lines/{line_id}/distribution", DistributionAsync) + .Api("lines_distribution", "Histogram and CDF of a histogram metric", Tag) + .Returns().ProducesProblems(403, 404, 422, 429, 503); + group.MapGet("/lines/{line_id}/heatmap", HeatmapAsync) + .Api("lines_heatmap", "Time by latency heat map", Tag) + .Returns().ProducesProblems(403, 404, 422, 429, 503); + group.MapGet("/tests/{test_id}", TestAsync) + .Api("tests_get", "A test with its result", Tag) + .Returns().ProducesProblems(403, 404); + } + + private static Task OverviewAsync( + HttpContext http, LineService lineService, QueryService query, BackendOptions options, TimeProvider time, + string? range, DateTimeOffset? from, DateTimeOffset? to, string? preset, string? metrics, string? sort, string? order) => + QueryHelpers.GuardedAsync(http, async ct => + { + Result selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultOverviewPreset : null), options.MaxMetricsPerQuery); + if (!selection.TryGet(out MetricSelection? sel, out FleetError? error)) + { + return Problems.From(http, error); + } + + IReadOnlyList visible = await lineService.ListAsync(a => a.Agent.Status == Fleet.Server.NodeStatus.Active && (a.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)), ct); + Result result = await query.OverviewAsync(visible, new RangeRequest(range, from, to, null, null), sel, ct); + if (!result.TryGet(out OverviewResult? overview, out error)) + { + return Problems.From(http, error); + } + + IEnumerable lines = Sort(overview.Lines, sort, order, sel); + IResult? cached = QueryHelpers.ApplyCaching(http, overview.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), overview.Plan.Name + string.Join(',', sel.AllIds)); + if (cached is not null) + { + return cached; + } + + var dto = new OverviewDto( + range ?? "custom", Mappers.Time(overview.Range.FromMs), Mappers.Time(overview.Range.ToMs), Steps.Format(overview.Range.StepMs), + overview.Plan.Name, overview.Plan.Degraded, [.. lines.Select(Mappers.ToDto)]); + return TypedResults.Json(dto, ApiJsonContext.Default.OverviewDto); + }); + + private static async Task LineAsync(HttpContext http, LineService lines, TestService tests, QueryService query, string line_id, CancellationToken ct) + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct); + if (!found.TryGet(out LineView? line, out FleetError? error)) + { + return Problems.From(http, error); + } + + AgentView view = line.Agent; + IReadOnlyList recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, ct); + TestRecord? last = recent.Count > 0 ? recent[0] : null; + return TypedResults.Json( + new LineDto( + line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected, + Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last)), + ApiJsonContext.Default.LineDto); + } + + private static Task SeriesAsync( + HttpContext http, LineService lines, QueryService query, BackendOptions options, TimeProvider time, + string line_id, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step, string? tz, string? preset, string? metrics, string? anchor) => + QueryHelpers.GuardedAsync(http, async ct => + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct); + if (!found.TryGet(out LineView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + Result selection = MetricSelection.Resolve(metrics, preset ?? (metrics is null ? DefaultSeriesPreset : null), options.MaxMetricsPerQuery); + if (!selection.TryGet(out MetricSelection? sel, out error)) + { + return Problems.From(http, error); + } + + Result result = await query.SeriesAsync(view.Key, new RangeRequest(range, from, to, step, tz, anchor), sel, ct); + if (!result.TryGet(out SeriesResult? series, out error)) + { + return Problems.From(http, error); + } + + IResult? cached = QueryHelpers.ApplyCaching(http, series.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), series.Plan.Name + string.Join(',', series.Versions.Values)); + return cached ?? TypedResults.Json(Mappers.ToDto(series, live: true), ApiJsonContext.Default.SeriesDto); + }); + + private static Task CompareAsync( + HttpContext http, LineService lineService, QueryService query, TimeProvider time, + [FromQuery(Name = "lines")] string? lineIds, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) => + QueryHelpers.GuardedAsync(http, async ct => + { + string? list = lineIds; + if (string.IsNullOrWhiteSpace(list)) + { + return Problems.From(http, ValidationError.Single("lines", "is required")); + } + + var ids = new List(); + foreach (string id in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()) + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lineService, id, ct); + if (!found.TryGet(out LineView? view, out FleetError? findError)) + { + return Problems.From(http, findError); + } + + ids.Add(view.Key); + } + + Result result = await query.CompareAsync(ids, metric, new RangeRequest(range, from, to, step, null), ct); + if (!result.TryGet(out CompareResult? compare, out FleetError? error)) + { + return Problems.From(http, error); + } + + IResult? cached = QueryHelpers.ApplyCaching(http, compare.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), compare.Plan.Name + metric); + return cached ?? TypedResults.Json( + new CompareDto( + compare.Metric, Mappers.Time(compare.Range.FromMs), Mappers.Time(compare.Range.ToMs), Steps.Format(compare.Range.StepMs), compare.Plan.Name, compare.Plan.Degraded, + compare.T, compare.Values, compare.Summary), + ApiJsonContext.Default.CompareDto); + }); + + private static Task DistributionAsync( + HttpContext http, LineService lines, QueryService query, TimeProvider time, + string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to) => + QueryHelpers.GuardedAsync(http, async ct => + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct); + if (!found.TryGet(out LineView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + var request = new RangeRequest(range, from, to, null, null); + Result result = await query.DistributionAsync(view.Key, metric, request, ct); + if (!result.TryGet(out DistributionResult? distribution, out error)) + { + return Problems.From(http, error); + } + + Result<(QueryRange Range, SourcePlan Plan)> planned = query.Plan(request, "15m"); + IResult? cached = planned.IsSuccess ? QueryHelpers.ApplyCaching(http, planned.Value.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), metric) : null; + return cached ?? TypedResults.Json( + new DistributionDto( + distribution.Metric, distribution.Unit, [.. distribution.Buckets.Select(b => new DistributionBucketDto(b.Le, checked((long)b.Count)))], + distribution.P50, distribution.P95, distribution.P99, checked((long)distribution.Count)), + ApiJsonContext.Default.DistributionDto); + }); + + private static Task HeatmapAsync( + HttpContext http, LineService lines, QueryService query, TimeProvider time, + string line_id, string metric, string? range, DateTimeOffset? from, DateTimeOffset? to, string? step) => + QueryHelpers.GuardedAsync(http, async ct => + { + Result found = await QueryHelpers.FindVisibleLineAsync(http, lines, line_id, ct); + if (!found.TryGet(out LineView? view, out FleetError? error)) + { + return Problems.From(http, error); + } + + Result result = await query.HeatmapAsync(view.Key, metric, new RangeRequest(range, from, to, step, null), ct); + if (!result.TryGet(out HeatmapResult? heatmap, out error)) + { + return Problems.From(http, error); + } + + IResult? cached = QueryHelpers.ApplyCaching(http, heatmap.Range, time.GetUtcNow().ToUnixTimeMilliseconds(), heatmap.Plan.Name + metric); + return cached ?? TypedResults.Json( + new HeatmapDto( + metric, Steps.Format(heatmap.Range.StepMs), heatmap.Plan.Name, heatmap.Plan.Degraded, heatmap.T, heatmap.Edges, + [.. heatmap.Counts.Select(row => (IReadOnlyList)[.. row.Select(c => checked((long)c))])]), + ApiJsonContext.Default.HeatmapDto); + }); + + private static async Task TestAsync(HttpContext http, TestService tests, AgentService agents, string test_id, CancellationToken ct) + { + TestDetail? detail = await tests.GetAsync(new TestId(test_id), ct); + if (detail is null) + { + return Problems.Create(http, MonitorErrors.TestNotFound, "test not found"); + } + + Result visible = await QueryHelpers.FindVisibleAgentAsync(http, agents, detail.Test.AgentId.Value, ct); + if (!visible.IsSuccess) + { + return Problems.Create(http, MonitorErrors.TestNotFound, "test not found"); + } + + return TypedResults.Json(Mappers.ToDto(detail), ApiJsonContext.Default.TestDto); + } + + private static IEnumerable Sort(IReadOnlyList lines, string? sort, string? order, MetricSelection selection) + { + string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault(); + if (key is null) + { + return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal); + } + + bool descending = order is not null + ? order.Equals("desc", StringComparison.OrdinalIgnoreCase) + : key.StartsWith("score.", StringComparison.Ordinal) || (MetricRegistry.TryFindExport(key, out MetricDef def, out _) && def.Better == Better.Higher); + IEnumerable withValue = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is not null); + IEnumerable without = lines.Where(l => l.Summary.Values.GetValueOrDefault(key) is null); + IOrderedEnumerable ordered = descending + ? withValue.OrderByDescending(l => l.Summary.Values[key]) + : withValue.OrderBy(l => l.Summary.Values[key]); + return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal)); + } +} diff --git a/backend/src/Monitor.Backend.Application/Admin/EventService.cs b/backend/src/Monitor.Backend.Application/Admin/EventService.cs new file mode 100644 index 0000000..1c4ff5d --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Admin/EventService.cs @@ -0,0 +1,16 @@ +using Fleet.Core; + +namespace Monitor.Backend.Application; + +public sealed class EventService +{ + private readonly IEventStore _events; + + public EventService(IEventStore events) + { + _events = events; + } + + public ValueTask> ListAsync(NodeId? agentId, string? kind, long fromMs, long toMs, long? beforeId, int limit, CancellationToken ct) => + _events.ListAsync(agentId, kind, fromMs, toMs, beforeId, limit, ct); +} diff --git a/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs new file mode 100644 index 0000000..658e13d --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Admin/JoinTokenService.cs @@ -0,0 +1,65 @@ +using Fleet.Core; + +namespace Monitor.Backend.Application; + +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours); + +public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, string InstallCommand); + +/// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 +public sealed class JoinTokenService +{ + private readonly IJoinTokenStore _store; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + + public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time) + { + _store = store; + _options = options; + _time = time; + } + + public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct) + { + var errors = new Dictionary(); + if (request.MaxUses is < 1 or > 1000) + { + errors["max_uses"] = "must be between 1 and 1000"; + } + + if (request.ExpiresInHours is < 1 or > 24 * 365) + { + errors["expires_in_hours"] = "must be between 1 and 8760"; + } + + if (request.Labels is { } labels && (labels.Count > 16 || labels.Any(l => l.Length is 0 or > 32))) + { + errors["labels"] = "at most 16 labels of 1-32 characters"; + } + + if (request.Note is { Length: > 200 }) + { + errors["note"] = "must be at most 200 characters"; + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); + } + + string plaintext = TokenHash.NewToken("jt"); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + JoinTokenInfo info = await _store.CreateAsync( + Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); + + string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); + string command = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name "; + return Result.Ok(new CreatedJoinToken(info, plaintext, command)); + } + + public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); + + public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); +} diff --git a/backend/src/Monitor.Backend.Application/Admin/OpsService.cs b/backend/src/Monitor.Backend.Application/Admin/OpsService.cs new file mode 100644 index 0000000..13ebe0f --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Admin/OpsService.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.Text; + +namespace Monitor.Backend.Application; + +/// 运维:健康检查和 Prometheus 指标(在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数)。 +public sealed class OpsService +{ + private readonly IStorageHealth _health; + private readonly IMetricStore _metrics; + private readonly IRawStore _raw; + private readonly IAgentGateway _gateway; + private readonly BackendCounters _counters; + private readonly MetricRecompute _recompute; + + public OpsService(IStorageHealth health, IMetricStore metrics, IRawStore raw, IAgentGateway gateway, BackendCounters counters, MetricRecompute recompute) + { + _health = health; + _metrics = metrics; + _raw = raw; + _gateway = gateway; + _counters = counters; + _recompute = recompute; + } + + public ValueTask HealthAsync(CancellationToken ct) => _health.CheckAsync(ct); + + public RecomputeProgress RecomputeProgress => _recompute.Progress; + + public ValueTask QueueLengthAsync(CancellationToken ct) => _metrics.QueueLengthAsync(ct); + + public async ValueTask RenderPrometheusAsync(CancellationToken ct) + { + var text = new StringBuilder(); + void Metric(string name, string help, double value, string type = "gauge") + { + text.Append("# HELP ").Append(name).Append(' ').AppendLine(help); + text.Append("# TYPE ").Append(name).Append(' ').AppendLine(type); + text.Append(name).Append(' ').AppendLine(value.ToString(CultureInfo.InvariantCulture)); + } + + RecomputeProgress progress = _recompute.Progress; + Metric("monitor_agents_online", "Agents with an open control connection.", _gateway.OnlineAgents.Count); + Metric("monitor_chunks_ingested_total", "Raw chunks stored.", _counters.ChunksIngested, "counter"); + Metric("monitor_chunks_duplicate_total", "Raw chunks received more than once.", _counters.ChunksDuplicate, "counter"); + Metric("monitor_chunks_discarded_total", "Raw chunks rejected as invalid.", _counters.ChunksDiscarded, "counter"); + Metric("monitor_ingest_bytes_total", "Bytes of raw chunks stored.", _counters.BytesIngested, "counter"); + Metric("monitor_windows_analyzed_total", "Minute windows computed.", _counters.WindowsAnalyzed, "counter"); + Metric("monitor_analysis_queue_length", "Minute windows waiting to be computed.", await _metrics.QueueLengthAsync(ct)); + Metric("monitor_recompute_windows_done", "Windows recomputed by the current backfill.", progress.WindowsDone); + Metric("monitor_recompute_windows_total", "Windows to recompute in the current backfill.", progress.WindowsTotal); + Metric("monitor_raw_bytes", "Disk used by raw segment files.", _raw.TotalBytes); + Metric("monitor_sse_connections", "Open server-sent event connections.", _counters.SseConnections); + return text.ToString(); + } +} diff --git a/backend/src/Monitor.Backend.Application/Admin/RawBrowserService.cs b/backend/src/Monitor.Backend.Application/Admin/RawBrowserService.cs new file mode 100644 index 0000000..b69fa06 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Admin/RawBrowserService.cs @@ -0,0 +1,59 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 原始数据的检索与下载(管理员)。块过了保留期返回 raw_expired,从来不存在返回 chunk_not_found。 +public sealed class RawBrowserService +{ + private readonly IRawStore _raw; + private readonly SettingsService _settings; + private readonly TimeProvider _time; + + public RawBrowserService(IRawStore raw, SettingsService settings, TimeProvider time) + { + _raw = raw; + _settings = settings; + _time = time; + } + + public ValueTask> ListAsync(RawQuery query, CancellationToken ct) => _raw.FindAsync(query, ct); + + public async ValueTask> GetAsync(ChunkId id, CancellationToken ct) + { + RawChunkMeta? meta = await _raw.GetMetaAsync(id, ct); + if (meta is not null) + { + byte[]? bytes = await _raw.ReadAsync(meta, ct); + return bytes is null + ? Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.RawExpired, "the raw data has expired") + : Result<(RawChunkMeta, byte[])>.Ok((meta, bytes)); + } + + // 索引已经随保留期清理:ID 里的时间戳(UUIDv7)早于保留期就是 expired + int days = await _settings.GetIntAsync(SettingsService.TestRawDays, ct); + long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (days * 86_400_000L); + return ChunkTimestampMs(id.Value) is { } created && created < cutoff + ? Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.RawExpired, "the raw data has expired") + : Result<(RawChunkMeta, byte[])>.Fail(MonitorErrors.ChunkNotFound, "chunk not found"); + } + + /// UUIDv7 的前 48 位是毫秒时间戳。 + public static long? ChunkTimestampMs(Guid id) + { + if (id.Version != 7) + { + return null; + } + + Span bytes = stackalloc byte[16]; + id.TryWriteBytes(bytes, bigEndian: true, out _); + long ms = 0; + for (int i = 0; i < 6; i++) + { + ms = (ms << 8) | bytes[i]; + } + + return ms; + } +} diff --git a/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs new file mode 100644 index 0000000..115c76b --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Admin/SettingsService.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using System.Text.Json; +using Fleet.Core; + +namespace Monitor.Backend.Application; + +public sealed record SettingSpec(string Key, string Type, string Default, long Min, long Max, string Description); + +public sealed record SettingView(string Key, string Type, string Value, string Default, string Description); + +/// 站点设置与保留期:键是固定的一组,值经过类型和范围校验。 +public sealed class SettingsService +{ + public const string SitePublic = "site.public"; + public const string RawDays = "retention.raw_days"; + public const string TestRawDays = "retention.test_raw_days"; + public const string Metric10sHours = "retention.metric_10s_hours"; + public const string Metric1mDays = "retention.metric_1m_days"; + public const string Metric5mDays = "retention.metric_5m_days"; + public const string EventDays = "retention.events_days"; + public const string HostDays = "retention.host_days"; + + private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(5); + + private readonly ISettingsStore _store; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private long _sitePublicAtMs; + private bool _sitePublic; + + public SettingsService(ISettingsStore store, BackendOptions options, TimeProvider time) + { + _store = store; + _options = options; + _time = time; + } + + public IReadOnlyList Specs => Known(_options); + + private static IReadOnlyList Known(BackendOptions o) => + [ + new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."), + new(RawDays, "int", o.RawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of continuous tasks."), + new(TestRawDays, "int", o.TestRawRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep raw chunks of on-demand tests."), + new(Metric10sHours, "int", o.Metric10sRetentionHours.ToString(CultureInfo.InvariantCulture), 1, 720, "Hours to keep the 10 s metric layer."), + new(Metric1mDays, "int", o.Metric1mRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep the 1 min metric layer."), + new(Metric5mDays, "int", o.Metric5mRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 3650, "Days to keep the 5 min metric layer."), + new(EventDays, "int", o.EventRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 3650, "Days to keep events."), + new(HostDays, "int", o.HostRetentionDays.ToString(CultureInfo.InvariantCulture), 1, 365, "Days to keep the per-minute host resource samples."), + ]; + + public async ValueTask> ListAsync(CancellationToken ct) + { + IReadOnlyDictionary stored = await _store.GetAllAsync(ct); + return [.. Specs.Select(s => new SettingView(s.Key, s.Type, stored.GetValueOrDefault(s.Key) ?? s.Default, s.Default, s.Description))]; + } + + public async ValueTask>> PatchAsync(IReadOnlyDictionary changes, CancellationToken ct) + { + var errors = new Dictionary(); + var accepted = new Dictionary(); + foreach ((string key, JsonElement value) in changes) + { + SettingSpec? spec = Specs.FirstOrDefault(s => s.Key == key); + if (spec is null) + { + errors[key] = "is not a known setting"; + } + else if (spec.Type == "bool") + { + if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + accepted[key] = value.GetBoolean() ? "true" : "false"; + } + else + { + errors[key] = "must be a boolean"; + } + } + else if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long n) && n >= spec.Min && n <= spec.Max) + { + accepted[key] = n.ToString(CultureInfo.InvariantCulture); + } + else + { + errors[key] = $"must be an integer between {spec.Min} and {spec.Max}"; + } + } + + if (errors.Count > 0) + { + return ValidationError.Fail>(errors); + } + + foreach ((string key, string value) in accepted) + { + await _store.SetAsync(key, value, ct); + } + + Interlocked.Exchange(ref _sitePublicAtMs, 0); + return Result>.Ok(await ListAsync(ct)); + } + + public async ValueTask IsSitePublicAsync(CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (now - Interlocked.Read(ref _sitePublicAtMs) < (long)s_cacheTtl.TotalMilliseconds) + { + return _sitePublic; + } + + _sitePublic = await _store.GetAsync(SitePublic, ct) == "true"; + Interlocked.Exchange(ref _sitePublicAtMs, now); + return _sitePublic; + } + + public async ValueTask GetIntAsync(string key, CancellationToken ct) + { + SettingSpec spec = Specs.First(s => s.Key == key); + string? stored = await _store.GetAsync(key, ct); + return int.TryParse(stored, NumberStyles.None, CultureInfo.InvariantCulture, out int value) ? value : int.Parse(spec.Default, CultureInfo.InvariantCulture); + } +} diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs b/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs new file mode 100644 index 0000000..ccff24a --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/AgentEventService.cs @@ -0,0 +1,48 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// monitor.event:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。 +public sealed class AgentEventService +{ + private readonly AgentRuntime _runtime; + private readonly IEventStore _events; + private readonly TimeProvider _time; + + public AgentEventService(AgentRuntime runtime, IEventStore events, TimeProvider time) + { + _runtime = runtime; + _events = events; + _time = time; + } + + public async ValueTask OnEventAsync(NodeId agent, AgentEventParams e, CancellationToken ct) + { + if (!AgentEventKinds.All.Contains(e.Kind)) + { + return; + } + + // 节点上报的时间可能有偏差:限制在合理范围内,避免污染事件时间线 + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long at = Math.Abs(e.AtUtcMs - now) > 24L * 3600 * 1000 ? now : e.AtUtcMs; + + AgentRuntimeState state = _runtime.Get(agent); + switch (e.Kind) + { + case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail): + state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at) : new ConnectedProbeInfo(e.Detail, null, null, at)); + break; + case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail): + state.RemoveProbe(e.Detail); + break; + case AgentEventKinds.EndpointChanged: + state.CountEndpointChange(); + break; + } + + string? detail = e.Detail is { Length: > 512 } ? e.Detail[..512] : e.Detail; + await _events.AddAsync(agent, e.Kind, at, detail, ct); + } +} diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs b/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs new file mode 100644 index 0000000..98e7738 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/AgentRuntime.cs @@ -0,0 +1,102 @@ +using System.Collections.Concurrent; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。 +public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs); + +/// Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。 +public sealed class AgentRuntimeState +{ + private readonly object _gate = new(); + private int _endpointChanges; + private IReadOnlyList _probes = []; + + /// 当前连着这个 Agent 的 Probe(每个 Probe 一项)。 + public IReadOnlyList Probes => Volatile.Read(ref _probes); + + public bool ProbeConnected => Probes.Count > 0; + + public bool IsConnected(string probeId) => Probes.Any(p => p.Id == probeId); + + /// 事件 probe_connected:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。 + public void AddProbe(ConnectedProbeInfo info) + { + lock (_gate) + { + Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id), info]); + } + } + + public void RemoveProbe(string id) + { + lock (_gate) + { + Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id)]); + } + } + + /// 心跳里的完整快照:覆盖事件推算出的结果。 + public void ReplaceProbes(IEnumerable probes) + { + lock (_gate) + { + Volatile.Write(ref _probes, [.. probes.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]); + } + } + + public void ClearProbes() => ReplaceProbes([]); + + /// 最新的主机资源占用快照,以及收到它的时间。 + public HostSnapshot? Host { get; set; } + + public long HostReceivedAtMs { get; set; } + + public AgentHeartbeatExtra? LastHeartbeat { get; set; } + + public long LastHeartbeatAtMs { get; set; } + + public int EndpointChanges => Volatile.Read(ref _endpointChanges); + + public void CountEndpointChange() => Interlocked.Increment(ref _endpointChanges); +} + +public sealed class AgentRuntime +{ + private readonly ConcurrentDictionary _states = new(); + + public AgentRuntimeState Get(NodeId agent) => _states.GetOrAdd(agent, _ => new AgentRuntimeState()); + + public bool TryGet(NodeId agent, out AgentRuntimeState state) => _states.TryGetValue(agent, out state!); + + public void Remove(NodeId agent) => _states.TryRemove(agent, out _); + + /// 某个 Probe 现在连着哪些 Agent。 + public IReadOnlyList<(NodeId Agent, ConnectedProbeInfo Probe)> ConnectionsOf(string probeId) + { + var result = new List<(NodeId, ConnectedProbeInfo)>(); + foreach ((NodeId agent, AgentRuntimeState state) in _states) + { + foreach (ConnectedProbeInfo probe in state.Probes) + { + if (probe.Id == probeId) + { + result.Add((agent, probe)); + } + } + } + + return result; + } + + /// Agent 断开时,它对 Probe 会话的认知也随之失效。 + public void MarkOffline(NodeId agent) + { + if (_states.TryGetValue(agent, out AgentRuntimeState? state)) + { + state.ClearProbes(); + } + } +} diff --git a/backend/src/Monitor.Backend.Application/Agents/AgentService.cs b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs new file mode 100644 index 0000000..ffc8ee9 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/AgentService.cs @@ -0,0 +1,169 @@ +using System.Text.RegularExpressions; +using Fleet.Core; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null) +{ + /// 至少有一个 Probe 连着。 + public bool ProbeConnected => Probes.Count > 0; +} + +/// Agent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。 +public sealed partial class AgentService +{ + private readonly IAgentStore _agents; + private readonly AgentRuntime _runtime; + private readonly IAgentGateway _gateway; + private readonly TaskPlanner _planner; + private readonly ISettingsStore _settings; + + public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings) + { + _agents = agents; + _runtime = runtime; + _gateway = gateway; + _planner = planner; + _settings = settings; + } + + public async ValueTask> ListAsync(CancellationToken ct) + { + IReadOnlyList agents = await _agents.ListAsync(ct); + return [.. agents.Select(View)]; + } + + public async ValueTask GetAsync(NodeId id, CancellationToken ct) => + await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null; + + public AgentView View(AgentRecord agent) + { + _runtime.TryGet(agent.Id, out AgentRuntimeState? state); + return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host); + } + + public async ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) + { + var errors = new Dictionary(); + if (patch.Name is not null && !NamePattern().IsMatch(patch.Name)) + { + errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'"; + } + + if (patch.PublicHost is not null && !HostPattern().IsMatch(patch.PublicHost)) + { + errors["public_host"] = "must be a host name or IP address"; + } + + if (patch.Labels is not null && (patch.Labels.Count > 16 || patch.Labels.Any(l => l.Length is 0 or > 32))) + { + errors["labels"] = "at most 16 labels of 1-32 characters"; + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); + } + + Result result = await _agents.PatchAsync(id, patch, expectedRowVersion, ct); + if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) + { + return Result.Fail(error); + } + + if (patch.Name is not null || patch.PublicHost is not null) + { + await AfterAgentSetChangedAsync(ct); + } + + return Result.Ok(View(agent)); + } + + public ValueTask> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct); + + public ValueTask> EnableAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct); + + public async ValueTask> DisableAsync(NodeId id, CancellationToken ct) + { + Result result = await ChangeStatusAsync(id, NodeStatus.Disabled, ct); + if (result.IsSuccess) + { + await _gateway.DisconnectAsync(id, "disabled", ct); + } + + return result; + } + + public async ValueTask> TakeoverAsync(NodeId pendingId, CancellationToken ct) + { + Result result = await _agents.TakeoverAsync(pendingId, ct); + if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) + { + return Result.Fail(error); + } + + // 公钥换了,旧连接使用的是旧密钥 + await _gateway.DisconnectAsync(agent.Id, "key replaced", ct); + await AfterAgentSetChangedAsync(ct); + return Result.Ok(View(agent)); + } + + public async ValueTask DeleteAsync(NodeId id, CancellationToken ct) + { + await _gateway.DisconnectAsync(id, "deleted", ct); + await _agents.DeleteAsync(id, ct); + _runtime.Remove(id); + await AfterAgentSetChangedAsync(ct); + } + + /// Probe 需要连接的 Agent 列表(所有已激活的 Agent)。版本号在列表变化时递增。 + public async ValueTask ProbeAgentsAsync(CancellationToken ct) + { + IReadOnlyList agents = await _agents.ListAsync(ct); + long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0; + var result = new ProbeAgentsResult { Version = version }; + foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0)) + { + result.Agents.Add(new AgentEndpoint + { + AgentId = agent.Id, + Name = agent.Name, + Host = agent.PublicHost, + TcpPort = agent.ProbeTcpPort, + UdpPorts = [.. agent.ProbeUdpPorts], + Spki = Convert.ToBase64String(agent.TlsSpki), + }); + } + + return result; + } + + /// Agent 集合变了:递增列表版本、重算各 Agent 的期望状态、通知 Probe。 + public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct) + { + await _planner.BumpProbeAgentsVersionAsync(ct); + _gateway.NotifyAllTaskSetsChanged(); + await _gateway.NotifyProbeAgentsChangedAsync(await ProbeAgentsAsync(ct), ct); + } + + private async ValueTask> ChangeStatusAsync(NodeId id, NodeStatus status, CancellationToken ct) + { + Result result = await _agents.SetStatusAsync(id, status, ct); + if (!result.TryGet(out AgentRecord? agent, out FleetError? error)) + { + return Result.Fail(error); + } + + await AfterAgentSetChangedAsync(ct); + _gateway.NotifyTaskSetChanged(id); + return Result.Ok(View(agent)); + } + + [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] + private static partial Regex NamePattern(); + + [GeneratedRegex(@"^[A-Za-z0-9.:\-\[\]]{1,253}$")] + private static partial Regex HostPattern(); +} diff --git a/backend/src/Monitor.Backend.Application/Agents/HostStatusService.cs b/backend/src/Monitor.Backend.Application/Agents/HostStatusService.cs new file mode 100644 index 0000000..284fa15 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/HostStatusService.cs @@ -0,0 +1,67 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// monitor.host:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个 Agent 每分钟存一个样本做历史。 +/// 快照里没有 CPU 型号、主机名、网卡名和 IP(Agent 根本不采集它们)。 +/// +public sealed class HostStatusService +{ + private static readonly TimeSpan s_stale = TimeSpan.FromSeconds(15); + + private readonly AgentRuntime _runtime; + private readonly IHostStore _store; + private readonly LiveHub _live; + private readonly TimeProvider _time; + private readonly Dictionary _lastSavedMinute = []; + private readonly object _gate = new(); + + public HostStatusService(AgentRuntime runtime, IHostStore store, LiveHub live, TimeProvider time) + { + _runtime = runtime; + _store = store; + _live = live; + _time = time; + } + + public async ValueTask OnHostAsync(NodeId agent, HostSnapshot snapshot, CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + snapshot.AtUtcMs = now; // 以 Backend 收到的时间为准:节点的时钟不可信 + AgentRuntimeState state = _runtime.Get(agent); + state.Host = snapshot; + state.HostReceivedAtMs = now; + _live.Publish(new HostSignal(agent, snapshot)); + + long minute = now - (now % 60_000); + bool save; + lock (_gate) + { + save = !_lastSavedMinute.TryGetValue(agent, out long last) || last != minute; + if (save) + { + _lastSavedMinute[agent] = minute; + } + } + + if (save) + { + await _store.SaveAsync(agent, minute, snapshot, ct); + } + } + + /// 最新快照;超过 15 秒没有更新就是过期(Agent 离线或者没有开启上报)。 + public (HostSnapshot? Host, bool Stale) Latest(NodeId agent) + { + if (!_runtime.TryGet(agent, out AgentRuntimeState? state) || state.Host is null) + { + return (null, true); + } + + return (state.Host, _time.GetUtcNow().ToUnixTimeMilliseconds() - state.HostReceivedAtMs > s_stale.TotalMilliseconds); + } + + public ValueTask> HistoryAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct) => _store.ReadAsync(agent, fromMs, toMs, ct); +} diff --git a/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs b/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs new file mode 100644 index 0000000..418c27f --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/MonitorFleetHooks.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Rpc; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// Monitor 在 Fleet 之上的接入点:注册之后补全公开地址与端口。 +public sealed class MonitorEnrollmentHook : IEnrollmentHook +{ + private readonly IAgentStore _agents; + private readonly AgentService _service; + + public MonitorEnrollmentHook(IAgentStore agents, AgentService service) + { + _agents = agents; + _service = service; + } + + public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct) + { + AgentEnrollMeta meta = new(); + if (context.ProductMeta is { } raw) + { + try + { + meta = raw.Deserialize(MonitorJsonContext.Default.AgentEnrollMeta) ?? meta; + } + catch (JsonException) + { + // 元数据格式不对就用默认值,节点之后可以通过 hello 补全 + } + } + + // public_host 未提供时,取注册请求的来源 IP + string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? ""; + await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct); + if (node.Status == NodeStatus.Active) + { + await _service.AfterAgentSetChangedAsync(ct); + } + } +} + +/// 节点上线、下线、hello、心跳:更新运行时状态,补全 Agent 信息,并推送给 SSE。 +public sealed class MonitorNodeEventSink : INodeEventSink +{ + private readonly IAgentStore _agents; + private readonly AgentRuntime _runtime; + private readonly IEventStore _events; + private readonly LiveHub _live; + private readonly AgentService _service; + + public MonitorNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service) + { + _agents = agents; + _runtime = runtime; + _events = events; + _live = live; + _service = service; + } + + public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct) + { + NodeId id = nodeEvent.Node.Id; + switch (nodeEvent.Kind) + { + case NodeEventKind.Online: + _live.Publish(new NodeSignal("agent", id.Value, true, nodeEvent.AtMs)); + await _events.AddAsync(id, "agent_online", nodeEvent.AtMs, null, ct); + break; + case NodeEventKind.Offline: + _runtime.MarkOffline(id); + _live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs)); + await _events.AddAsync(id, "agent_offline", nodeEvent.AtMs, null, ct); + break; + case NodeEventKind.Hello when nodeEvent.Hello is { } hello: + await ApplyHelloAsync(id, hello, ct); + break; + case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra: + try + { + AgentRuntimeState state = _runtime.Get(id); + AgentHeartbeatExtra? beat = extra.Deserialize(MonitorJsonContext.Default.AgentHeartbeatExtra); + state.LastHeartbeat = beat; + state.LastHeartbeatAtMs = nodeEvent.AtMs; + if (beat is not null) + { + state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs))); + } + } + catch (JsonException) + { + // 忽略格式不正确的附加字段 + } + + break; + } + } + + private async ValueTask ApplyHelloAsync(NodeId id, HelloParams hello, CancellationToken ct) + { + var kinds = new List(); + foreach (string capability in hello.Capabilities) + { + if (capability.StartsWith(MonitorCapabilities.KindsPrefix, StringComparison.Ordinal)) + { + kinds.AddRange(capability[MonitorCapabilities.KindsPrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + } + + AgentHelloMeta meta = new(); + if (hello.Meta is { } raw) + { + try + { + meta = raw.Deserialize(MonitorJsonContext.Default.AgentHelloMeta) ?? meta; + } + catch (JsonException) + { + // 忽略 + } + } + + byte[]? spki = null; + if (meta.TlsSpki is not null) + { + try + { + spki = Convert.FromBase64String(meta.TlsSpki); + } + catch (FormatException) + { + // 忽略 + } + } + + AgentRecord? before = await _agents.GetAsync(id, ct); + string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); + await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts), ct); + + // 端口或证书指纹变了,Probe 需要新的列表 + AgentRecord? after = await _agents.GetAsync(id, ct); + if (before is not null && after is not null + && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts)) + && after.Status == NodeStatus.Active) + { + await _service.AfterAgentSetChangedAsync(ct); + } + } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(LocalLimits))] +internal sealed partial class LocalLimitsJson : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs new file mode 100644 index 0000000..48a0005 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Agents/ProbeService.cs @@ -0,0 +1,198 @@ +using System.Text.RegularExpressions; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record BootstrapAgent(NodeId AgentId, string Host, int TcpPort, string Spki); + +/// 本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。 +public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps); + +/// probe.json:只在生成(或轮换密钥)时返回一次,含私钥。 +public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, IReadOnlyList Bootstrap, ProbeLimits Limits); + +/// 某个 Probe 现在连着的一个 Agent(Agent 上报)。 +public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs); + +public sealed record ProbeView(ProbeRecord Probe, IReadOnlyList Connections) +{ + public bool Revoked => Probe.RevokedAtMs is not null; +} + +/// +/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。 +/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(peers);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话。 +/// 一条线路是一对(Probe,Agent)。 +/// +public sealed partial class ProbeService +{ + private readonly IProbeStore _probes; + private readonly IAgentStore _agents; + private readonly AgentService _agentService; + private readonly AgentRuntime _runtime; + private readonly IAgentGateway _gateway; + private readonly TimeProvider _time; + + public ProbeService(IProbeStore probes, IAgentStore agents, AgentService agentService, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time) + { + _probes = probes; + _agents = agents; + _agentService = agentService; + _runtime = runtime; + _gateway = gateway; + _time = time; + } + + public async ValueTask> CreateAsync(string? name, CancellationToken ct) + { + string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim(); + if (!NamePattern().IsMatch(finalName)) + { + return ValidationError.Fail("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'"); + } + + if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase))) + { + return Result.Fail(MonitorErrors.NameConflict, $"a probe named {finalName} already exists"); + } + + using NodeIdentity identity = NodeIdentity.Generate(); + var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null); + await _probes.SaveAsync(probe, ct); + _gateway.NotifyAllTaskSetsChanged(); + return Result.Ok(await BuildFileAsync(probe, identity, ct)); + } + + public async ValueTask> ListAsync(CancellationToken ct) + { + IReadOnlyList probes = await _probes.ListAsync(ct); + IReadOnlyList agents = await _agents.ListAsync(ct); + return [.. probes.Select(p => View(p, agents))]; + } + + public async ValueTask GetAsync(string id, CancellationToken ct) => + await _probes.GetAsync(id, ct) is { } probe ? View(probe, await _agents.ListAsync(ct)) : null; + + public async ValueTask> RenameAsync(string id, string name, CancellationToken ct) + { + if (await _probes.GetAsync(id, ct) is not { } probe) + { + return Result.Fail(MonitorErrors.ProbeNotFound, "probe not found"); + } + + string trimmed = name.Trim(); + if (!NamePattern().IsMatch(trimmed)) + { + return ValidationError.Fail("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'"); + } + + if ((await _probes.ListAsync(ct)).Any(p => p.Id != id && string.Equals(p.Name, trimmed, StringComparison.OrdinalIgnoreCase))) + { + return Result.Fail(MonitorErrors.NameConflict, $"a probe named {trimmed} already exists"); + } + + await _probes.SaveAsync(probe with { Name = trimmed }, ct); + return Result.Ok((await GetAsync(id, ct))!); + } + + /// 换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。 + public async ValueTask> RotateAsync(string id, CancellationToken ct) + { + if (await _probes.GetAsync(id, ct) is not { } probe) + { + return Result.Fail(MonitorErrors.ProbeNotFound, "probe not found"); + } + + if (probe.RevokedAtMs is not null) + { + return Result.Fail(MonitorErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead"); + } + + using NodeIdentity identity = NodeIdentity.Generate(); + ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki }; + await _probes.SaveAsync(updated, ct); + _gateway.NotifyAllTaskSetsChanged(); + await DisconnectEverywhereAsync(id, ct); + return Result.Ok(await BuildFileAsync(updated, identity, ct)); + } + + /// 吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。 + public async ValueTask> RevokeAsync(string id, CancellationToken ct) + { + if (await _probes.GetAsync(id, ct) is null) + { + return Result.Fail(MonitorErrors.ProbeNotFound, "probe not found"); + } + + await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + _gateway.NotifyAllTaskSetsChanged(); + return Result.Ok((await GetAsync(id, ct))!); + } + + /// 删除:Probe 身份和它的线路从面板消失;已经入库的数据按保留策略自然过期。 + public async ValueTask DeleteAsync(string id, CancellationToken ct) + { + bool deleted = await _probes.DeleteAsync(id, ct); + if (deleted) + { + _gateway.NotifyAllTaskSetsChanged(); + } + + return deleted; + } + + /// 让某个 Agent 断开这个 Probe(Probe 会按退避重连,除非它已经被吊销)。 + public async ValueTask> DisconnectAsync(string probeId, NodeId agentId, CancellationToken ct) + { + if (await _probes.GetAsync(probeId, ct) is null) + { + return Result.Fail(MonitorErrors.ProbeNotFound, "probe not found"); + } + + if (await _agents.GetAsync(agentId, ct) is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } + + return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct); + } + + private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct) + { + foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId)) + { + await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct); + } + } + + private ProbeView View(ProbeRecord probe, IReadOnlyList agents) + { + var connections = new List(); + foreach ((NodeId agentId, ConnectedProbeInfo info) in _runtime.ConnectionsOf(probe.Id)) + { + AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId); + if (agent is not null) + { + connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs)); + } + } + + return new ProbeView(probe, [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]); + } + + private async ValueTask BuildFileAsync(ProbeRecord probe, NodeIdentity identity, CancellationToken ct) + { + ProbeAgentsResult agents = await _agentService.ProbeAgentsAsync(ct); + return new ProbeIdentityFile( + new ProbeId(probe.Id), + Convert.ToBase64String(identity.ExportPkcs8()), + [.. agents.Agents.Select(a => new BootstrapAgent(a.AgentId, a.Host, a.TcpPort, a.Spki))], + new ProbeLimits(MaxTestMbps: 100, MaxTotalPps: 20_000)); + } + + [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] + private static partial Regex NamePattern(); +} diff --git a/backend/src/Monitor.Backend.Application/Analysis/AnalysisScheduler.cs b/backend/src/Monitor.Backend.Application/Analysis/AnalysisScheduler.cs new file mode 100644 index 0000000..e29a1f1 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Analysis/AnalysisScheduler.cs @@ -0,0 +1,135 @@ +using Fleet.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Application; + +/// +/// 持久路径:分钟窗口就绪(窗口结束 + 宽限,或迟到的块到达)之后从原始块计算 → 写入 metric_1m → 标记降采样脏桶; +/// 然后把脏桶合并成 5 分钟、1 小时层。 +/// +public sealed partial class AnalysisScheduler : BackgroundService +{ + private const int BatchSize = 50; + + private readonly IMetricStore _metrics; + private readonly WindowComputer _computer; + private readonly BackendOptions _options; + private readonly BackendCounters _counters; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public AnalysisScheduler(IMetricStore metrics, WindowComputer computer, BackendOptions options, BackendCounters counters, TimeProvider time, ILogger logger) + { + _metrics = metrics; + _computer = computer; + _options = options; + _counters = counters; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + int worked; + do + { + worked = await RunOnceAsync(stoppingToken); + } + while (worked >= BatchSize); + + await Task.Delay(_options.AnalysisPollInterval, _time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // 后台循环不能因为一次失败而退出 + LogFailed(ex); + await Task.Delay(_options.AnalysisPollInterval, _time, stoppingToken); + } + } + } + + /// 处理一批到期的窗口和脏桶,返回处理的窗口数(也供测试直接调用)。 + public async Task RunOnceAsync(CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + IReadOnlyList due = await _metrics.DequeueDueAsync(now, BatchSize, ct); + foreach (QueueItem item in due) + { + await ComputeWindowAsync(item, ct); + _counters.CountWindowAnalyzed(); + } + + if (due.Count > 0) + { + await _metrics.CompleteAsync(due, ct); + } + + await RollupAsync(ct); + return due.Count; + } + + private async ValueTask ComputeWindowAsync(QueueItem item, CancellationToken ct) + { + long width = MetricLevel.M1.WidthMs(); + WindowComputer.Computed computed = await _computer.ComputeAsync(item.AgentId, item.Series, item.WindowStart, item.WindowStart + width, width, null, ct); + if (!computed.Buckets.TryGetValue(item.WindowStart, out MetricSet? set)) + { + return; + } + + await _metrics.UpsertAsync(MetricLevel.M1, [new MetricRow(item.AgentId, item.Series, item.WindowStart, computed.AnyPartial, AccumulatorCodec.Encode(set))], ct); + await MarkUpperLevelsAsync(item.AgentId, item.Series, item.WindowStart, ct); + } + + internal async ValueTask MarkUpperLevelsAsync(NodeId agent, string series, long windowStart, CancellationToken ct) + { + await _metrics.MarkRollupDirtyAsync(MetricLevel.M5, agent, series, Floor(windowStart, MetricLevel.M5), ct); + } + + /// 降采样:读取下一级的累加器状态,精确合并后写回。 + private async ValueTask RollupAsync(CancellationToken ct) + { + foreach ((MetricLevel level, NodeId agent, string series, long bucketStart) in await _metrics.TakeRollupDirtyAsync(100, ct)) + { + MetricLevel source = level == MetricLevel.M5 ? MetricLevel.M1 : MetricLevel.M5; + long end = bucketStart + level.WidthMs(); + IReadOnlyList rows = await _metrics.ReadAsync(source, agent, series, bucketStart, end, ct); + if (rows.Count == 0) + { + continue; + } + + var merged = new MetricSet(); + bool partial = false; + foreach (MetricRow row in rows) + { + if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? set, out _)) + { + merged.Merge(set); + partial |= row.Partial; + } + } + + await _metrics.UpsertAsync(level, [new MetricRow(agent, series, bucketStart, partial, AccumulatorCodec.Encode(merged))], ct); + if (level == MetricLevel.M5) + { + await _metrics.MarkRollupDirtyAsync(MetricLevel.H1, agent, series, Floor(bucketStart, MetricLevel.H1), ct); + } + } + } + + internal static long Floor(long ms, MetricLevel level) => Math.DivRem(ms, level.WidthMs(), out _) * level.WidthMs(); + + [LoggerMessage(Level = LogLevel.Error, Message = "analysis iteration failed")] + private partial void LogFailed(Exception exception); +} diff --git a/backend/src/Monitor.Backend.Application/Analysis/MetricRecompute.cs b/backend/src/Monitor.Backend.Application/Analysis/MetricRecompute.cs new file mode 100644 index 0000000..0214343 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Analysis/MetricRecompute.cs @@ -0,0 +1,157 @@ +using Fleet.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Application; + +public sealed record RecomputeProgress(string State, IReadOnlyList Metrics, long WindowsDone, long WindowsTotal, long? FromMs, long? ToMs); + +/// +/// 指标版本与回填:每个 有指纹(声明内容 + 算子签名)。启动时与 metric_versions 比对, +/// 发现新增或变化的指标,就在保留期内的原始数据上重新计算这些指标(低优先级、限速),完成后记录新指纹。 +/// 回填结果只替换这些指标,其他指标保持不变;超出原始数据保留期的历史保留旧口径。 +/// +public sealed partial class MetricRecompute : BackgroundService +{ + private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(5); + + private readonly IMetricStore _metrics; + private readonly IRawStore _raw; + private readonly WindowComputer _computer; + private readonly AnalysisScheduler _scheduler; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly ILogger _logger; + private RecomputeProgress _progress = new("idle", [], 0, 0, null, null); + + public MetricRecompute(IMetricStore metrics, IRawStore raw, WindowComputer computer, AnalysisScheduler scheduler, BackendOptions options, TimeProvider time, ILogger logger) + { + _metrics = metrics; + _raw = raw; + _computer = computer; + _scheduler = scheduler; + _options = options; + _time = time; + _logger = logger; + } + + public RecomputeProgress Progress => Volatile.Read(ref _progress); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await RunAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // 正常停止 + } + catch (Exception ex) + { + LogFailed(ex); + Volatile.Write(ref _progress, Progress with { State = "failed" }); + } + } + + /// 比对指纹并回填(也供测试直接调用)。 + public async Task RunAsync(CancellationToken ct) + { + IReadOnlyList known = await _metrics.GetVersionsAsync(ct); + var knownById = known.ToDictionary(k => k.MetricId, k => k.Fingerprint, StringComparer.Ordinal); + List changed = [.. MetricRegistry.All.Where(m => !knownById.TryGetValue(m.Id, out string? fp) || fp != m.Fingerprint)]; + if (changed.Count == 0) + { + Volatile.Write(ref _progress, new RecomputeProgress("idle", [], 0, 0, null, null)); + return; + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long from = now - (long)TimeSpan.FromDays(_options.RawRetentionDays).TotalMilliseconds; + HashSet ids = [.. changed.Select(m => m.Id)]; + IReadOnlyList<(NodeId AgentId, string Series)> series = await _raw.ListSeriesAsync(from, now, ct); + + // 先数一遍窗口,用于进度 + var plan = new List<(NodeId Agent, string Series, List Windows)>(); + long total = 0; + foreach ((NodeId agent, string seriesId) in series) + { + List windows = await ListWindowsAsync(agent, seriesId, from, now, ct); + plan.Add((agent, seriesId, windows)); + total += windows.Count; + } + + Volatile.Write(ref _progress, new RecomputeProgress("running", [.. ids], 0, total, from, now)); + long done = 0; + long tenSecondsFloor = now - (long)TimeSpan.FromHours(_options.Metric10sRetentionHours).TotalMilliseconds; + foreach ((NodeId agent, string seriesId, List windows) in plan) + { + foreach (long window in windows) + { + ct.ThrowIfCancellationRequested(); + await RecomputeWindowAsync(agent, seriesId, window, ids, window >= tenSecondsFloor, ct); + Volatile.Write(ref _progress, new RecomputeProgress("running", [.. ids], ++done, total, from, now)); + await Task.Delay(s_pause, _time, ct); + } + } + + foreach (MetricDef metric in changed) + { + await _metrics.SetVersionAsync(new MetricVersionRecord(metric.Id, metric.Fingerprint, null, null), ct); + } + + Volatile.Write(ref _progress, new RecomputeProgress("done", [.. ids], done, total, from, now)); + } + + private async ValueTask> ListWindowsAsync(NodeId agent, string series, long from, long to, CancellationToken ct) + { + long minute = MetricLevel.M1.WidthMs(); + var windows = new SortedSet(); + long? cursor = null; + while (true) + { + IReadOnlyList page = await _raw.FindAsync(new RawQuery(agent, series, FromMs: from, ToMs: to, AfterStartTs: cursor, Limit: 500), ct); + foreach (RawChunkMeta meta in page.Where(m => m.TestId is null)) + { + for (long w = Math.DivRem(meta.StartTs, minute, out _) * minute; w <= meta.StartTs + meta.DurationMs; w += minute) + { + windows.Add(w); + } + } + + if (page.Count < 500) + { + return [.. windows]; + } + + cursor = page[^1].StartTs; + } + } + + private async ValueTask RecomputeWindowAsync(NodeId agent, string series, long window, ISet only, bool includeTenSeconds, CancellationToken ct) + { + long minute = MetricLevel.M1.WidthMs(); + if (includeTenSeconds) + { + WindowComputer.Computed ten = await _computer.ComputeAsync(agent, series, window, window + minute, MetricLevel.S10.WidthMs(), only, ct); + if (ten.Buckets.Count > 0) + { + await _metrics.PatchAsync( + MetricLevel.S10, + [.. ten.Buckets.Select(b => new MetricRow(agent, series, b.Key, ten.AnyPartial, AccumulatorCodec.Encode(b.Value)))], + (IReadOnlySet)only, ct); + } + } + + WindowComputer.Computed one = await _computer.ComputeAsync(agent, series, window, window + minute, minute, only, ct); + if (one.Buckets.TryGetValue(window, out MetricSet? set)) + { + await _metrics.PatchAsync(MetricLevel.M1, [new MetricRow(agent, series, window, one.AnyPartial, AccumulatorCodec.Encode(set))], (IReadOnlySet)only, ct); + await _scheduler.MarkUpperLevelsAsync(agent, series, window, ct); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "metric recompute failed")] + private partial void LogFailed(Exception exception); +} diff --git a/backend/src/Monitor.Backend.Application/Analysis/WindowComputer.cs b/backend/src/Monitor.Backend.Application/Analysis/WindowComputer.cs new file mode 100644 index 0000000..f5328f2 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Analysis/WindowComputer.cs @@ -0,0 +1,64 @@ +using Fleet.Core; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// 从原始块重新计算一个窗口(持久路径与回填共用):读取与窗口重叠的块 → 评估 → 取出窗口对应的累加器。 +/// 完全由原始数据决定,所以重复执行结果相同(幂等)。 +/// +public sealed class WindowComputer +{ + /// 块最长 10 分钟,所以起点早于窗口 10 分钟的块不会与窗口重叠。 + private const long MaxChunkSpanMs = 600_000; + + private readonly IRawStore _raw; + + public WindowComputer(IRawStore raw) + { + _raw = raw; + } + + public sealed record Computed(SortedDictionary Buckets, bool AnyPartial, int ChunkCount); + + /// + /// 在 [windowStart, windowEnd) 内按 分桶评估; 不为空时只算这些指标。 + /// + public async ValueTask ComputeAsync(NodeId agent, string series, long windowStart, long windowEnd, long bucketMs, ISet? only, CancellationToken ct) + { + IReadOnlyList metas = await _raw.FindAsync( + new RawQuery(agent, series, FromMs: windowStart - MaxChunkSpanMs, ToMs: windowEnd, Limit: 2000), ct); + var all = new SortedDictionary(); + bool partial = false; + int count = 0; + foreach (RawChunkMeta meta in metas) + { + if (meta.TestId is not null || meta.StartTs + meta.DurationMs <= windowStart) + { + continue; + } + + byte[]? bytes = await _raw.ReadAsync(meta, ct); + if (bytes is null || !RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _)) + { + continue; + } + + count++; + partial |= meta.Partial; + WindowEvaluator.Default.Evaluate(chunk, bucketMs, all, only); + } + + var inside = new SortedDictionary(); + foreach ((long start, MetricSet set) in all) + { + if (start >= windowStart && start < windowEnd) + { + inside[start] = set; + } + } + + return new Computed(inside, partial, count); + } +} diff --git a/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..e5800b6 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using Fleet.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Monitor.Backend.Application; + +public static class ApplicationServiceCollectionExtensions +{ + /// + /// 注册 Application 层的全部服务。需要事先注册端口的实现(存储、)和 。 + /// 后台任务按这里的顺序启动: 必须在最前面。 + /// + public static IServiceCollection AddMonitorApplication(this IServiceCollection services, BackendOptions options) + { + services.AddSingleton(options); + services.AddHostedService(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddHostedService(); + services.AddHostedService(); + return services; + } +} diff --git a/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs b/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs new file mode 100644 index 0000000..e157b4c --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Auth/AuthModels.cs @@ -0,0 +1,29 @@ +namespace Monitor.Backend.Application; + +public enum PrincipalKind +{ + Anonymous, + Session, + Token, +} + +/// 访问级别:作用域为 read 的令牌只读,admin 可以管理。 +public enum ApiScope +{ + None = 0, + Read = 1, + Admin = 2, +} + +public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId) +{ + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null); + + public bool CanRead => Scope >= ApiScope.Read; + + public bool IsAdmin => Scope >= ApiScope.Admin; +} + +public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username); + +public sealed record CreatedApiToken(ApiTokenRecord Record, string Plaintext); diff --git a/backend/src/Monitor.Backend.Application/Auth/AuthService.cs b/backend/src/Monitor.Backend.Application/Auth/AuthService.cs new file mode 100644 index 0000000..18780c9 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Auth/AuthService.cs @@ -0,0 +1,172 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 面板用户、Cookie 会话、API 令牌。密码用 PBKDF2-SHA256,令牌和会话只保存 SHA-256。 +public sealed partial class AuthService +{ + private const int MinPasswordLength = 8; + private const int MaxPasswordLength = 256; + private const long TokenTouchIntervalMs = 60_000; + + private readonly IAuthStore _store; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly byte[] _dummySalt = RandomNumberGenerator.GetBytes(16); + + public AuthService(IAuthStore store, BackendOptions options, TimeProvider time) + { + _store = store; + _options = options; + _time = time; + } + + /// 登录失败一律返回 unauthorized,不区分“用户不存在”和“密码错误”。 + public async ValueTask> LoginAsync(string username, string password, CancellationToken ct) + { + UserRecord? user = await _store.GetUserByNameAsync(username, ct); + byte[] salt = user?.PasswordSalt ?? _dummySalt; + int iterations = user?.Iterations ?? _options.Pbkdf2Iterations; + byte[] hash = Hash(password, salt, iterations); + if (user is null || !CryptographicOperations.FixedTimeEquals(hash, user.PasswordHash)) + { + return Result.Fail(MonitorErrors.Unauthorized, "invalid credentials"); + } + + byte[] token = RandomNumberGenerator.GetBytes(32); + byte[] csrf = RandomNumberGenerator.GetBytes(32); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long expires = now + (long)_options.SessionLifetime.TotalMilliseconds; + await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct); + await _store.DeleteExpiredSessionsAsync(now, ct); + return Result.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username)); + } + + public async ValueTask AuthenticateSessionAsync(string sessionToken, CancellationToken ct) + { + if (!Base64Url.TryDecode(sessionToken, out byte[] raw) || raw.Length != 32) + { + return null; + } + + SessionRecord? session = await _store.GetSessionAsync(SHA256.HashData(raw), ct); + if (session is null || session.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds()) + { + return null; + } + + UserRecord? user = await _store.GetUserAsync(session.UserId, ct); + return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); + } + + public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) + { + ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct); + if (token is null || token.RevokedAtMs is not null) + { + return null; + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs) + { + await _store.TouchApiTokenAsync(token.Id, now, ct); + } + + ApiScope scope = token.Scope == "admin" ? ApiScope.Admin : ApiScope.Read; + return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id); + } + + public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct) + { + if (Base64Url.TryDecode(sessionToken, out byte[] raw)) + { + await _store.DeleteSessionAsync(SHA256.HashData(raw), ct); + } + } + + public async ValueTask> CreateUserAsync(string username, string password, CancellationToken ct) + { + var errors = new Dictionary(); + if (!UsernamePattern().IsMatch(username)) + { + errors["username"] = "must be 3-32 characters of letters, digits, '.', '_' or '-'"; + } + + if (password.Length is < MinPasswordLength or > MaxPasswordLength) + { + errors["password"] = $"must be {MinPasswordLength}-{MaxPasswordLength} characters"; + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); + } + + byte[] salt = RandomNumberGenerator.GetBytes(16); + var user = new UserRecord(Ulid.NewPrefixed("usr", _time), username, Hash(password, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, _time.GetUtcNow().ToUnixTimeMilliseconds()); + return await _store.CreateUserAsync(user, ct) + ? Result.Ok(user) + : Result.Fail(MonitorErrors.NameConflict, "username already exists"); + } + + public async ValueTask> ChangePasswordAsync(string userId, string newPassword, CancellationToken ct) + { + if (newPassword.Length is < MinPasswordLength or > MaxPasswordLength) + { + return ValidationError.Fail("password", $"must be {MinPasswordLength}-{MaxPasswordLength} characters"); + } + + byte[] salt = RandomNumberGenerator.GetBytes(16); + await _store.UpdatePasswordAsync(userId, Hash(newPassword, salt, _options.Pbkdf2Iterations), salt, _options.Pbkdf2Iterations, ct); + return Result.Ok(true); + } + + public ValueTask> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct); + + public async ValueTask> CreateApiTokenAsync(string name, string scope, CancellationToken ct) + { + if (name.Length is 0 or > 64) + { + return ValidationError.Fail("name", "must be 1-64 characters"); + } + + if (scope is not ("read" or "admin")) + { + return ValidationError.Fail("scope", "must be read or admin"); + } + + string plaintext = TokenHash.NewToken("mon"); + var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null); + await _store.CreateApiTokenAsync(record, ct); + return Result.Ok(new CreatedApiToken(record, plaintext)); + } + + public ValueTask> ListApiTokensAsync(CancellationToken ct) => _store.ListApiTokensAsync(ct); + + public ValueTask RevokeApiTokenAsync(string id, CancellationToken ct) => _store.RevokeApiTokenAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + + /// 首次启动时(没有任何用户)用配置里的凭据创建初始管理员。 + public async ValueTask EnsureInitialAdminAsync(CancellationToken ct) + { + if (string.IsNullOrEmpty(_options.InitialAdminUser) || string.IsNullOrEmpty(_options.InitialAdminPassword) || await _store.CountUsersAsync(ct) > 0) + { + return false; + } + + return (await CreateUserAsync(_options.InitialAdminUser, _options.InitialAdminPassword, ct)).IsSuccess; + } + + public static bool FixedTimeEquals(string a, string b) => + CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); + + private static byte[] Hash(string password, byte[] salt, int iterations) => + Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, 32); + + [GeneratedRegex("^[A-Za-z0-9._-]{3,32}$")] + private static partial Regex UsernamePattern(); +} diff --git a/backend/src/Monitor.Backend.Application/Auth/LoginRateLimiter.cs b/backend/src/Monitor.Backend.Application/Auth/LoginRateLimiter.cs new file mode 100644 index 0000000..134cce6 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Auth/LoginRateLimiter.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; + +namespace Monitor.Backend.Application; + +/// 登录限流:每个来源在滑动的一分钟内最多尝试若干次。 +public sealed class LoginRateLimiter +{ + private readonly ConcurrentDictionary> _attempts = new(); + private readonly BackendOptions _options; + private readonly TimeProvider _time; + + public LoginRateLimiter(BackendOptions options, TimeProvider time) + { + _options = options; + _time = time; + } + + /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 + public bool TryAcquire(string source, out int retryAfterSeconds) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + Queue queue = _attempts.GetOrAdd(source, _ => new Queue()); + lock (queue) + { + while (queue.Count > 0 && now - queue.Peek() >= 60_000) + { + queue.Dequeue(); + } + + if (queue.Count >= _options.LoginAttemptsPerMinute) + { + retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000); + return false; + } + + queue.Enqueue(now); + retryAfterSeconds = 0; + return true; + } + } + + /// 清理很久没有尝试的来源,避免字典无限增长。 + public void Prune() + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + foreach ((string key, Queue queue) in _attempts) + { + lock (queue) + { + if (queue.Count == 0 || now - queue.Peek() >= 60_000) + { + _attempts.TryRemove(key, out _); + } + } + } + } +} diff --git a/backend/src/Monitor.Backend.Application/BackendCounters.cs b/backend/src/Monitor.Backend.Application/BackendCounters.cs new file mode 100644 index 0000000..032831f --- /dev/null +++ b/backend/src/Monitor.Backend.Application/BackendCounters.cs @@ -0,0 +1,40 @@ +namespace Monitor.Backend.Application; + +/// 进程内计数器,用于 /metrics(Prometheus 格式)。 +public sealed class BackendCounters +{ + private long _chunksIngested; + private long _chunksDuplicate; + private long _chunksDiscarded; + private long _bytesIngested; + private long _sseConnections; + private long _windowsAnalyzed; + + public long ChunksIngested => Interlocked.Read(ref _chunksIngested); + + public long ChunksDuplicate => Interlocked.Read(ref _chunksDuplicate); + + public long ChunksDiscarded => Interlocked.Read(ref _chunksDiscarded); + + public long BytesIngested => Interlocked.Read(ref _bytesIngested); + + public long SseConnections => Interlocked.Read(ref _sseConnections); + + public long WindowsAnalyzed => Interlocked.Read(ref _windowsAnalyzed); + + public void CountIngested(long bytes) + { + Interlocked.Increment(ref _chunksIngested); + Interlocked.Add(ref _bytesIngested, bytes); + } + + public void CountDuplicate() => Interlocked.Increment(ref _chunksDuplicate); + + public void CountDiscarded() => Interlocked.Increment(ref _chunksDiscarded); + + public void CountWindowAnalyzed() => Interlocked.Increment(ref _windowsAnalyzed); + + public void SseOpened() => Interlocked.Increment(ref _sseConnections); + + public void SseClosed() => Interlocked.Decrement(ref _sseConnections); +} diff --git a/backend/src/Monitor.Backend.Application/BackendOptions.cs b/backend/src/Monitor.Backend.Application/BackendOptions.cs new file mode 100644 index 0000000..ded13ac --- /dev/null +++ b/backend/src/Monitor.Backend.Application/BackendOptions.cs @@ -0,0 +1,62 @@ +namespace Monitor.Backend.Application; + +/// Backend 的可调参数(保留期等也可以通过 settings 改,这里是默认值与硬上限)。 +public sealed class BackendOptions +{ + /// 数据目录:monitor.db 与 raw/。 + public string DataDirectory { get; set; } = "data"; + + /// 面板的对外地址(用于生成安装命令)。 + public string? PublicUrl { get; set; } + + public int Pbkdf2Iterations { get; set; } = 600_000; + + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 + public bool SecureCookies { get; set; } = true; + + /// + /// 允许跨域调用 API 的前端来源,逗号分隔(如 http://localhost:5173,https://panel.example.com,或 *);空 = 不允许,只有同源。 + /// 跨域只支持 Bearer 令牌(不带 Cookie),所以不影响会话的安全。 + /// + public string? CorsOrigins { get; set; } + + public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12); + + public int LoginAttemptsPerMinute { get; set; } = 10; + + public int RawRetentionDays { get; set; } = 30; + + public int TestRawRetentionDays { get; set; } = 90; + + public int Metric10sRetentionHours { get; set; } = 48; + + public int Metric1mRetentionDays { get; set; } = 30; + + public int Metric5mRetentionDays { get; set; } = 365; + + public int EventRetentionDays { get; set; } = 365; + + public int HostRetentionDays { get; set; } = 7; + + /// 分钟窗口在结束之后再等一会儿,让迟到的块先到。 + public TimeSpan WindowGrace { get; set; } = TimeSpan.FromSeconds(60); + + public TimeSpan AnalysisPollInterval { get; set; } = TimeSpan.FromSeconds(5); + + public TimeSpan QueryTimeout { get; set; } = TimeSpan.FromSeconds(10); + + public int MaxSsePerClient { get; set; } = 4; + + public int MaxSseTopics { get; set; } = 32; + + public int MaxPointsPerSeries { get; set; } = 2000; + + public int MaxMetricsPerQuery { get; set; } = 32; + + public int MaxCompareAgents { get; set; } = 20; + + /// 初始管理员(只在没有任何用户时创建)。 + public string? InitialAdminUser { get; set; } + + public string? InitialAdminPassword { get; set; } +} diff --git a/backend/src/Monitor.Backend.Application/Ingest/IngestService.cs b/backend/src/Monitor.Backend.Application/Ingest/IngestService.cs new file mode 100644 index 0000000..4bbc381 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Ingest/IngestService.cs @@ -0,0 +1,146 @@ +using Fleet.Core; +using Microsoft.Extensions.Logging; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// 和 都会让节点把消息从发件箱里移除。 +/// 表示这条消息永远不可能被接受(损坏、冒名、未知 Kind、时间离谱), +/// 同样确认掉,否则节点会无限重发——原因记入日志和事件。 +/// +public enum IngestOutcome +{ + Stored, + Duplicate, + Discarded, +} + +/// +/// 原始块入库:校验 → 追加到段文件并写索引 → 流式路径(1s 热层 + 10s 层)→ 登记分钟窗口等待持久路径计算。 +/// 持久化成功之后才返回,Fleet 随后才会 ACK。 +/// +public sealed partial class IngestService +{ + private const long MaxFutureSkewMs = 3_600_000; + private const int MaxDurationMs = 600_000; + + private readonly IRawStore _raw; + private readonly IMetricStore _metrics; + private readonly HotLayer _hot; + private readonly LiveHub _live; + private readonly BackendCounters _counters; + private readonly IEventStore _events; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public IngestService( + IRawStore raw, + IMetricStore metrics, + HotLayer hot, + LiveHub live, + BackendCounters counters, + IEventStore events, + BackendOptions options, + TimeProvider time, + ILogger logger) + { + _raw = raw; + _metrics = metrics; + _hot = hot; + _live = live; + _counters = counters; + _events = events; + _options = options; + _time = time; + _logger = logger; + } + + public async ValueTask IngestAsync(NodeId agent, Guid msgId, ReadOnlyMemory bytes, CancellationToken ct) + { + Result decoded = RawChunkCodec.Decode(bytes.Span); + if (!decoded.TryGet(out RawChunk? chunk, out FleetError? error)) + { + return await DiscardAsync(agent, "undecodable chunk: " + error.Message, ct); + } + + RawChunkHeader header = chunk.Header; + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + string? problem = + header.AgentId != agent ? "agent_id does not match the authenticated node" + : header.ChunkId.Value != msgId ? "chunk_id does not match the message id" + : Kinds.Find(header.Kind) is null ? $"unknown kind {header.Kind}" + : header.DurationMs is <= 0 or > MaxDurationMs ? "duration out of range" + : header.StartUtcMs > now + MaxFutureSkewMs ? "chunk starts in the future" + : header.StartUtcMs < now - (long)TimeSpan.FromDays(_options.TestRawRetentionDays).TotalMilliseconds ? "chunk is older than the retention" + : null; + if (problem is not null) + { + return await DiscardAsync(agent, problem, ct); + } + + string series = header.Series; + var meta = new RawChunkMeta( + header.ChunkId, agent, series, header.Kind, header.TaskId, header.TestId, header.Phase, + header.StartUtcMs, header.DurationMs, header.Partial, "", 0, bytes.Length); + bool stored = await _raw.AppendAsync(new RawChunkAppend(meta, bytes, DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(header.StartUtcMs).UtcDateTime).ToString("yyyy-MM-dd")), ct); + if (!stored) + { + _counters.CountDuplicate(); + return IngestOutcome.Duplicate; + } + + _counters.CountIngested(bytes.Length); + if (header.TestId is { } testId) + { + // 测试数据不进入持续线路的指标 + _live.Publish(new TestSignal(testId)); + return IngestOutcome.Stored; + } + + await FeedAnalysisAsync(agent, series, chunk, now, ct); + _live.Publish(new ChunkSignal(agent, series)); + return IngestOutcome.Stored; + } + + private async ValueTask FeedAnalysisAsync(NodeId agent, string series, RawChunk chunk, long now, CancellationToken ct) + { + _hot.Merge(agent, series, chunk); + + var tenSeconds = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, MetricLevel.S10.WidthMs(), tenSeconds); + var rows = new List(tenSeconds.Count); + foreach ((long start, MetricSet set) in tenSeconds) + { + rows.Add(new MetricRow(agent, series, start, chunk.Header.Partial, AccumulatorCodec.Encode(set))); + } + + await _metrics.MergeAsync(MetricLevel.S10, rows, ct); + + // 持久路径:块覆盖到的每个分钟窗口,等窗口结束再加宽限时间之后计算;迟到的块立即重算 + long minute = MetricLevel.M1.WidthMs(); + long first = Math.DivRem(chunk.Header.StartUtcMs, minute, out _) * minute; + long last = Math.DivRem(chunk.Header.StartUtcMs + chunk.Header.DurationMs, minute, out _) * minute; + var queue = new List(); + for (long window = first; window <= last; window += minute) + { + long ready = window + minute + (long)_options.WindowGrace.TotalMilliseconds; + queue.Add(new QueueItem(agent, series, window, Math.Max(now, ready), ready <= now ? "late_chunk" : "new")); + } + + await _metrics.EnqueueAsync(queue, ct); + } + + private async ValueTask DiscardAsync(NodeId agent, string reason, CancellationToken ct) + { + _counters.CountDiscarded(); + LogDiscarded(agent.Value, reason); + await _events.AddAsync(agent, "chunk_rejected", _time.GetUtcNow().ToUnixTimeMilliseconds(), reason, ct); + return IngestOutcome.Discarded; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "chunk discarded agent={Agent} err={Err}")] + private partial void LogDiscarded(string agent, string err); +} diff --git a/backend/src/Monitor.Backend.Application/Live/HotLayer.cs b/backend/src/Monitor.Backend.Application/Live/HotLayer.cs new file mode 100644 index 0000000..2b921b7 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Live/HotLayer.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using Fleet.Core; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// 1 秒精度的内存热层:最近 1 小时的累加器。桶以 编码后的字节保存, +/// 15 条线路约十几 MB;它同时是 SSE 实时推送的数据来源。重启后为空,由后台任务从原始块回填。 +/// +public sealed class HotLayer +{ + public const long BucketMs = 1000; + public static readonly TimeSpan Retention = TimeSpan.FromHours(1); + + private readonly ConcurrentDictionary<(NodeId Agent, string Series), Ring> _rings = new(); + private readonly TimeProvider _time; + private volatile bool _ready; + + public HotLayer(TimeProvider time) + { + _time = time; + } + + /// 回填完成之前热层不完整,查询规划器不会选用它。 + public bool Ready => _ready; + + public void MarkReady() => _ready = true; + + public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys; + + /// 评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。 + public void Merge(NodeId agent, string series, RawChunk chunk) + { + Ring ring = _rings.GetOrAdd((agent, series), _ => new Ring()); + var buckets = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, BucketMs, buckets); + long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (long)Retention.TotalMilliseconds; + lock (ring) + { + if (!ring.Chunks.Add(chunk.Header.ChunkId)) + { + return; + } + + foreach ((long start, MetricSet set) in buckets) + { + if (start < cutoff) + { + continue; + } + + if (ring.Buckets.TryGetValue(start, out byte[]? existing) && AccumulatorCodec.Decode(existing).TryGet(out MetricSet? merged, out _)) + { + merged.Merge(set); + ring.Buckets[start] = AccumulatorCodec.Encode(merged); + } + else + { + ring.Buckets[start] = AccumulatorCodec.Encode(set); + } + } + + Prune(ring, cutoff); + } + } + + /// [from, to) 内的 1 秒桶(解码后的副本,按时间升序)。 + public IReadOnlyList> GetRange(NodeId agent, string series, long fromMs, long toMs) + { + if (!_rings.TryGetValue((agent, series), out Ring? ring)) + { + return []; + } + + var result = new List>(); + lock (ring) + { + foreach ((long start, byte[] bytes) in ring.Buckets) + { + if (start >= toMs) + { + break; + } + + if (start >= fromMs && AccumulatorCodec.Decode(bytes).TryGet(out MetricSet? set, out _)) + { + result.Add(new KeyValuePair(start, set)); + } + } + } + + return result; + } + + public int BucketCount(NodeId agent, string series) + { + if (!_rings.TryGetValue((agent, series), out Ring? ring)) + { + return 0; + } + + lock (ring) + { + return ring.Buckets.Count; + } + } + + public void Remove(NodeId agent) + { + foreach ((NodeId Agent, string Series) key in _rings.Keys.Where(k => k.Agent == agent).ToList()) + { + _rings.TryRemove(key, out _); + } + } + + private static void Prune(Ring ring, long cutoff) + { + while (ring.Buckets.Count > 0) + { + long first = ring.Buckets.Keys.First(); + if (first >= cutoff) + { + break; + } + + ring.Buckets.Remove(first); + } + + // 块 ID 集合只需要覆盖热层的时间窗,限制大小即可 + if (ring.Chunks.Count > 2000) + { + ring.Chunks.Clear(); + } + } + + private sealed class Ring + { + public SortedDictionary Buckets { get; } = []; + + public HashSet Chunks { get; } = []; + } +} diff --git a/backend/src/Monitor.Backend.Application/Live/HotLayerBackfill.cs b/backend/src/Monitor.Backend.Application/Live/HotLayerBackfill.cs new file mode 100644 index 0000000..73dd177 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Live/HotLayerBackfill.cs @@ -0,0 +1,67 @@ +using Fleet.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 重启后热层为空:从原始块回填最近 1 小时(低优先级、逐块让出)。完成之前查询规划器不会选用热层。 +public sealed partial class HotLayerBackfill : BackgroundService +{ + private readonly HotLayer _hot; + private readonly IRawStore _raw; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public HotLayerBackfill(HotLayer hot, IRawStore raw, TimeProvider time, ILogger logger) + { + _hot = hot; + _raw = raw; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await RunAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // 正常停止 + } + catch (Exception ex) + { + // 回填失败不应该让热层永远不可用:标记就绪,缺的部分会通过降级到 10s 层之外的查询体现 + LogFailed(ex); + _hot.MarkReady(); + } + } + + /// 回填(也供测试直接调用)。 + public async Task RunAsync(CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long from = now - (long)HotLayer.Retention.TotalMilliseconds - 600_000; + foreach ((NodeId agent, string series) in await _raw.ListSeriesAsync(from, now, ct)) + { + IReadOnlyList metas = await _raw.FindAsync(new RawQuery(agent, series, FromMs: from, ToMs: now, Limit: 2000), ct); + foreach (RawChunkMeta meta in metas.Where(m => m.TestId is null)) + { + byte[]? bytes = await _raw.ReadAsync(meta, ct); + if (bytes is not null && RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _)) + { + _hot.Merge(agent, series, chunk); + } + + await Task.Yield(); + } + } + + _hot.MarkReady(); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "hot layer backfill failed")] + private partial void LogFailed(Exception exception); +} diff --git a/backend/src/Monitor.Backend.Application/Live/LiveHub.cs b/backend/src/Monitor.Backend.Application/Live/LiveHub.cs new file mode 100644 index 0000000..f0dc7cc --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Live/LiveHub.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 发布到 SSE 订阅者的信号。SSE 只负责“更快地获得变化”,推送的内容都能由 REST 查询重建。 +public abstract record LiveSignal; + +/// 节点上下线等。 +public sealed record NodeSignal(string Kind, string Id, bool Online, long AtMs) : LiveSignal; + +/// 某条序列有新的原始块入库。 +public sealed record ChunkSignal(NodeId AgentId, string Series) : LiveSignal; + +public sealed record TestSignal(TestId TestId) : LiveSignal; + +/// 某个 Agent 的主机资源占用有了新的快照(实时推给展示页)。 +public sealed record HostSignal(NodeId AgentId, HostSnapshot Host) : LiveSignal; + +public sealed class LiveSubscription : IDisposable +{ + private readonly LiveHub _hub; + private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(256) { FullMode = BoundedChannelFullMode.DropOldest }); + + internal LiveSubscription(LiveHub hub) + { + _hub = hub; + } + + public ChannelReader Reader => _channel.Reader; + + internal void Post(LiveSignal signal) => _channel.Writer.TryWrite(signal); + + public void Dispose() + { + _hub.Remove(this); + _channel.Writer.TryComplete(); + } +} + +/// 内存中的发布订阅:入库、节点上下线、测试状态变化都经过这里。 +public sealed class LiveHub +{ + private readonly ConcurrentDictionary _subscriptions = new(); + + public int SubscriberCount => _subscriptions.Count; + + public LiveSubscription Subscribe() + { + var subscription = new LiveSubscription(this); + _subscriptions[subscription] = 0; + return subscription; + } + + public void Publish(LiveSignal signal) + { + foreach (LiveSubscription subscription in _subscriptions.Keys) + { + subscription.Post(signal); + } + } + + internal void Remove(LiveSubscription subscription) => _subscriptions.TryRemove(subscription, out _); +} diff --git a/backend/src/Monitor.Backend.Application/Models/AgentModels.cs b/backend/src/Monitor.Backend.Application/Models/AgentModels.cs new file mode 100644 index 0000000..2b7dd90 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Models/AgentModels.cs @@ -0,0 +1,62 @@ +using Fleet.Core; +using Fleet.Server; + +namespace Monitor.Backend.Application; + +public sealed record AgentRecord( + NodeId Id, + string Name, + NodeStatus Status, + byte[] PublicKey, + byte[] TlsSpki, + string PublicHost, + int ProbeTcpPort, + IReadOnlyList ProbeUdpPorts, + IReadOnlyList Labels, + bool PublicVisible, + IReadOnlyList Kinds, + string? AgentVersion, + string? LocalLimitsJson, + long TaskVersion, + long AppliedTaskVersion, + string? RejectedJson, + long CreatedAtMs, + long? LastSeenAtMs, + long RowVersion); + +/// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 +public sealed class AgentPatch +{ + public string? Name { get; init; } + + public IReadOnlyList? Labels { get; init; } + + public string? PublicHost { get; init; } + + public bool? PublicVisible { get; init; } +} + +/// 来自 fleet.hello 的信息。 +public sealed record AgentHello( + IReadOnlyList Kinds, + string? AgentVersion, + string? LocalLimitsJson, + byte[]? TlsSpki, + int? ProbeTcpPort, + IReadOnlyList? ProbeUdpPorts); + +/// 注册之后 Monitor 的补充:公开地址和端口。 +public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); + +public sealed record JoinTokenInfo( + string Id, + string? Note, + IReadOnlyList Labels, + bool AutoApprove, + int MaxUses, + int Uses, + long ExpiresAtMs, + long? RevokedAtMs, + long CreatedAtMs); + +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs); diff --git a/backend/src/Monitor.Backend.Application/Models/RawModels.cs b/backend/src/Monitor.Backend.Application/Models/RawModels.cs new file mode 100644 index 0000000..4f2be36 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Models/RawModels.cs @@ -0,0 +1,64 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 原始块的索引信息(块本身在段文件里)。 +public sealed record RawChunkMeta( + ChunkId ChunkId, + NodeId AgentId, + string Series, + string Kind, + TaskId? TaskId, + TestId? TestId, + int? Phase, + long StartTs, + int DurationMs, + bool Partial, + string SegmentPath, + long Offset, + int Length); + +/// 写入一个原始块:块的完整字节加上用于索引的元数据。 +public sealed record RawChunkAppend(RawChunkMeta Meta, ReadOnlyMemory Bytes, string Day); + +public sealed record RawQuery( + NodeId? AgentId = null, + string? Series = null, + string? Kind = null, + TestId? TestId = null, + long? FromMs = null, + long? ToMs = null, + long? AfterStartTs = null, + int Limit = 500); + +public sealed record MetricRow(NodeId AgentId, string Series, long WindowStart, bool Partial, byte[] Metrics); + +/// 每次入队递增:处理完成时只删除序号没变的项,处理期间又有迟到的块时不会丢更新。 +public sealed record QueueItem(NodeId AgentId, string Series, long WindowStart, long NotBefore, string Reason, long Seq = 0); + +public sealed record MetricVersionRecord(string MetricId, string Fingerprint, long? RecomputeFrom, long? RecomputeCursor); + +/// 指标窗口的层级。数值是窗口宽度(秒)。 +public enum MetricLevel +{ + S10 = 10, + M1 = 60, + M5 = 300, + H1 = 3600, +} + +public static class MetricLevels +{ + public static readonly IReadOnlyList All = [MetricLevel.S10, MetricLevel.M1, MetricLevel.M5, MetricLevel.H1]; + + public static long WidthMs(this MetricLevel level) => (long)level * 1000; + + public static string TableName(this MetricLevel level) => level switch + { + MetricLevel.S10 => "metric_10s", + MetricLevel.M1 => "metric_1m", + MetricLevel.M5 => "metric_5m", + _ => "metric_1h", + }; +} diff --git a/backend/src/Monitor.Backend.Application/Models/TaskAndTestModels.cs b/backend/src/Monitor.Backend.Application/Models/TaskAndTestModels.cs new file mode 100644 index 0000000..7e732a1 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Models/TaskAndTestModels.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record TaskRecord( + TaskId Id, + string Scope, + NodeId? AgentId, + TaskId? BaseTaskId, + string Kind, + string? Target, + IReadOnlyDictionary Params, + string Schedule, + bool Enabled, + long UpdatedAtMs, + long RowVersion); + +public sealed record TestRecord( + TestId Id, + NodeId AgentId, + ProbeId ProbeId, + string Plan, + string ParamsJson, + string Trigger, + string Status, + string? ErrorCode, + int? Phase, + int PhaseCount, + long RequestedAtMs, + long? StartedAtMs, + long? FinishedAtMs); + +/// 测试结果:指标与曲线都以 JSON 保存。 +public sealed record TestResultRecord(TestId TestId, byte[] MetricsJson, byte[] SeriesJson); + +public sealed record EventRecord(long Id, NodeId? AgentId, string Kind, long AtMs, string? Detail); diff --git a/backend/src/Monitor.Backend.Application/Monitor.Backend.Application.csproj b/backend/src/Monitor.Backend.Application/Monitor.Backend.Application.csproj new file mode 100644 index 0000000..611afa2 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Monitor.Backend.Application.csproj @@ -0,0 +1,11 @@ + + + true + + + + + + + + diff --git a/backend/src/Monitor.Backend.Application/Ports/IAgentGateway.cs b/backend/src/Monitor.Backend.Application/Ports/IAgentGateway.cs new file mode 100644 index 0000000..443a795 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Ports/IAgentGateway.cs @@ -0,0 +1,32 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// Application 通往 Agent 连接的端口(由 Gateway 实现):下发状态变化、发起按需测试、通知 Probe 列表变化。 +/// +public interface IAgentGateway +{ + IReadOnlyCollection OnlineAgents { get; } + + bool IsOnline(NodeId agent); + + /// 某个 Agent 的期望状态变了:Gateway 重新计算并推送。 + void NotifyTaskSetChanged(NodeId agent); + + void NotifyAllTaskSetsChanged(); + + /// Agent 列表变了:通知所有在线 Agent 转发给 Probe。 + ValueTask NotifyProbeAgentsChangedAsync(ProbeAgentsResult agents, CancellationToken ct); + + /// 失败时错误码为 probe_offline、test_busy、agent_offline 等。 + ValueTask> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct); + + ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct); + + /// 让 Agent 断开某个 Probe 的会话。返回 Agent 是否真的有这个会话。 + ValueTask> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct); + + ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct); +} diff --git a/backend/src/Monitor.Backend.Application/Ports/IAuthStore.cs b/backend/src/Monitor.Backend.Application/Ports/IAuthStore.cs new file mode 100644 index 0000000..02c7e35 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Ports/IAuthStore.cs @@ -0,0 +1,41 @@ +namespace Monitor.Backend.Application; + +public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs); + +public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs); + +public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs); + +public interface IAuthStore +{ + ValueTask CountUsersAsync(CancellationToken ct); + + ValueTask GetUserByNameAsync(string username, CancellationToken ct); + + ValueTask GetUserAsync(string id, CancellationToken ct); + + ValueTask> ListUsersAsync(CancellationToken ct); + + /// 用户名已存在时返回 false。 + ValueTask CreateUserAsync(UserRecord user, CancellationToken ct); + + ValueTask UpdatePasswordAsync(string userId, byte[] hash, byte[] salt, int iterations, CancellationToken ct); + + ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct); + + ValueTask GetSessionAsync(byte[] idSha256, CancellationToken ct); + + ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct); + + ValueTask DeleteExpiredSessionsAsync(long nowMs, CancellationToken ct); + + ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct); + + ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct); + + ValueTask> ListApiTokensAsync(CancellationToken ct); + + ValueTask RevokeApiTokenAsync(string id, long nowMs, CancellationToken ct); + + ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct); +} diff --git a/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs new file mode 100644 index 0000000..2b81e70 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Ports/StorePorts.cs @@ -0,0 +1,197 @@ +using Fleet.Core; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public interface IAgentStore +{ + ValueTask> ListAsync(CancellationToken ct); + + ValueTask GetAsync(NodeId id, CancellationToken ct); + + /// 注册之后补充公开地址与端口(仅在记录还是注册时的默认值时才写入)。 + ValueTask ApplyEnrollmentAsync(NodeId id, AgentEnrollment enrollment, CancellationToken ct); + + /// 应用 merge-patch。 不为 null 且不匹配时返回 precondition_failed。 + ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct); + + /// pending → active、active ↔ disabled。激活时名称不能与其他已建立的节点冲突。 + ValueTask> SetStatusAsync(NodeId id, NodeStatus status, CancellationToken ct); + + /// 接管:把同名旧节点的公钥换成待审批节点的公钥,保留历史数据,并删除待审批记录。 + ValueTask> TakeoverAsync(NodeId pendingId, CancellationToken ct); + + ValueTask DeleteAsync(NodeId id, CancellationToken ct); + + ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct); + + ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct); + + ValueTask GetTaskHashAsync(NodeId id, CancellationToken ct); + + ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct); +} + +public interface IJoinTokenStore +{ + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); + + ValueTask> ListAsync(CancellationToken ct); + + ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); +} + +public interface IProbeStore +{ + ValueTask> ListAsync(CancellationToken ct); + + ValueTask GetAsync(string id, CancellationToken ct); + + /// 新增或整条更新(轮换密钥、改名都走这里)。 + ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct); + + /// 吊销:保留记录,公钥立即从所有 Agent 的名单里移除。已经吊销的返回 false。 + ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); + + ValueTask DeleteAsync(string id, CancellationToken ct); +} + +public interface ISettingsStore +{ + ValueTask GetAsync(string key, CancellationToken ct); + + ValueTask SetAsync(string key, string value, CancellationToken ct); + + ValueTask> GetAllAsync(CancellationToken ct); + + /// 原子地加一并返回新值(用于版本号计数器)。 + ValueTask IncrementAsync(string key, CancellationToken ct); +} + +public interface ITaskStore +{ + ValueTask> ListAsync(CancellationToken ct); + + ValueTask GetAsync(TaskId id, CancellationToken ct); + + ValueTask CreateAsync(TaskRecord task, CancellationToken ct); + + ValueTask> UpdateAsync(TaskRecord task, long? expectedRowVersion, CancellationToken ct); + + ValueTask DeleteAsync(TaskId id, CancellationToken ct); +} + +public interface ITestStore +{ + ValueTask CreateAsync(TestRecord test, CancellationToken ct); + + ValueTask GetAsync(TestId id, CancellationToken ct); + + ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct); + + ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct); + + ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct); + + ValueTask HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct); + + ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct); + + ValueTask GetResultAsync(TestId id, CancellationToken ct); + + /// 已成功结束但还没有结果的测试(用于补算结果)。 + ValueTask> ListNeedingResultAsync(int limit, CancellationToken ct); + + /// 把长时间处于 queued/running 的测试标记为 failed(例如 Backend 重启或节点失联)。 + ValueTask FailStaleAsync(long olderThanMs, string errorCode, long nowMs, CancellationToken ct); +} + +public interface IEventStore +{ + ValueTask AddAsync(NodeId? agentId, string kind, long atMs, string? detail, CancellationToken ct); + + ValueTask> ListAsync(NodeId? agentId, string? kind, long fromMs, long toMs, long? beforeId, int limit, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); +} + +/// 主机资源占用的历史:每个 Agent 每分钟一个样本。 +public interface IHostStore +{ + ValueTask SaveAsync(NodeId agent, long minuteMs, Monitor.Protocol.HostSnapshot snapshot, CancellationToken ct); + + ValueTask> ReadAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); +} + +public sealed record HostSample(long AtMs, double? Cpu, long? MemUsed, long? MemTotal, long? DiskUsed, long? DiskTotal, long? NetRxBps, long? NetTxBps, long? UptimeSeconds); + +public interface IRawStore +{ + /// 追加写入段文件 → 刷盘 → 写索引;chunk_id 已存在时返回 false(调用方仍然确认)。 + ValueTask AppendAsync(RawChunkAppend chunk, CancellationToken ct); + + ValueTask GetMetaAsync(ChunkId id, CancellationToken ct); + + ValueTask> FindAsync(RawQuery query, CancellationToken ct); + + ValueTask ReadAsync(RawChunkMeta meta, CancellationToken ct); + + /// 删除早于截止日期的段文件和索引。持续任务与按需测试保留期不同。 + ValueTask DeleteExpiredAsync(DateOnly continuousBefore, DateOnly testsBefore, CancellationToken ct); + + /// 时间范围内有原始块的序列(用于重算)。 + ValueTask> ListSeriesAsync(long fromMs, long toMs, CancellationToken ct); + + /// 启动时截断没有索引记录的段文件尾部。 + ValueTask RecoverAsync(CancellationToken ct); + + long TotalBytes { get; } +} + +public interface IMetricStore +{ + /// 覆盖写入(recompute 路径)。 + ValueTask UpsertAsync(MetricLevel level, IReadOnlyList rows, CancellationToken ct); + + /// 读取—合并—写回(流式路径:同一个窗口由多个块累加)。 + ValueTask MergeAsync(MetricLevel level, IReadOnlyList rows, CancellationToken ct); + + /// 用新算出的指标替换已有行里的同名指标,其余指标保持不变(回填新增或变更的指标)。 + ValueTask PatchAsync(MetricLevel level, IReadOnlyList rows, IReadOnlySet replaceMetricIds, CancellationToken ct); + + ValueTask> ReadAsync(MetricLevel level, NodeId agentId, string series, long fromMs, long toMs, CancellationToken ct); + + /// 这条序列在某一层里最早的窗口起点;没有数据返回 null。 + ValueTask EarliestAsync(MetricLevel level, NodeId agentId, string series, CancellationToken ct); + + ValueTask> ReadAgentsAsync(MetricLevel level, IReadOnlyCollection agentIds, string series, long fromMs, long toMs, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(MetricLevel level, long cutoffMs, CancellationToken ct); + + ValueTask EnqueueAsync(IReadOnlyList items, CancellationToken ct); + + ValueTask> DequeueDueAsync(long nowMs, int limit, CancellationToken ct); + + ValueTask CompleteAsync(IReadOnlyList items, CancellationToken ct); + + ValueTask QueueLengthAsync(CancellationToken ct); + + ValueTask MarkRollupDirtyAsync(MetricLevel level, NodeId agentId, string series, long bucketStart, CancellationToken ct); + + ValueTask> TakeRollupDirtyAsync(int limit, CancellationToken ct); + + ValueTask> GetVersionsAsync(CancellationToken ct); + + ValueTask SetVersionAsync(MetricVersionRecord version, CancellationToken ct); +} + +public sealed record StorageHealth(bool Database, bool RawDirectory); + +/// /healthz 用:SQLite 与原始数据目录都可写。 +public interface IStorageHealth +{ + ValueTask CheckAsync(CancellationToken ct); +} diff --git a/backend/src/Monitor.Backend.Application/Query/Bucketing.cs b/backend/src/Monitor.Backend.Application/Query/Bucketing.cs new file mode 100644 index 0000000..b959fd3 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/Bucketing.cs @@ -0,0 +1,73 @@ +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Application; + +/// 把源行合并成查询步长的桶:计数相加、直方图逐桶相加,所以结果是精确的,不是各点的平均。 +public sealed class BucketedRows +{ + public BucketedRows(long[] t, MetricSet?[] sets, int[] rowCounts) + { + T = t; + Sets = sets; + RowCounts = rowCounts; + } + + public long[] T { get; } + + public MetricSet?[] Sets { get; } + + /// 每个桶合并了多少个源行(用来估算持续时长和覆盖率)。 + public int[] RowCounts { get; } + + public int TotalRows => RowCounts.Sum(); + + public static BucketedRows Create(IReadOnlyList rows, QueryRange range) + { + int n = range.Points; + long[] t = new long[n]; + for (int i = 0; i < n; i++) + { + t[i] = range.FromMs + (i * range.StepMs); + } + + var sets = new MetricSet?[n]; + int[] counts = new int[n]; + foreach (SourceRow row in rows) + { + long index = (row.Start - range.FromMs) / range.StepMs; + if (row.Start < range.FromMs || index >= n) + { + continue; + } + + int i = (int)index; + if (sets[i] is null) + { + sets[i] = row.Set.Clone(); + } + else + { + sets[i]!.Merge(row.Set); + } + + counts[i]++; + } + + return new BucketedRows(t, sets, counts); + } + + /// 整个时间段的精确合并。 + public MetricSet Merge() + { + var merged = new MetricSet(); + foreach (MetricSet? set in Sets) + { + if (set is not null) + { + merged.Merge(set); + } + } + + return merged; + } +} diff --git a/backend/src/Monitor.Backend.Application/Query/Lines.cs b/backend/src/Monitor.Backend.Application/Query/Lines.cs new file mode 100644 index 0000000..800c5db --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/Lines.cs @@ -0,0 +1,104 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 一条线路 = 一对(Agent,Probe)。存储里它的序列是 line:<probe_id>,对外的 id 是 <agent_id>~<probe_id>。 +public readonly record struct LineKey(NodeId Agent, ProbeId Probe) +{ + public string Series => RawChunkHeader.LinePrefix + Probe.Value; + + public string Id => Agent.Value + "~" + Probe.Value; + + public static bool TryParse(string text, out LineKey key) + { + int split = text.IndexOf('~', StringComparison.Ordinal); + if (split <= 0 || split == text.Length - 1) + { + key = default; + return false; + } + + key = new LineKey(new NodeId(text[..split]), new ProbeId(text[(split + 1)..])); + return true; + } +} + +/// 一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。 +public sealed record LineView(AgentView Agent, ProbeRecord Probe) +{ + public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id)); + + public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id); + + public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id); +} + +/// 线路目录:把 id 解析成(Agent,Probe),列出所有线路。 +public sealed class LineService +{ + private readonly AgentService _agents; + private readonly IProbeStore _probes; + + public LineService(AgentService agents, IProbeStore probes) + { + _agents = agents; + _probes = probes; + } + + /// 所有线路:每个 Agent 和每个未吊销的 Probe 配成一对。 + public async ValueTask> ListAsync(Func include, CancellationToken ct) + { + IReadOnlyList agents = await _agents.ListAsync(ct); + IReadOnlyList probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)]; + return [.. agents.Where(include).SelectMany(a => probes.Select(p => new LineView(a, p)))]; + } + + /// + /// 线路 id 是 agent~probe。只给 Agent id 时:恰好有一个未吊销的 Probe 就用它,否则要求明确指定(probe_required)。 + /// + public async ValueTask> ResolveAsync(string id, CancellationToken ct) + { + string agentPart = id; + string? probePart = null; + if (id.Contains('~', StringComparison.Ordinal)) + { + if (!LineKey.TryParse(id, out LineKey key)) + { + return Result.Fail(MonitorErrors.AgentNotFound, "line not found"); + } + + agentPart = key.Agent.Value; + probePart = key.Probe.Value; + } + + AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct); + if (agent is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "line not found"); + } + + IReadOnlyList probes = await _probes.ListAsync(ct); + ProbeRecord? probe; + if (probePart is not null) + { + probe = probes.FirstOrDefault(p => p.Id == probePart); + if (probe is null) + { + return Result.Fail(MonitorErrors.ProbeNotFound, "probe not found"); + } + } + else + { + ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)]; + if (active.Length != 1) + { + return Result.Fail(MonitorErrors.ProbeRequired, active.Length == 0 ? "there is no active probe" : "several probes exist: use ~ as the line id"); + } + + probe = active[0]; + } + + return Result.Ok(new LineView(agent, probe)); + } +} diff --git a/backend/src/Monitor.Backend.Application/Query/MetricSelection.cs b/backend/src/Monitor.Backend.Application/Query/MetricSelection.cs new file mode 100644 index 0000000..ca74d91 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/MetricSelection.cs @@ -0,0 +1,91 @@ +using Fleet.Core; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 请求的指标:导出 id(rtt.p95)与评分 id(score.mc)。 +public sealed record MetricSelection(IReadOnlyList ExportIds, IReadOnlyList ScoreModelIds) +{ + public bool WantsScores => ScoreModelIds.Count > 0; + + public IEnumerable AllIds => ExportIds.Concat(ScoreModelIds.Select(m => "score." + m)); + + public static Result Resolve(string? metrics, string? presets, int maxMetrics) + { + var ids = new List(); + if (presets is not null) + { + foreach (string presetId in presets.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + PresetDef? preset = MetricRegistry.FindPreset(presetId); + if (preset is null) + { + return Result.Fail(MonitorErrors.UnknownPreset, $"unknown preset {presetId}"); + } + + ids.AddRange(preset.MetricIds); + } + } + + if (metrics is not null) + { + ids.AddRange(metrics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + ids = [.. ids.Distinct(StringComparer.Ordinal)]; + if (ids.Count > maxMetrics) + { + return Result.Fail(MonitorErrors.TooManyMetrics, $"at most {maxMetrics} metrics per request"); + } + + var exports = new List(); + var scores = new List(); + foreach (string id in ids) + { + if (id.StartsWith("score.", StringComparison.Ordinal)) + { + string model = id["score.".Length..]; + if (ModelRegistry.Find(model) is null) + { + return Result.Fail(MonitorErrors.UnknownMetric, $"unknown metric {id}"); + } + + scores.Add(model); + } + else if (MetricRegistry.TryFindExport(id, out _, out _)) + { + exports.Add(id); + } + else + { + return Result.Fail(MonitorErrors.UnknownMetric, $"unknown metric {id}"); + } + } + + return Result.Ok(new MetricSelection(exports, scores)); + } +} + +public static class ExportEvaluator +{ + /// 导出指定的值;没有样本的指标不出现在结果里。 + public static Dictionary Evaluate(MetricSet set, IEnumerable exportIds, double durationSeconds) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (string id in exportIds) + { + if (MetricRegistry.TryFindExport(id, out MetricDef def, out MetricExport export) + && set.TryGet(def.Id, out Accumulator acc) + && MetricDef.Export(acc, export, durationSeconds) is { } value + && double.IsFinite(value)) + { + values[id] = value; + } + } + + return values; + } + + public static IEnumerable AllExportIds() => MetricRegistry.AllExports().Select(e => e.Export.Id); +} diff --git a/backend/src/Monitor.Backend.Application/Query/QueryResults.cs b/backend/src/Monitor.Backend.Application/Query/QueryResults.cs new file mode 100644 index 0000000..996a527 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/QueryResults.cs @@ -0,0 +1,47 @@ +using Fleet.Core; +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Application; + +public sealed record SummaryResult( + IReadOnlyDictionary Values, + IReadOnlyDictionary Samples, + double Coverage); + +public sealed record GapInfo(long FromMs, long ToMs, string Reason); + +public sealed record SeriesResult( + LineKey Line, + QueryRange Range, + SourcePlan Plan, + IReadOnlyList T, + IReadOnlyDictionary Values, + SummaryResult Summary, + IReadOnlyList Gaps, + IReadOnlyList Events, + IReadOnlyDictionary Versions); + +public sealed record OverviewLine(LineView Line, SummaryResult Summary, TestRecord? LastTest); + +public sealed record OverviewResult(QueryRange Range, IReadOnlyList Lines, SourcePlan Plan); + +public sealed record DistributionBucket(double Le, ulong Count); + +public sealed record DistributionResult( + string Metric, + string Unit, + IReadOnlyList Buckets, + double? P50, + double? P95, + double? P99, + ulong Count); + +public sealed record HeatmapResult(IReadOnlyList T, IReadOnlyList Edges, IReadOnlyList> Counts, QueryRange Range, SourcePlan Plan); + +public sealed record CompareResult( + IReadOnlyList T, + IReadOnlyDictionary Values, + IReadOnlyDictionary Summary, + string Metric, + QueryRange Range, + SourcePlan Plan); diff --git a/backend/src/Monitor.Backend.Application/Query/QueryService.cs b/backend/src/Monitor.Backend.Application/Query/QueryService.cs new file mode 100644 index 0000000..a082026 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/QueryService.cs @@ -0,0 +1,429 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// 展示数据查询:任意时间段、自动步长、数据分层与现场合并、精确汇总、列式结果。 +/// 新增指标时这套代码不需要任何改动:它只通过注册表按 id 读取累加器并导出值。 +/// +public sealed class QueryService +{ + /// 热力图的延迟桶边界(毫秒)。 + public static readonly IReadOnlyList HeatmapEdges = + [1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 70, 100, 150, 200, 300, 500, 1000, 2000, 5000, 30000]; + + private readonly RangeResolver _resolver; + private readonly SourcePlanner _planner; + private readonly RowLoader _loader; + private readonly HotLayer _hot; + private readonly IEventStore _events; + private readonly ITestStore _tests; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + + public QueryService(RangeResolver resolver, SourcePlanner planner, RowLoader loader, HotLayer hot, IEventStore events, ITestStore tests, BackendOptions options, TimeProvider time) + { + _resolver = resolver; + _planner = planner; + _loader = loader; + _hot = hot; + _events = events; + _tests = tests; + _options = options; + _time = time; + } + + public Result<(QueryRange Range, SourcePlan Plan)> Plan(RangeRequest request, string defaultRange) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + Result resolved = _resolver.Resolve(request, now, defaultRange); + if (!resolved.TryGet(out QueryRange? range, out FleetError? error)) + { + return Result<(QueryRange, SourcePlan)>.Fail(error); + } + + SourcePlan plan = _planner.Plan(range, now, _hot.Ready); + if (plan.StepMs != range.StepMs) + { + range = range.WithStep(plan.StepMs); + } + + return Result<(QueryRange, SourcePlan)>.Ok((range, plan)); + } + + // ───────────────────────── 单条线路的时序 ───────────────────────── + + public async ValueTask> SeriesAsync(LineKey line, RangeRequest request, MetricSelection selection, CancellationToken ct) + { + // anchor=latest:离线或刚恢复的线路,窗口以它最后一个样本为终点(落在最近 5 秒之内就当作在线,仍以当前时间为终点) + if (string.Equals(request.Anchor, "latest", StringComparison.OrdinalIgnoreCase) && request.To is null && request.From is null && request.Range is not null + && RangeResolver.TryParseSpan(request.Range, out long span)) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (await _loader.LatestSampleMsAsync(line.Agent, line.Series, now, ct) is { } latest && latest < now - 5000) + { + request = request with { Range = null, From = DateTimeOffset.FromUnixTimeMilliseconds(latest - span), To = DateTimeOffset.FromUnixTimeMilliseconds(latest) }; + } + } + + Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "15m"); + if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error)) + { + return Result.Fail(error); + } + + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + if (!loaded.TryGet(out IReadOnlyList? rows, out error)) + { + return Result.Fail(error); + } + + BucketedRows bucketed = BucketedRows.Create(rows, p.Range); + IReadOnlyDictionary overlay = selection.WantsScores ? await TestOverlayAsync(line, ct) : new Dictionary(); + + int n = p.Range.Points; + var values = selection.AllIds.ToDictionary(id => id, _ => new double?[n], StringComparer.Ordinal); + double widthSeconds = p.Plan.LevelWidthMs / 1000.0; + IEnumerable evaluate = selection.WantsScores ? ExportEvaluator.AllExportIds() : selection.ExportIds; + for (int i = 0; i < n; i++) + { + MetricSet? set = bucketed.Sets[i]; + if (set is null) + { + continue; + } + + Dictionary exported = ExportEvaluator.Evaluate(set, evaluate, bucketed.RowCounts[i] * widthSeconds); + foreach (string id in selection.ExportIds) + { + if (exported.TryGetValue(id, out double v)) + { + values[id][i] = v; + } + } + + if (selection.WantsScores) + { + Dictionary scores = ModelRegistry.ScoreAll(BuildView(exported, overlay)); + foreach (string model in selection.ScoreModelIds) + { + values["score." + model][i] = scores[model].Score; + } + } + } + + SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, overlay); + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + IReadOnlyList events = await _events.ListAsync(line.Agent, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct); + IReadOnlyList gaps = FindGaps(bucketed, p.Range, nowMs, events); + var versions = new Dictionary(StringComparer.Ordinal); + foreach (string id in selection.ExportIds) + { + if (MetricRegistry.TryFindExport(id, out MetricDef def, out _)) + { + versions[id] = def.Fingerprint; + } + } + + return Result.Ok(new SeriesResult(line, p.Range, p.Plan, bucketed.T, values, summary, gaps, events, versions)); + } + + /// 这条线路最早的数据(Unix 毫秒);没有数据返回 null。 + public ValueTask EarliestAsync(LineKey line, CancellationToken ct) => + _loader.EarliestSampleMsAsync(line.Agent, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + + // ───────────────────────── 首页排行 ───────────────────────── + + public async ValueTask> OverviewAsync(IReadOnlyList lines, RangeRequest request, MetricSelection selection, CancellationToken ct) + { + Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "5m"); + if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error)) + { + return Result.Fail(error); + } + + Result>> loaded = await _loader.LoadManyAsync(p.Plan, p.Range, [.. lines.Select(l => l.Key)], ct); + if (!loaded.TryGet(out Dictionary>? byLine, out error)) + { + return Result.Fail(error); + } + + var result = new List(lines.Count); + foreach (LineView line in lines) + { + IReadOnlyList rows = byLine.GetValueOrDefault(line.Key) ?? []; + var merged = new MetricSet(); + int count = 0; + foreach (SourceRow row in rows) + { + if (row.Start >= p.Range.FromMs && row.Start < p.Range.ToMs) + { + merged.Merge(row.Set); + count++; + } + } + + IReadOnlyDictionary overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary(); + IReadOnlyList recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, ct); + result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null)); + } + + return Result.Ok(new OverviewResult(p.Range, result, p.Plan)); + } + + // ───────────────────────── 对比、分布、热力图 ───────────────────────── + + public async ValueTask> CompareAsync(IReadOnlyList lines, string exportId, RangeRequest request, CancellationToken ct) + { + if (lines.Count == 0 || lines.Count > _options.MaxCompareAgents) + { + return ValidationError.Fail("lines", $"between 1 and {_options.MaxCompareAgents} lines are required"); + } + + if (!MetricRegistry.TryFindExport(exportId, out _, out _)) + { + return Result.Fail(MonitorErrors.UnknownMetric, $"unknown metric {exportId}"); + } + + Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "1h"); + if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error)) + { + return Result.Fail(error); + } + + Result>> loaded = await _loader.LoadManyAsync(p.Plan, p.Range, lines, ct); + if (!loaded.TryGet(out Dictionary>? byLine, out error)) + { + return Result.Fail(error); + } + + var values = new Dictionary(StringComparer.Ordinal); + var summary = new Dictionary(StringComparer.Ordinal); + long[] t = []; + double widthSeconds = p.Plan.LevelWidthMs / 1000.0; + foreach (LineKey line in lines) + { + BucketedRows bucketed = BucketedRows.Create(byLine[line], p.Range); + t = bucketed.T; + double?[] column = new double?[p.Range.Points]; + for (int i = 0; i < column.Length; i++) + { + if (bucketed.Sets[i] is { } set && ExportEvaluator.Evaluate(set, [exportId], bucketed.RowCounts[i] * widthSeconds).TryGetValue(exportId, out double v)) + { + column[i] = v; + } + } + + values[line.Id] = column; + summary[line.Id] = ExportEvaluator.Evaluate(bucketed.Merge(), [exportId], bucketed.TotalRows * widthSeconds).TryGetValue(exportId, out double total) ? total : null; + } + + return Result.Ok(new CompareResult(t, values, summary, exportId, p.Range, p.Plan)); + } + + public async ValueTask> DistributionAsync(LineKey line, string metricId, RangeRequest request, CancellationToken ct) + { + MetricDef? def = MetricRegistry.Find(metricId); + if (def is null || def.Kind != AccKind.Histogram) + { + return Result.Fail(MonitorErrors.UnknownMetric, $"{metricId} is not a histogram metric"); + } + + Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "15m"); + if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error)) + { + return Result.Fail(error); + } + + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + if (!loaded.TryGet(out IReadOnlyList? rows, out error)) + { + return Result.Fail(error); + } + + var merged = new HistogramAcc(); + foreach (SourceRow row in rows) + { + if (row.Set.TryGet(metricId, out Accumulator acc) && acc is HistogramAcc h) + { + merged.Merge(h); + } + } + + var buckets = new List(); + if (merged.Count > 0) + { + Dictionary counts = merged.Buckets().ToDictionary(b => b.Bucket, b => b.Count); + int first = counts.Keys.Min(); + int last = counts.Keys.Max(); + for (int b = first; b <= last; b++) + { + buckets.Add(new DistributionBucket(Math.Round(HistogramAcc.UpperBound(b), 3), counts.GetValueOrDefault(b))); + } + } + + return Result.Ok(new DistributionResult(metricId, def.Exports[0].Unit.ToString().ToLowerInvariant(), buckets, merged.Quantile(0.5), merged.Quantile(0.95), merged.Quantile(0.99), merged.Count)); + } + + public async ValueTask> HeatmapAsync(LineKey line, string metricId, RangeRequest request, CancellationToken ct) + { + MetricDef? def = MetricRegistry.Find(metricId); + if (def is null || def.Kind != AccKind.Histogram) + { + return Result.Fail(MonitorErrors.UnknownMetric, $"{metricId} is not a histogram metric"); + } + + Result<(QueryRange Range, SourcePlan Plan)> planned = Plan(request, "6h"); + if (!planned.TryGet(out (QueryRange Range, SourcePlan Plan) p, out FleetError? error)) + { + return Result.Fail(error); + } + + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + if (!loaded.TryGet(out IReadOnlyList? rows, out error)) + { + return Result.Fail(error); + } + + BucketedRows bucketed = BucketedRows.Create(rows, p.Range); + var counts = new List>(bucketed.T.Length); + foreach (MetricSet? set in bucketed.Sets) + { + ulong[] cells = new ulong[HeatmapEdges.Count]; + if (set is not null && set.TryGet(metricId, out Accumulator acc) && acc is HistogramAcc h) + { + foreach ((int bucket, ulong count) in h.Buckets()) + { + double upper = HistogramAcc.UpperBound(bucket); + int cell = HeatmapEdges.Count - 1; + for (int e = 0; e < HeatmapEdges.Count; e++) + { + if (upper <= HeatmapEdges[e]) + { + cell = e; + break; + } + } + + cells[cell] += count; + } + } + + counts.Add(cells); + } + + return Result.Ok(new HeatmapResult(bucketed.T, HeatmapEdges, counts, p.Range, p.Plan)); + } + + // ───────────────────────── 汇总与评分 ───────────────────────── + + private SummaryResult Summarize(MetricSet merged, int totalRows, SourcePlan plan, QueryRange range, MetricSelection selection, IReadOnlyDictionary overlay) + { + double durationSeconds = totalRows * plan.LevelWidthMs / 1000.0; + IEnumerable evaluate = selection.WantsScores ? ExportEvaluator.AllExportIds() : selection.ExportIds; + Dictionary exported = ExportEvaluator.Evaluate(merged, evaluate, durationSeconds); + + var values = new Dictionary(StringComparer.Ordinal); + foreach (string id in selection.ExportIds) + { + values[id] = exported.TryGetValue(id, out double v) ? v : null; + } + + if (selection.WantsScores) + { + Dictionary scores = ModelRegistry.ScoreAll(BuildView(exported, overlay)); + foreach (string model in selection.ScoreModelIds) + { + values["score." + model] = scores[model].Score; + } + } + + var samples = new Dictionary(StringComparer.Ordinal); + foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports().Where(e => e.Export.Id.StartsWith("n.", StringComparison.Ordinal))) + { + if (merged.TryGet(def.Id, out Accumulator acc) && acc is CounterAcc counter && counter.Value > 0) + { + samples[export.Id["n.".Length..]] = counter.Value; + } + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long expected = Math.Max(1, (Math.Min(range.ToMs, Math.Max(now, range.FromMs + plan.LevelWidthMs)) - range.FromMs) / plan.LevelWidthMs); + return new SummaryResult(values, samples, Math.Round(Math.Min(1.0, (double)totalRows / expected), 4)); + } + + private static MetricView BuildView(Dictionary exported, IReadOnlyDictionary overlay) + { + // 最近一次测试的结果补充持续指标里没有的值(例如 hy2.* 与 connect.*) + var all = new Dictionary(exported, StringComparer.Ordinal); + foreach ((string id, double value) in overlay) + { + all.TryAdd(id, value); + } + + return new MetricView(all); + } + + private async ValueTask> TestOverlayAsync(LineKey line, CancellationToken ct) + { + long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day); + var overlay = new Dictionary(StringComparer.Ordinal); + var seenPlans = new HashSet(); + foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, ct)) + { + if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan)) + { + continue; + } + + if (await _tests.GetResultAsync(test.Id, ct) is { } stored) + { + foreach ((string id, double value) in TestService.ToView(stored).Metrics) + { + overlay.TryAdd(id, value); + } + } + } + + return overlay; + } + + /// 连续 ≥ 2 个空桶视为缺口;原因取该缺口附近最近的事件,没有事件就是 no_data。 + private static List FindGaps(BucketedRows bucketed, QueryRange range, long nowMs, IReadOnlyList events) + { + var gaps = new List(); + int i = 0; + int n = bucketed.Sets.Length; + while (i < n) + { + if (bucketed.Sets[i] is not null || bucketed.T[i] >= nowMs) + { + i++; + continue; + } + + int start = i; + while (i < n && bucketed.Sets[i] is null && bucketed.T[i] < nowMs) + { + i++; + } + + if (i - start >= 2) + { + long from = bucketed.T[start]; + long to = bucketed.T[i - 1] + range.StepMs; + EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "probe_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated"); + gaps.Add(new GapInfo(from, to, near?.Kind ?? "no_data")); + } + } + + return gaps; + } + + /// 从 JSON 文本读取参数(供端点之外的调用方使用)。 + public static Dictionary ParseParams(string json) => + JsonSerializer.Deserialize(json, TestJson.Default.ParamMap) ?? []; +} diff --git a/backend/src/Monitor.Backend.Application/Query/RangeResolver.cs b/backend/src/Monitor.Backend.Application/Query/RangeResolver.cs new file mode 100644 index 0000000..b5a0558 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/RangeResolver.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// now(默认)或 latest:相对时间段(range=)以当前时间还是以这条线路最后一个样本为终点。后者让离线的线路显示它最后一段数据,而不是空图。 +public sealed record RangeRequest(string? Range, DateTimeOffset? From, DateTimeOffset? To, string? Step, string? TimeZone, string? Anchor = null); + +/// 对齐之后的查询时间段:[FromMs, ToMs),区间内每个桶宽 。 +public sealed record QueryRange(long FromMs, long ToMs, long StepMs, bool IsRelative, long RequestedFromMs, long RequestedToMs) +{ + public int Points => (int)((ToMs - FromMs) / StepMs); + + public bool FirstPartial => RequestedFromMs != FromMs; + + public bool LastPartial => RequestedToMs != ToMs; + + public QueryRange WithStep(long step) => this with { StepMs = step, FromMs = Steps.FloorTo(RequestedFromMs, step), ToMs = Steps.CeilTo(RequestedToMs, step) }; +} + +/// range 或 from/to 与 step → 对齐后的桶序列。 +public sealed partial class RangeResolver +{ + private const long MaxSpanMs = 400 * Steps.Day; + + private readonly BackendOptions _options; + + public RangeResolver(BackendOptions options) + { + _options = options; + } + + public Result Resolve(RangeRequest request, long nowMs, string defaultRange = "5m") + { + // 目前所有分桶都按 UTC 对齐;其他时区(例如“按天”从本地零点切分)还没有实现,明确拒绝而不是悄悄忽略 + if (!string.IsNullOrEmpty(request.TimeZone) && !request.TimeZone.Equals("UTC", StringComparison.OrdinalIgnoreCase) && request.TimeZone != "Z") + { + return ValidationError.Fail("tz", "only UTC is supported at the moment"); + } + + if (request.Anchor is not null && !request.Anchor.Equals("now", StringComparison.OrdinalIgnoreCase) && !request.Anchor.Equals("latest", StringComparison.OrdinalIgnoreCase)) + { + return ValidationError.Fail("anchor", "must be now or latest"); + } + + if (request.Range is not null && (request.From is not null || request.To is not null)) + { + return ValidationError.Fail("range", "is mutually exclusive with from and to"); + } + + long from; + long to; + bool relative; + if (request.From is not null || request.To is not null) + { + to = request.To?.ToUnixTimeMilliseconds() ?? nowMs; + from = request.From?.ToUnixTimeMilliseconds() ?? (to - Steps.Hour); + relative = request.To is null; + } + else + { + if (!TryParseSpan(request.Range ?? defaultRange, out long span)) + { + return Result.Fail(MonitorErrors.InvalidRange, "range must look like 5m, 6h or 7d"); + } + + to = nowMs; + from = nowMs - span; + relative = true; + } + + if (from >= to) + { + return Result.Fail(MonitorErrors.InvalidRange, "from must be earlier than to"); + } + + if (to - from > MaxSpanMs) + { + return Result.Fail(MonitorErrors.RangeTooLarge, "the range is longer than 400 days"); + } + + long step; + if (request.Step is null or "auto") + { + step = Steps.Auto(to - from); + } + else if (!Steps.TryParse(request.Step, out step)) + { + return Result.Fail(MonitorErrors.InvalidStep, "step must be one of: " + string.Join(", ", Steps.Common.Select(Steps.Format))); + } + + var range = new QueryRange(Steps.FloorTo(from, step), Steps.CeilTo(to, step), step, relative, from, to); + if (range.Points > _options.MaxPointsPerSeries) + { + long suggested = Steps.Common.FirstOrDefault(s => (Steps.CeilTo(to, s) - Steps.FloorTo(from, s)) / s <= _options.MaxPointsPerSeries, Steps.Day); + return Result.Fail(MonitorErrors.RangeTooLarge, $"{range.Points} points exceed the limit of {_options.MaxPointsPerSeries}; use step={Steps.Format(suggested)} or a shorter range"); + } + + return Result.Ok(range); + } + + public static bool TryParseSpan(string text, out long ms) + { + ms = 0; + Match m = SpanPattern().Match(text); + if (!m.Success || !long.TryParse(m.Groups[1].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out long n) || n <= 0) + { + return false; + } + + long unit = m.Groups[2].Value switch + { + "s" => Steps.Second, + "m" => Steps.Minute, + "h" => Steps.Hour, + _ => Steps.Day, + }; + if (n > MaxSpanMs / unit) + { + return false; + } + + ms = n * unit; + return true; + } + + [GeneratedRegex(@"^(\d{1,6})([smhd])$")] + private static partial Regex SpanPattern(); +} diff --git a/backend/src/Monitor.Backend.Application/Query/RowLoader.cs b/backend/src/Monitor.Backend.Application/Query/RowLoader.cs new file mode 100644 index 0000000..e9f397e --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/RowLoader.cs @@ -0,0 +1,176 @@ +using Fleet.Core; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public readonly record struct SourceRow(long Start, MetricSet Set, bool Partial); + +/// 从原始块现算的查询全局最多同时 2 个。 +public sealed class RawQueryGate : IDisposable +{ + private readonly SemaphoreSlim _slots = new(2, 2); + + public bool TryEnter() => _slots.Wait(0); + + public void Exit() => _slots.Release(); + + public void Dispose() => _slots.Dispose(); +} + +/// 按数据源计划读取一条序列的累加器行(热层、10s/1m/5m/1h 层,或从原始块现算)。 +public sealed class RowLoader +{ + private readonly IMetricStore _metrics; + private readonly HotLayer _hot; + private readonly WindowComputer _computer; + private readonly RawQueryGate _gate; + + public RowLoader(IMetricStore metrics, HotLayer hot, WindowComputer computer, RawQueryGate gate) + { + _metrics = metrics; + _hot = hot; + _computer = computer; + _gate = gate; + } + + /// + /// 这条序列最后一个样本的时刻:先看热层(1 秒精度,保留 1 小时),没有再看 1 分钟层最近 7 天(精度是窗口宽度)。没有任何数据返回 null。 + /// + public async ValueTask LatestSampleMsAsync(NodeId agent, string series, long nowMs, CancellationToken ct) + { + long? hot = _hot.GetRange(agent, series, nowMs - Steps.Hour, nowMs + 10_000).Select(r => (long?)r.Key).Max(); + if (hot is { } at) + { + return at + 1000; + } + + IReadOnlyList rows = await _metrics.ReadAsync(MetricLevel.M1, agent, series, nowMs - (7 * Steps.Day), nowMs + 60_000, ct); + return rows.Count == 0 ? null : rows.Max(r => r.WindowStart) + 60_000; + } + + /// + /// 这条序列最早的数据在什么时候:各层(10 秒、1 分钟、5 分钟、1 小时)里最早的窗口,加上热层里还没落到持久层的部分。 + /// 展示页据此只提供“有数据”的时间范围。没有任何数据返回 null。 + /// + public async ValueTask EarliestSampleMsAsync(NodeId agent, string series, long nowMs, CancellationToken ct) + { + // 从粗到细:粗的层窗口很宽(最宽 1 小时),起点会比真实的第一个样本早; + // 如果更细的层的起点落在粗层第一个窗口里面,就用更细的(更准确);落在窗口之外说明更早的数据只剩粗层里有。 + long? earliest = null; + long width = 0; + foreach (MetricLevel level in new[] { MetricLevel.H1, MetricLevel.M5, MetricLevel.M1, MetricLevel.S10 }) + { + if (await _metrics.EarliestAsync(level, agent, series, ct) is not { } at) + { + continue; + } + + if (earliest is null || at < earliest.Value + width) + { + earliest = earliest is null ? at : Math.Min(earliest.Value, at); + } + + width = level.WidthMs(); + } + + long? hot = _hot.GetRange(agent, series, nowMs - Steps.Hour, nowMs + 10_000).Select(r => (long?)r.Key).Min(); + if (hot is { } h && (earliest is null || h < earliest.Value + width)) + { + earliest = earliest is null ? h : Math.Min(earliest.Value, h); + } + + return earliest; + } + + public async ValueTask>> LoadAsync(SourcePlan plan, QueryRange range, NodeId agent, string series, CancellationToken ct) + { + switch (plan.Source) + { + case DataSource.Hot: + return Result>.Ok([.. _hot.GetRange(agent, series, range.FromMs, range.ToMs).Select(r => new SourceRow(r.Key, r.Value, false))]); + case DataSource.Raw: + if (!_gate.TryEnter()) + { + return Result>.Fail(MonitorErrors.TooManyRequests, "too many queries are reading raw data; retry shortly or use a coarser step"); + } + + try + { + WindowComputer.Computed computed = await _computer.ComputeAsync(agent, series, range.FromMs, range.ToMs, plan.StepMs, null, ct); + return Result>.Ok([.. computed.Buckets.Select(b => new SourceRow(b.Key, b.Value, computed.AnyPartial))]); + } + finally + { + _gate.Exit(); + } + + default: + IReadOnlyList rows = await _metrics.ReadAsync(ToLevel(plan.Source), agent, series, range.FromMs, range.ToMs, ct); + return Result>.Ok(Decode(rows)); + } + } + + /// 多条线路一起读取(首页排行)。热层与原始块现算逐条读取。 + public async ValueTask>>> LoadManyAsync( + SourcePlan plan, QueryRange range, IReadOnlyCollection lines, CancellationToken ct) + { + var result = new Dictionary>(); + if (plan.Source is DataSource.Hot or DataSource.Raw) + { + foreach (LineKey line in lines) + { + Result> one = await LoadAsync(plan, range, line.Agent, line.Series, ct); + if (!one.TryGet(out IReadOnlyList? rows, out FleetError? error)) + { + return Result>>.Fail(error); + } + + result[line] = rows; + } + + return Result>>.Ok(result); + } + + // 每个 Probe 一个序列:按序列分组,每组一次查询 + foreach (IGrouping bySeries in lines.GroupBy(l => l.Series)) + { + NodeId[] agents = [.. bySeries.Select(l => l.Agent).Distinct()]; + IReadOnlyList all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct); + foreach (IGrouping group in all.GroupBy(r => r.AgentId)) + { + result[new LineKey(group.Key, new ProbeId(bySeries.Key[RawChunkHeader.LinePrefix.Length..]))] = Decode([.. group]); + } + } + + foreach (LineKey line in lines) + { + result.TryAdd(line, []); + } + + return Result>>.Ok(result); + } + + private static MetricLevel ToLevel(DataSource source) => source switch + { + DataSource.S10 => MetricLevel.S10, + DataSource.M1 => MetricLevel.M1, + DataSource.M5 => MetricLevel.M5, + _ => MetricLevel.H1, + }; + + private static List Decode(IReadOnlyList rows) + { + var result = new List(rows.Count); + foreach (MetricRow row in rows) + { + // 损坏的行跳过(体现为缺口),不让一行坏数据拖垮整个查询 + if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? set, out _)) + { + result.Add(new SourceRow(row.WindowStart, set, row.Partial)); + } + } + + return result; + } +} diff --git a/backend/src/Monitor.Backend.Application/Query/SourcePlanner.cs b/backend/src/Monitor.Backend.Application/Query/SourcePlanner.cs new file mode 100644 index 0000000..4e3df11 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/SourcePlanner.cs @@ -0,0 +1,81 @@ +namespace Monitor.Backend.Application; + +public enum DataSource +{ + Hot, + S10, + M1, + M5, + H1, + Raw, +} + +public sealed record SourcePlan(DataSource Source, long LevelWidthMs, long StepMs, bool Degraded) +{ + public string Name => Source switch + { + DataSource.Hot => "hot", + DataSource.S10 => "10s", + DataSource.M1 => "1m", + DataSource.M5 => "5m", + DataSource.H1 => "1h", + _ => "raw", + }; +} + +/// +/// 选择数据源:在“覆盖该时间段”并且“粒度能整除步长”的各层中选最粗的一层,再把累加器按步长现场合并。 +/// 没有这样的层时:区间 ≤ 15 分钟且步长 < 10 s,从原始块现算;否则降级为可用的最细层并标记 degraded。 +/// +public sealed class SourcePlanner +{ + public const long RawComputeMaxSpanMs = 15 * Steps.Minute; + + private readonly BackendOptions _options; + + public SourcePlanner(BackendOptions options) + { + _options = options; + } + + public SourcePlan Plan(QueryRange range, long nowMs, bool hotReady) + { + long from = range.RequestedFromMs; + long step = range.StepMs; + + // 由粗到细 + (DataSource Source, long Width, bool Available)[] levels = + [ + (DataSource.H1, Steps.Hour, true), + (DataSource.M5, 5 * Steps.Minute, from >= nowMs - (long)_options.Metric5mRetentionDays * Steps.Day), + (DataSource.M1, Steps.Minute, from >= nowMs - (long)_options.Metric1mRetentionDays * Steps.Day), + (DataSource.S10, 10 * Steps.Second, from >= nowMs - (long)_options.Metric10sRetentionHours * Steps.Hour), + (DataSource.Hot, Steps.Second, hotReady && from >= nowMs - Steps.Hour), + ]; + + foreach ((DataSource source, long width, bool available) in levels) + { + if (available && step % width == 0) + { + return new SourcePlan(source, width, step, false); + } + } + + bool rawAvailable = from >= nowMs - (long)_options.RawRetentionDays * Steps.Day; + if (rawAvailable && step < 10 * Steps.Second && range.ToMs - range.FromMs <= RawComputeMaxSpanMs) + { + return new SourcePlan(DataSource.Raw, step, step, false); + } + + // 降级:可用的最细层,步长上调到该层宽度的整数倍 + foreach ((DataSource source, long width, bool available) in levels.Reverse()) + { + if (available) + { + return new SourcePlan(source, width, Steps.Snap(step, width), true); + } + } + + return new SourcePlan(DataSource.H1, Steps.Hour, Steps.Snap(step, Steps.Hour), true); + } +} diff --git a/backend/src/Monitor.Backend.Application/Query/Steps.cs b/backend/src/Monitor.Backend.Application/Query/Steps.cs new file mode 100644 index 0000000..b1830f1 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Query/Steps.cs @@ -0,0 +1,90 @@ +using System.Globalization; + +namespace Monitor.Backend.Application; + +/// 常用步长:1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d。 +public static class Steps +{ + public const long Second = 1000; + public const long Minute = 60 * Second; + public const long Hour = 60 * Minute; + public const long Day = 24 * Hour; + + /// 每条曲线最多约 720 个点。 + public const int TargetPoints = 720; + + public static readonly IReadOnlyList Common = + [ + 1 * Second, 2 * Second, 5 * Second, 10 * Second, 15 * Second, 30 * Second, + 1 * Minute, 2 * Minute, 5 * Minute, 10 * Minute, 15 * Minute, 30 * Minute, + 1 * Hour, 2 * Hour, 3 * Hour, 6 * Hour, 12 * Hour, 1 * Day, + ]; + + /// 步长 = 常用步长中 ≥ 区间长度 / 720 的最小值。 + public static long Auto(long spanMs) + { + long wanted = (spanMs + TargetPoints - 1) / TargetPoints; + foreach (long step in Common) + { + if (step >= wanted) + { + return step; + } + } + + return Common[^1]; + } + + public static bool TryParse(string text, out long ms) + { + foreach (long step in Common) + { + if (string.Equals(Format(step), text, StringComparison.Ordinal)) + { + ms = step; + return true; + } + } + + ms = 0; + return false; + } + + public static string Format(long ms) + { + if (ms % Day == 0) + { + return (ms / Day).ToString(CultureInfo.InvariantCulture) + "d"; + } + + if (ms % Hour == 0) + { + return (ms / Hour).ToString(CultureInfo.InvariantCulture) + "h"; + } + + if (ms % Minute == 0) + { + return (ms / Minute).ToString(CultureInfo.InvariantCulture) + "m"; + } + + return (ms / Second).ToString(CultureInfo.InvariantCulture) + "s"; + } + + /// 不小于 且是 整数倍的最小常用步长。 + public static long Snap(long step, long width) + { + foreach (long candidate in Common) + { + if (candidate >= step && candidate % width == 0) + { + return candidate; + } + } + + return width; + } + + public static long FloorTo(long ms, long step) => Math.DivRem(ms, step, out long rem) * step - (rem < 0 ? step : 0); + + public static long CeilTo(long ms, long step) => FloorTo(ms + step - 1, step); +} diff --git a/backend/src/Monitor.Backend.Application/Retention/RetentionService.cs b/backend/src/Monitor.Backend.Application/Retention/RetentionService.cs new file mode 100644 index 0000000..2b2db9c --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Retention/RetentionService.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Monitor.Backend.Application; + +/// 保留清理:原始段文件(持续任务与按需测试保留期不同)、各层指标、事件。保留期来自 settings,可以在线修改。 +public sealed partial class RetentionService : BackgroundService +{ + private static readonly TimeSpan s_interval = TimeSpan.FromHours(1); + + private readonly IRawStore _raw; + private readonly IMetricStore _metrics; + private readonly IEventStore _events; + private readonly IAuthStore _auth; + private readonly IHostStore _hosts; + private readonly SettingsService _settings; + private readonly LoginRateLimiter _limiter; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) + { + _raw = raw; + _metrics = metrics; + _events = events; + _hosts = hosts; + _auth = auth; + _settings = settings; + _limiter = limiter; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunOnceAsync(stoppingToken); + await Task.Delay(s_interval, _time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // 后台循环不能因为一次失败而退出 + LogFailed(ex); + await Task.Delay(TimeSpan.FromMinutes(5), _time, stoppingToken); + } + } + } + + /// 执行一轮清理(也供测试直接调用)。 + public async Task RunOnceAsync(CancellationToken ct) + { + DateTimeOffset now = _time.GetUtcNow(); + long nowMs = now.ToUnixTimeMilliseconds(); + DateOnly today = DateOnly.FromDateTime(now.UtcDateTime); + int rawDays = await _settings.GetIntAsync(SettingsService.RawDays, ct); + int testDays = await _settings.GetIntAsync(SettingsService.TestRawDays, ct); + + await _raw.DeleteExpiredAsync(today.AddDays(-rawDays), today.AddDays(-testDays), ct); + await _metrics.DeleteOlderThanAsync(MetricLevel.S10, nowMs - (await _settings.GetIntAsync(SettingsService.Metric10sHours, ct) * Steps.Hour), ct); + await _metrics.DeleteOlderThanAsync(MetricLevel.M1, nowMs - (await _settings.GetIntAsync(SettingsService.Metric1mDays, ct) * Steps.Day), ct); + await _metrics.DeleteOlderThanAsync(MetricLevel.M5, nowMs - (await _settings.GetIntAsync(SettingsService.Metric5mDays, ct) * Steps.Day), ct); + await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct); + await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct); + await _auth.DeleteExpiredSessionsAsync(nowMs, ct); + _limiter.Prune(); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "retention pass failed")] + private partial void LogFailed(Exception exception); +} diff --git a/backend/src/Monitor.Backend.Application/StartupTasks.cs b/backend/src/Monitor.Backend.Application/StartupTasks.cs new file mode 100644 index 0000000..318fe29 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/StartupTasks.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Hosting; + +namespace Monitor.Backend.Application; + +/// +/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。 +/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。 +/// +public sealed class StartupTasks : IHostedService +{ + private readonly IRawStore _raw; + private readonly AuthService _auth; + private readonly TaskService _tasks; + + public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks) + { + _raw = raw; + _auth = auth; + _tasks = tasks; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await _raw.RecoverAsync(cancellationToken); + await _auth.EnsureInitialAdminAsync(cancellationToken); + await _tasks.SeedDefaultsAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/backend/src/Monitor.Backend.Application/Tasks/EffectiveTasks.cs b/backend/src/Monitor.Backend.Application/Tasks/EffectiveTasks.cs new file mode 100644 index 0000000..504ce09 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tasks/EffectiveTasks.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record EffectiveTask( + TaskId Id, + string Scope, + string Kind, + string? Target, + IReadOnlyDictionary Params, + string Schedule, + ParamValues Resolved); + +/// +/// 有效任务集:线路任务模板 + 针对单个 Agent 的覆盖 + 该 Agent 的目标任务。 +/// 参数经 ParamSpec 校验并补全默认值,Backend 和节点使用同一套规则。 +/// +public static class EffectiveTasks +{ + public const string LineScope = "line"; + public const string TargetScope = "target"; + + public static IReadOnlyList Compute(IReadOnlyList all, AgentRecord agent, bool filterByAgentKinds) + { + var result = new List(); + var overrides = new Dictionary(); + foreach (TaskRecord task in all) + { + if (task.AgentId == agent.Id && task.BaseTaskId is { } baseId) + { + overrides[baseId] = task; + } + } + + foreach (TaskRecord task in all.OrderBy(t => t.Id.Value, StringComparer.Ordinal)) + { + if (task.Scope == LineScope && task.AgentId is null) + { + // 模板:有覆盖时以覆盖为准(覆盖可以停用或改参数) + TaskRecord? over = overrides.GetValueOrDefault(task.Id); + if (over is not null) + { + if (over.Enabled) + { + Add(result, over.Id, task.Scope, over.Kind, null, Merge(task.Params, over.Params), over.Schedule, agent, filterByAgentKinds); + } + } + else if (task.Enabled) + { + Add(result, task.Id, task.Scope, task.Kind, null, task.Params, task.Schedule, agent, filterByAgentKinds); + } + } + else if (task.Scope == TargetScope && task.AgentId == agent.Id && task.Enabled) + { + Add(result, task.Id, task.Scope, task.Kind, task.Target, task.Params, task.Schedule, agent, filterByAgentKinds); + } + } + + return result; + } + + private static Dictionary Merge(IReadOnlyDictionary baseParams, IReadOnlyDictionary over) + { + var merged = new Dictionary(baseParams); + foreach ((string key, JsonElement value) in over) + { + merged[key] = value; + } + + return merged; + } + + private static void Add( + List result, + TaskId id, + string scope, + string kind, + string? target, + IReadOnlyDictionary parameters, + string schedule, + AgentRecord agent, + bool filterByAgentKinds) + { + KindSpec? spec = Kinds.Find(kind); + if (spec is null || (filterByAgentKinds && agent.Kinds.Count > 0 && !agent.Kinds.Contains(kind))) + { + return; + } + + ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters); + if (!resolved.IsValid) + { + return; + } + + result.Add(new EffectiveTask(id, scope, kind, target, resolved.Values!.Values, schedule, resolved.Values)); + } + + /// 该 Agent 持续线路任务的总带宽(bit/s)。 + public static long ContinuousLineBudget(IReadOnlyList tasks) + { + long total = 0; + foreach (EffectiveTask task in tasks) + { + if (task.Scope == LineScope && task.Schedule == TaskSchedule.Continuous) + { + total += Kinds.Find(task.Kind)!.BudgetBitsPerSecond(task.Resolved); + } + } + + return total; + } +} diff --git a/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs new file mode 100644 index 0000000..c3021fb --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tasks/TaskPlanner.cs @@ -0,0 +1,93 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Fleet.Core; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record DesiredTaskSet(long Version, TaskSet Document); + +/// +/// 为每个 Agent 计算期望状态():有效任务集 + 允许连接的 Probe 公钥。 +/// 只有内容真的变了才递增版本号,所以断线重连后 Agent 收到的永远是当前版本的完整快照。 +/// +public sealed class TaskPlanner : IDisposable +{ + public const string ProbeAgentsVersionKey = "probe_agents_version"; + + private readonly ITaskStore _tasks; + private readonly IAgentStore _agents; + private readonly IProbeStore _probes; + private readonly ISettingsStore _settings; + private readonly SemaphoreSlim _gate = new(1, 1); + + public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings) + { + _tasks = tasks; + _agents = agents; + _probes = probes; + _settings = settings; + } + + /// pending、disabled 或不存在的 Agent 返回 null(不下发状态)。 + public async ValueTask GetDesiredAsync(NodeId agentId, CancellationToken ct) + { + AgentRecord? agent = await _agents.GetAsync(agentId, ct); + if (agent is null || agent.Status != NodeStatus.Active) + { + return null; + } + + IReadOnlyList all = await _tasks.ListAsync(ct); + IReadOnlyList probes = await _probes.ListAsync(ct); + long listVersion = long.TryParse(await _settings.GetAsync(ProbeAgentsVersionKey, ct), out long v) ? v : 0; + + var document = new TaskSet { ProbeAgentsVersion = listVersion }; + foreach (EffectiveTask task in EffectiveTasks.Compute(all, agent, filterByAgentKinds: true)) + { + document.Tasks.Add(new TaskSpec + { + Id = task.Id, + Scope = task.Scope, + Kind = task.Kind, + Target = task.Target, + Params = new Dictionary(task.Params), + Schedule = task.Schedule, + Enabled = true, + }); + } + + foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null)) + { + document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PublicKey) }); + } + + string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MonitorJsonContext.Default.TaskSet))); + + await _gate.WaitAsync(ct); + try + { + // 重新读取,避免并发的两次计算各自递增 + AgentRecord current = await _agents.GetAsync(agentId, ct) ?? agent; + string? storedHash = await _agents.GetTaskHashAsync(agentId, ct); + long version = current.TaskVersion; + if (storedHash != hash) + { + version++; + await _agents.SetTaskVersionAsync(agentId, version, hash, ct); + } + + document.Version = version; + return new DesiredTaskSet(version, document); + } + finally + { + _gate.Release(); + } + } + + public void Dispose() => _gate.Dispose(); + + public ValueTask BumpProbeAgentsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(ProbeAgentsVersionKey, ct); +} diff --git a/backend/src/Monitor.Backend.Application/Tasks/TaskService.cs b/backend/src/Monitor.Backend.Application/Tasks/TaskService.cs new file mode 100644 index 0000000..00547e9 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tasks/TaskService.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Text.Json; +using Fleet.Core; +using Fleet.Server; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record TaskInput( + string Scope, + string Kind, + string? Target, + IReadOnlyDictionary? Params, + string? Schedule, + bool? Enabled, + NodeId? AgentId, + TaskId? BaseTaskId); + +/// 可以修改的字段(merge-patch:为 null 的字段不改)。 +public sealed record TaskPatch(IReadOnlyDictionary? Params, string? Schedule, bool? Enabled, string? Target); + +public sealed class TaskService +{ + private readonly ITaskStore _tasks; + private readonly IAgentStore _agents; + private readonly ISettingsStore _settings; + private readonly IAgentGateway _gateway; + private readonly TimeProvider _time; + + public TaskService(ITaskStore tasks, IAgentStore agents, ISettingsStore settings, IAgentGateway gateway, TimeProvider time) + { + _tasks = tasks; + _agents = agents; + _settings = settings; + _gateway = gateway; + _time = time; + } + + public ValueTask> ListAsync(CancellationToken ct) => _tasks.ListAsync(ct); + + public ValueTask GetAsync(TaskId id, CancellationToken ct) => _tasks.GetAsync(id, ct); + + /// 首次启动时创建默认的线路任务模板(udp_echo、tcp_echo 持续运行),这样新 Agent 注册后自动获得任务。 + public async ValueTask SeedDefaultsAsync(CancellationToken ct) + { + if (await _settings.GetAsync("tasks.seeded", ct) is not null) + { + return; + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho }) + { + await _tasks.CreateAsync( + new TaskRecord(new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)), EffectiveTasks.LineScope, null, null, kind.Id, null, new Dictionary(), TaskSchedule.Continuous, true, now, 0), + ct); + } + + await _settings.SetAsync("tasks.seeded", "1", ct); + } + + public async ValueTask> CreateAsync(TaskInput input, CancellationToken ct) + { + Result validated = await ValidateAsync(input.Scope, input.Kind, input.Target, input.Params, input.Schedule ?? TaskSchedule.Continuous, input.AgentId, input.BaseTaskId, ct); + if (!validated.TryGet(out ValidatedTask? v, out FleetError? error)) + { + return Result.Fail(error); + } + + var record = new TaskRecord( + new TaskId(Ulid.NewPrefixed(TaskId.Prefix, _time)), + input.Scope, + input.AgentId, + input.BaseTaskId, + input.Kind, + v.Target, + input.Params ?? new Dictionary(), + input.Schedule ?? TaskSchedule.Continuous, + input.Enabled ?? true, + _time.GetUtcNow().ToUnixTimeMilliseconds(), + 0); + + IReadOnlyList after = [.. await _tasks.ListAsync(ct), record]; + Result budget = await CheckBudgetAsync(after, record.AgentId, ct); + if (!budget.IsSuccess) + { + return Result.Fail(budget.Error); + } + + await _tasks.CreateAsync(record, ct); + Notify(record); + return Result.Ok(record); + } + + public async ValueTask> UpdateAsync(TaskId id, TaskPatch patch, long? expectedRowVersion, CancellationToken ct) + { + TaskRecord? current = await _tasks.GetAsync(id, ct); + if (current is null) + { + return Result.Fail(MonitorErrors.TaskNotFound, "task not found"); + } + + IReadOnlyDictionary parameters = patch.Params ?? current.Params; + string schedule = patch.Schedule ?? current.Schedule; + string? target = patch.Target ?? current.Target; + Result validated = await ValidateAsync(current.Scope, current.Kind, target, parameters, schedule, current.AgentId, current.BaseTaskId, ct); + if (!validated.TryGet(out ValidatedTask? v, out FleetError? error)) + { + return Result.Fail(error); + } + + TaskRecord updated = current with + { + Params = parameters, + Schedule = schedule, + Target = v.Target, + Enabled = patch.Enabled ?? current.Enabled, + UpdatedAtMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), + }; + + IReadOnlyList after = [.. (await _tasks.ListAsync(ct)).Where(t => t.Id != id), updated]; + Result budget = await CheckBudgetAsync(after, updated.AgentId, ct); + if (!budget.IsSuccess) + { + return Result.Fail(budget.Error); + } + + Result saved = await _tasks.UpdateAsync(updated, expectedRowVersion, ct); + if (saved.IsSuccess) + { + Notify(saved.Value); + } + + return saved; + } + + public async ValueTask DeleteAsync(TaskId id, CancellationToken ct) + { + TaskRecord? current = await _tasks.GetAsync(id, ct); + if (current is null) + { + return; + } + + await _tasks.DeleteAsync(id, ct); + Notify(current); + } + + private void Notify(TaskRecord task) + { + if (task.AgentId is { } agent) + { + _gateway.NotifyTaskSetChanged(agent); + } + else + { + _gateway.NotifyAllTaskSetsChanged(); + } + } + + private sealed record ValidatedTask(string? Target); + + private async ValueTask> ValidateAsync( + string scope, + string kind, + string? target, + IReadOnlyDictionary? parameters, + string schedule, + NodeId? agentId, + TaskId? baseTaskId, + CancellationToken ct) + { + if (scope is not (EffectiveTasks.LineScope or EffectiveTasks.TargetScope)) + { + return ValidationError.Fail("scope", "must be line or target"); + } + + KindSpec? spec = Kinds.Find(kind); + if (spec is null) + { + return Result.Fail(MonitorErrors.UnknownKind, $"unknown kind {kind}"); + } + + Scope wanted = scope == EffectiveTasks.LineScope ? Scope.Line : Scope.Target; + if (!spec.Scopes.HasFlag(wanted)) + { + return ValidationError.Fail("kind", $"{kind} cannot be used for {scope} tasks"); + } + + ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters); + if (!resolved.IsValid) + { + return ValidationError.Fail(resolved.Errors.ToDictionary(e => "params." + e.Key, e => e.Value)); + } + + Result scheduleResult = TaskSchedule.Validate(schedule); + if (!scheduleResult.IsSuccess) + { + return ValidationError.Fail("schedule", scheduleResult.Error.Message); + } + + if (schedule == TaskSchedule.Once) + { + return ValidationError.Fail("schedule", "once tasks are created through the tests API"); + } + + if (agentId is { } id && await _agents.GetAsync(id, ct) is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } + + if (scope == EffectiveTasks.TargetScope) + { + if (agentId is null || !TryNormalizeTarget(target, out string? normalized)) + { + return ValidationError.Fail("target", agentId is null ? "agent_id is required for target tasks" : "must be an IP address with an optional port"); + } + + return Result.Ok(new ValidatedTask(normalized)); + } + + if (target is not null) + { + return ValidationError.Fail("target", "line tasks have no target"); + } + + if (baseTaskId is { } baseId) + { + TaskRecord? template = await _tasks.GetAsync(baseId, ct); + if (agentId is null || template is null || template.AgentId is not null || template.Scope != EffectiveTasks.LineScope) + { + return ValidationError.Fail("base_task_id", "must reference a line template and requires agent_id"); + } + + if (template.Kind != kind) + { + return ValidationError.Fail("kind", "an override must use the same kind as its template"); + } + } + else if (agentId is not null) + { + return ValidationError.Fail("agent_id", "per-agent line tasks must be overrides (base_task_id)"); + } + + return Result.Ok(new ValidatedTask(null)); + } + + /// 每条线路持续任务的总带宽上限;对所有受影响的 Agent(模板变更时是所有已激活的 Agent)检查。 + private async ValueTask> CheckBudgetAsync(IReadOnlyList after, NodeId? agentId, CancellationToken ct) + { + IReadOnlyList agents = await _agents.ListAsync(ct); + IEnumerable affected = agentId is { } id + ? agents.Where(a => a.Id == id) + : agents.Where(a => a.Status == NodeStatus.Active); + var checkedAny = false; + foreach (AgentRecord agent in affected) + { + checkedAny = true; + long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, agent with { Kinds = [] }, filterByAgentKinds: false)); + if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond) + { + return Result.Fail(MonitorErrors.BudgetExceeded, $"continuous tasks for {agent.Name} would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)"); + } + } + + if (!checkedAny && agentId is null) + { + // 还没有 Agent:用一个虚拟 Agent 检查模板本身 + var virtualAgent = new AgentRecord(new NodeId("agt_virtual"), "virtual", NodeStatus.Active, [], [], "", 0, [], [], true, [], null, null, 0, 0, null, 0, null, 0); + long bits = EffectiveTasks.ContinuousLineBudget(EffectiveTasks.Compute(after, virtualAgent, filterByAgentKinds: false)); + if (bits > ProtocolLimits.MaxLineBudgetBitsPerSecond) + { + return Result.Fail(MonitorErrors.BudgetExceeded, $"continuous line templates would use {bits / 1000} kbps (limit {ProtocolLimits.MaxLineBudgetBitsPerSecond / 1000} kbps)"); + } + } + + return Result.Ok(true); + } + + /// 只接受 IP 地址(可带端口),不做 DNS 解析,避免把 Agent 用作解析器。 + public static bool TryNormalizeTarget(string? target, out string? normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(target)) + { + return false; + } + + if (IPAddress.TryParse(target, out IPAddress? ip)) + { + normalized = ip.ToString(); + return true; + } + + if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint) && endpoint.Port is > 0 and <= 65535) + { + normalized = endpoint.ToString(); + return true; + } + + return false; + } +} diff --git a/backend/src/Monitor.Backend.Application/Tests/Plans.cs b/backend/src/Monitor.Backend.Application/Tests/Plans.cs new file mode 100644 index 0000000..31a6a87 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tests/Plans.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +public sealed record PhaseSpec(string Kind, IReadOnlyDictionary Overrides, int DurationSeconds); + +/// +/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Probe、Measurement)不认识它。 +/// 测试方案:多个阶段的 once 任务组合(例如 hy2_ladder 在 10、25、50、100 Mbps 下各跑 10 秒)。 +public sealed record PlanSpec(string Id, string Title, string Description, IReadOnlyList Phases); + +public static class Plans +{ + private static JsonElement N(double value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.Double); + + private static JsonElement S(string value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.String); + + private static PhaseSpec Train(double mbps) => + new("udp_train", new Dictionary { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10); + + public static readonly PlanSpec Hy2Ladder = new( + "hy2_ladder", + "Hysteria2 ladder", + "UDP train at 10, 25, 50 and 100 Mbit/s for 10 s each; models Brutal fixed-rate congestion control.", + [Train(10), Train(25), Train(50), Train(100)]); + + public static readonly PlanSpec RealitySession = new( + "reality_session", + "REALITY session", + "Connection establishment, a short (4 s) bulk transfer for the peak throughput and a held connection.", + [ + new PhaseSpec("connect", new Dictionary { ["per_minute"] = N(20) }, 30), + new PhaseSpec("tcp_bulk", new Dictionary { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4), + new PhaseSpec("hold", new Dictionary { ["heartbeat_s"] = N(5) }, 30), + ]); + + public static readonly IReadOnlyList All = [Hy2Ladder, RealitySession]; + + public static PlanSpec? Find(string id) + { + foreach (PlanSpec plan in All) + { + if (plan.Id == id) + { + return plan; + } + } + + return null; + } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(double))] +[System.Text.Json.Serialization.JsonSerializable(typeof(string))] +internal sealed partial class PlanJson : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/src/Monitor.Backend.Application/Tests/TestFinalizer.cs b/backend/src/Monitor.Backend.Application/Tests/TestFinalizer.cs new file mode 100644 index 0000000..c095a2d --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tests/TestFinalizer.cs @@ -0,0 +1,95 @@ +using Fleet.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// +/// 后台任务:给已成功结束的测试算结果(所有阶段的块到达之后,或结束一分钟后以现有的块为准), +/// 并把长时间没有汇报的测试标记为失败。 +/// +public sealed partial class TestFinalizer : BackgroundService +{ + private static readonly TimeSpan s_poll = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_chunkWait = TimeSpan.FromSeconds(60); + private static readonly TimeSpan s_stale = TimeSpan.FromMinutes(10); + + private readonly ITestStore _tests; + private readonly IRawStore _raw; + private readonly LiveHub _live; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public TestFinalizer(ITestStore tests, IRawStore raw, LiveHub live, TimeProvider time, ILogger logger) + { + _tests = tests; + _raw = raw; + _live = live; + _time = time; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunOnceAsync(stoppingToken); + await Task.Delay(s_poll, _time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // 后台循环不能因为一次失败而退出 + LogFailed(ex); + await Task.Delay(s_poll, _time, stoppingToken); + } + } + } + + /// 执行一轮(也供测试直接调用)。 + public async Task RunOnceAsync(CancellationToken ct) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + await _tests.FailStaleAsync(now - (long)s_stale.TotalMilliseconds, "test_timeout", now, ct); + + foreach (TestRecord test in await _tests.ListNeedingResultAsync(20, ct)) + { + IReadOnlyList metas = await _raw.FindAsync(new RawQuery(TestId: test.Id, Limit: 1000), ct); + int phases = metas.Where(m => m.Phase is not null).Select(m => m.Phase).Distinct().Count(); + bool waited = test.FinishedAtMs is { } finished && now - finished >= (long)s_chunkWait.TotalMilliseconds; + if (metas.Count == 0 ? !waited : phases < test.PhaseCount && !waited) + { + continue; + } + + var chunks = new List(metas.Count); + foreach (RawChunkMeta meta in metas) + { + byte[]? bytes = await _raw.ReadAsync(meta, ct); + if (bytes is not null && RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _)) + { + chunks.Add(chunk); + } + } + + TestResult result = TestEvaluator.Evaluate(test.Plan, chunks); + if (chunks.Count < test.PhaseCount) + { + result.Caveats.Add("partial_data"); + } + + await _tests.SaveResultAsync(TestService.ToRecord(test.Id, result), ct); + _live.Publish(new TestSignal(test.Id)); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "test finalizer iteration failed")] + private partial void LogFailed(Exception exception); +} diff --git a/backend/src/Monitor.Backend.Application/Tests/TestModels.cs b/backend/src/Monitor.Backend.Application/Tests/TestModels.cs new file mode 100644 index 0000000..c983179 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tests/TestModels.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Application; + +public sealed class StoredTestMetrics +{ + [JsonPropertyName("metrics")] + public Dictionary Metrics { get; set; } = []; + + [JsonPropertyName("caveats")] + public List Caveats { get; set; } = []; + + [JsonPropertyName("steps")] + public List Steps { get; set; } = []; +} + +[JsonSerializable(typeof(StoredTestMetrics))] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SeriesMap")] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")] +internal sealed partial class TestJson : JsonSerializerContext; + +public sealed record ScoreView(double? Score, int Version, bool Estimated, IReadOnlyList Dimensions, IReadOnlyList Caveats); + +public sealed record TestResultView( + IReadOnlyDictionary Metrics, + IReadOnlyDictionary Scores, + IReadOnlyDictionary Series, + IReadOnlyList Steps, + IReadOnlyList Caveats); + +public sealed record TestDetail(TestRecord Test, IReadOnlyList PhaseKinds, TestResultView? Result); + +public sealed record StartTestRequest(string Plan, IReadOnlyDictionary? Params); diff --git a/backend/src/Monitor.Backend.Application/Tests/TestService.cs b/backend/src/Monitor.Backend.Application/Tests/TestService.cs new file mode 100644 index 0000000..e489309 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/Tests/TestService.cs @@ -0,0 +1,257 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Server; +using Monitor.Backend.Analysis; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。 +public sealed class TestService +{ + private readonly ITestStore _tests; + private readonly IAgentStore _agents; + private readonly AgentRuntime _runtime; + private readonly IAgentGateway _gateway; + private readonly LiveHub _live; + private readonly TimeProvider _time; + + public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time) + { + _tests = tests; + _agents = agents; + _runtime = runtime; + _gateway = gateway; + _live = live; + _time = time; + } + + public async ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) + { + PlanSpec? plan = Plans.Find(request.Plan); + if (plan is null) + { + return Result.Fail(MonitorErrors.UnknownPlan, $"unknown plan {request.Plan}"); + } + + AgentRecord? agent = await _agents.GetAsync(agentId, ct); + if (agent is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } + + if (agent.Status != NodeStatus.Active) + { + return Result.Fail(MonitorErrors.AgentNotActive, "agent is not active"); + } + + // Agent 在 hello 里声明了它支持的采集方法:方案里有它不支持的就不下发(否则要等到阶段开始才失败) + string? unsupported = plan.Phases.Select(p => p.Kind).FirstOrDefault(kind => !agent.Kinds.Contains(kind)); + if (unsupported is not null) + { + return Result.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs"); + } + + Result> phases = BuildPhases(plan, request.Params, agent); + if (!phases.TryGet(out List? phaseList, out FleetError? error)) + { + return Result.Fail(error); + } + + if (!_gateway.IsOnline(agentId)) + { + return Result.Fail(MonitorErrors.AgentOffline, "agent is offline"); + } + + if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value))) + { + return Result.Fail(MonitorErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent"); + } + + if (await _tests.HasActiveAsync(agentId, probeId, ct)) + { + return Result.Fail(MonitorErrors.TestBusy, "another test is already running on this line"); + } + + var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time)); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary(request.Params), TestJson.Default.ParamMap); + var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null); + await _tests.CreateAsync(record, ct); + + Result sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, Plan = plan.Id, Phases = phaseList }, ct); + if (!sent.TryGet(out _, out FleetError? sendError)) + { + await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct); + return Result.Fail(sendError); + } + + return Result.Ok(record); + } + + public async ValueTask> CancelAsync(TestId id, CancellationToken ct) + { + TestRecord? test = await _tests.GetAsync(id, ct); + if (test is null) + { + return Result.Fail(MonitorErrors.TestNotFound, "test not found"); + } + + if (IsTerminal(test.Status)) + { + return Result.Ok(test); + } + + await _gateway.CancelTestAsync(test.AgentId, id, ct); + if (!_gateway.IsOnline(test.AgentId)) + { + // 节点已经不在线,没有人会再汇报这个测试 + await _tests.UpdateStatusAsync(id, TestStatuses.Aborted, null, null, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + _live.Publish(new TestSignal(id)); + } + + return Result.Ok(await _tests.GetAsync(id, ct) ?? test); + } + + /// monitor.test.status:只接受该测试所属 Agent 的汇报。 + public async ValueTask OnStatusAsync(NodeId agent, TestStatusParams status, CancellationToken ct) + { + TestRecord? test = await _tests.GetAsync(status.TestId, ct); + if (test is null || test.AgentId != agent || IsTerminal(test.Status)) + { + return; + } + + string next = status.Status switch + { + TestStatuses.Running or TestStatuses.PhaseDone or TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted => status.Status, + _ => test.Status, + }; + string stored = next == TestStatuses.PhaseDone ? TestStatuses.Running : next; + await _tests.UpdateStatusAsync(status.TestId, stored, status.Phase, status.ErrorCode, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + _live.Publish(new TestSignal(status.TestId)); + } + + public async ValueTask GetAsync(TestId id, CancellationToken ct) + { + TestRecord? test = await _tests.GetAsync(id, ct); + if (test is null) + { + return null; + } + + PlanSpec? plan = Plans.Find(test.Plan); + TestResultRecord? stored = await _tests.GetResultAsync(id, ct); + return new TestDetail(test, plan?.Phases.Select(p => p.Kind).ToList() ?? [], stored is null ? null : ToView(stored)); + } + + public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct); + + public static TestResultView ToView(TestResultRecord stored) + { + StoredTestMetrics metrics = JsonSerializer.Deserialize(stored.MetricsJson, TestJson.Default.StoredTestMetrics) ?? new StoredTestMetrics(); + Dictionary series = JsonSerializer.Deserialize(stored.SeriesJson, TestJson.Default.SeriesMap) ?? []; + var view = new MetricView(metrics.Metrics) { MeasuredOnServicePort = !metrics.Caveats.Contains("port_differs_from_service") }; + var scores = new Dictionary(); + foreach ((string id, ModelScore score) in ModelRegistry.ScoreAll(view)) + { + scores[id] = new ScoreView(score.Score, score.Version, score.Estimated, score.Dimensions, score.Caveats); + } + + return new TestResultView(metrics.Metrics, scores, series, metrics.Steps, metrics.Caveats); + } + + public static TestResultRecord ToRecord(TestId id, TestResult result) + { + var stored = new StoredTestMetrics { Metrics = result.Metrics, Caveats = result.Caveats, Steps = result.Steps }; + var series = result.Series.ToDictionary(s => s.Key, s => s.Value.Select(v => double.IsNaN(v) || double.IsInfinity(v) ? (double?)null : v).ToArray()); + return new TestResultRecord( + id, + JsonSerializer.SerializeToUtf8Bytes(stored, TestJson.Default.StoredTestMetrics), + JsonSerializer.SerializeToUtf8Bytes(series, TestJson.Default.SeriesMap)); + } + + public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted; + + private static Result> BuildPhases(PlanSpec plan, IReadOnlyDictionary? overrides, AgentRecord agent) + { + if (overrides is not null) + { + var unknown = new Dictionary(); + foreach (string key in overrides.Keys) + { + if (!plan.Phases.Any(p => Kinds.Find(p.Kind)!.Params.Any(s => s.Name == key))) + { + unknown[$"params.{key}"] = "is not a parameter of any phase of this plan"; + } + } + + if (unknown.Count > 0) + { + return ValidationError.Fail>(unknown); + } + } + + double? limitMbps = null; + if (agent.LocalLimitsJson is { } limitsJson) + { + try + { + limitMbps = JsonSerializer.Deserialize(limitsJson, LocalLimitsJson.Default.LocalLimits)?.MaxTotalMbps; + } + catch (JsonException) + { + // 无法解析就当作没有声明 + } + } + + var phases = new List(); + var errors = new Dictionary(); + foreach (PhaseSpec phase in plan.Phases) + { + KindSpec kind = Kinds.Find(phase.Kind)!; + var merged = new Dictionary(phase.Overrides); + if (overrides is not null) + { + foreach ((string key, JsonElement value) in overrides) + { + if (kind.Params.Any(s => s.Name == key)) + { + merged[key] = value; + } + } + } + + // 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改) + if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0) + { + merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32); + } + + ParamResolution resolved = ParamResolver.Resolve(kind.Params, merged); + if (!resolved.IsValid) + { + foreach ((string key, string message) in resolved.Errors) + { + errors[$"params.{key}"] = message; + } + + continue; + } + + long bits = kind.BudgetBitsPerSecond(resolved.Values!); + if (limitMbps is > 0 && bits > limitMbps * 1_000_000) + { + return Result>.Fail(MonitorErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s"); + } + + int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : phase.DurationSeconds; + phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary(resolved.Values.Values), DurationSeconds = duration }); + } + + return errors.Count > 0 ? ValidationError.Fail>(errors) : Result>.Ok(phases); + } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(int))] +internal sealed partial class PlanPortJson : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/src/Monitor.Backend.Application/ValidationError.cs b/backend/src/Monitor.Backend.Application/ValidationError.cs new file mode 100644 index 0000000..c1468f9 --- /dev/null +++ b/backend/src/Monitor.Backend.Application/ValidationError.cs @@ -0,0 +1,17 @@ +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.Backend.Application; + +/// 校验失败,带逐字段的原因(键是 JSON 字段路径)。Api 层把它映射成 problem+json 的 errors。 +public sealed record ValidationError(string Message, IReadOnlyDictionary Fields) + : FleetError(MonitorErrors.ValidationFailed, Message) +{ + public static ValidationError Single(string field, string message) => + new("Validation failed", new Dictionary { [field] = message }); + + public static Result Fail(string field, string message) => Result.Fail(Single(field, message)); + + public static Result Fail(IReadOnlyDictionary fields) => + Result.Fail(new ValidationError("Validation failed", fields)); +} diff --git a/backend/src/Monitor.Backend.Gateway/ConnectionShutdown.cs b/backend/src/Monitor.Backend.Gateway/ConnectionShutdown.cs new file mode 100644 index 0000000..83227d4 --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/ConnectionShutdown.cs @@ -0,0 +1,49 @@ +using Fleet.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Monitor.Backend.Gateway; + +/// +/// 宿主开始停止时先关闭所有 Agent 的长连接(Close 握手,不发 revoked:Agent 会重连)。 +/// 否则 Kestrel 的有序停止要等满 30 秒的超时,才会放弃这些永远不会自己结束的 WebSocket。 +/// +public sealed class ConnectionShutdown : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostApplicationLifetime _lifetime; + private Task? _closing; + + public ConnectionShutdown(IServiceProvider services, IHostApplicationLifetime lifetime) + { + _services = services; + _lifetime = lifetime; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + // ApplicationStopping 在有序停止的最开始触发,早于 Kestrel 等待连接结束:这里不阻塞,关闭在后台进行,StopAsync 再等它做完 + _lifetime.ApplicationStopping.Register(() => _closing = CloseAsync()); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_closing is { } closing) + { + await closing.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); + } + } + + private async Task CloseAsync() + { + try + { + await _services.GetRequiredService().CloseAllAsync("server shutting down"); + } + catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException) + { + // 停止过程中,服务已经在释放:Kestrel 的超时兜底 + } + } +} diff --git a/backend/src/Monitor.Backend.Gateway/FleetGateway.cs b/backend/src/Monitor.Backend.Gateway/FleetGateway.cs new file mode 100644 index 0000000..84847fd --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/FleetGateway.cs @@ -0,0 +1,73 @@ +using Fleet.Core; +using Fleet.Server; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Gateway; + +/// +/// Application 通往 Agent 连接的端口的实现,建立在 Fleet.Server.Link 的在线表之上。 +/// Fleet 的连接宿主依赖节点事件处理器,而事件处理器依赖 Application,Application 又依赖这个端口: +/// 这里在调用时才解析 Fleet 的服务,打断构造期的循环依赖。 +/// +public sealed class FleetGateway : IAgentGateway +{ + private readonly IServiceProvider _services; + + public FleetGateway(IServiceProvider services) + { + _services = services; + } + + private IFleetConnections Connections => _services.GetRequiredService(); + + private IStateNotifier Notifier => _services.GetRequiredService(); + + public IReadOnlyCollection OnlineAgents => Connections.OnlineNodes; + + public bool IsOnline(NodeId agent) => Connections.IsOnline(agent); + + public void NotifyTaskSetChanged(NodeId agent) => Notifier.NotifyChanged(agent); + + public void NotifyAllTaskSetsChanged() => Notifier.NotifyAllChanged(); + + public async ValueTask NotifyProbeAgentsChangedAsync(ProbeAgentsResult agents, CancellationToken ct) + { + IFleetConnections connections = Connections; + foreach (NodeId node in connections.OnlineNodes) + { + await connections.NotifyAsync(node, MonitorMethods.ProbeAgentsChanged, agents, MonitorJsonContext.Default.ProbeAgentsResult, ct); + } + } + + public async ValueTask> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct) + { + Result result = await Connections.RequestAsync( + agent, MonitorMethods.TestRun, run, MonitorJsonContext.Default.TestRunParams, MonitorJsonContext.Default.TestRunResult, ct); + if (!result.TryGet(out TestRunResult? accepted, out FleetError? error)) + { + return Result.Fail(error.Code == FleetErrors.NodeOffline ? MonitorErrors.AgentOffline : error.Code, error.Message); + } + + return accepted.Accepted ? Result.Ok(true) : Result.Fail(MonitorErrors.TestBusy, "the agent did not accept the test"); + } + + public async ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct) => + await Connections.RequestAsync( + agent, MonitorMethods.TestCancel, new TestCancelParams { TestId = test }, MonitorJsonContext.Default.TestCancelParams, MonitorJsonContext.Default.TestRunResult, ct); + + public async ValueTask> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct) + { + Result result = await Connections.RequestAsync( + agent, MonitorMethods.ProbeDisconnect, new ProbeDisconnectParams { ProbeId = probe }, MonitorJsonContext.Default.ProbeDisconnectParams, MonitorJsonContext.Default.TestRunResult, ct); + if (!result.TryGet(out TestRunResult? done, out FleetError? error)) + { + return Result.Fail(error.Code == FleetErrors.NodeOffline ? MonitorErrors.AgentOffline : error.Code, error.Message); + } + + return Result.Ok(done.Accepted); + } + + public ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct) => Connections.DisconnectAsync(agent, reason, ct); +} diff --git a/backend/src/Monitor.Backend.Gateway/GatewayServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Gateway/GatewayServiceCollectionExtensions.cs new file mode 100644 index 0000000..450dde1 --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/GatewayServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +using Fleet.Core; +using Fleet.Rpc; +using Fleet.Server; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Gateway; + +public static class GatewayServiceCollectionExtensions +{ + /// + /// 在 Fleet.Server 之上接入 Monitor:期望状态(TaskSet)、RawChunk 收件箱、monitor.* 方法、注册挂钩和节点事件。 + /// 需要事先注册 和 ,并在 AddMonitorApplication 之后调用。 + /// + public static IServiceCollection AddMonitorGateway(this IServiceCollection services) + { + services.AddSingleton(); + services.AddHostedService(); + services.AddFleetEnrollment(); + services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix) + .AddState() + .AddInbox() + .MapMethods(MapMonitorMethods); + return services; + } + + private static void MapMonitorMethods(RpcRouter router, IServiceProvider services) + { + router + .OnNotify( + MonitorMethods.Event, + MonitorJsonContext.Default.AgentEventParams, + (context, parameters, ct) => services.GetRequiredService().OnEventAsync(context.Node.Id, parameters, ct)) + .OnNotify( + MonitorMethods.Host, + MonitorJsonContext.Default.HostSnapshot, + (context, snapshot, ct) => services.GetRequiredService().OnHostAsync(context.Node.Id, snapshot, ct)) + .OnNotify( + MonitorMethods.TestStatus, + MonitorJsonContext.Default.TestStatusParams, + (context, parameters, ct) => services.GetRequiredService().OnStatusAsync(context.Node.Id, parameters, ct)) + .On( + MonitorMethods.ProbeAgents, + MonitorJsonContext.Default.ProbeAgentsParams, + MonitorJsonContext.Default.ProbeAgentsResult, + async (context, parameters, ct) => Result.Ok(await services.GetRequiredService().ProbeAgentsAsync(ct))); + } +} diff --git a/backend/src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj b/backend/src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj new file mode 100644 index 0000000..e9f9ceb --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj @@ -0,0 +1,11 @@ + + + Library + true + + + + + + + diff --git a/backend/src/Monitor.Backend.Gateway/RawChunkInbox.cs b/backend/src/Monitor.Backend.Gateway/RawChunkInbox.cs new file mode 100644 index 0000000..9aaedc8 --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/RawChunkInbox.cs @@ -0,0 +1,28 @@ +using Fleet.Server; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Gateway; + +/// +/// RawChunk 收件箱(二进制通道 16):持久化之后才返回,Fleet 随后才回复 ACK,按 chunk_id 去重。 +/// 永远不可能被接受的块(损坏、冒名、未知 Kind)也确认掉并记录原因,否则节点会无限重发。 +/// +public sealed class RawChunkInbox : IInbox +{ + private readonly IngestService _ingest; + + public RawChunkInbox(IngestService ingest) + { + _ingest = ingest; + } + + public byte Channel => MonitorChannels.RawChunk; + + public async ValueTask HandleAsync(NodeRecord node, Guid msgId, ReadOnlyMemory payload, CancellationToken ct) => + await _ingest.IngestAsync(node.Id, msgId, payload, ct) switch + { + IngestOutcome.Stored => InboxOutcome.Stored, + _ => InboxOutcome.Duplicate, + }; +} diff --git a/backend/src/Monitor.Backend.Gateway/TaskSetStateProvider.cs b/backend/src/Monitor.Backend.Gateway/TaskSetStateProvider.cs new file mode 100644 index 0000000..f72669f --- /dev/null +++ b/backend/src/Monitor.Backend.Gateway/TaskSetStateProvider.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Rpc; +using Fleet.Server; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Gateway; + +/// +/// 期望状态:为每个 Agent 下发完整的 快照(有效任务集 + Probe 公钥)。 +/// 节点应用之后回传 applied_version 和 rejected[](unsupported、budget_exceeded、policy_denied),面板上可以看到。 +/// +public sealed class TaskSetStateProvider : IStateProvider +{ + private readonly TaskPlanner _planner; + private readonly IAgentStore _agents; + + public TaskSetStateProvider(TaskPlanner planner, IAgentStore agents) + { + _planner = planner; + _agents = agents; + } + + public string Type => TaskSet.StateType; + + public async ValueTask GetAsync(NodeRecord node, CancellationToken ct) + { + DesiredTaskSet? desired = await _planner.GetDesiredAsync(node.Id, ct); + return desired is null ? null : new StateDocument(desired.Version, JsonSerializer.SerializeToElement(desired.Document, MonitorJsonContext.Default.TaskSet)); + } + + public async ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct) + { + string? rejected = result.Rejected.Count == 0 ? null : JsonSerializer.Serialize(result.Rejected, GatewayJson.Default.ListRejectedItem); + await _agents.SetAppliedTaskStateAsync(node.Id, result.AppliedVersion, rejected, ct); + } +} + +[JsonSerializable(typeof(List))] +internal sealed partial class GatewayJson : JsonSerializerContext; diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj b/backend/src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj new file mode 100644 index 0000000..88d4c26 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj @@ -0,0 +1,12 @@ + + + true + + + + + + + + + diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs b/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs new file mode 100644 index 0000000..1c567ba --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/Schema.cs @@ -0,0 +1,240 @@ +using Microsoft.Data.Sqlite; + +namespace Monitor.Backend.Storage.Sqlite; + +/// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 +internal static class Schema +{ + public const int CurrentVersion = 3; + + public static void Migrate(SqliteConnection connection) + { + int version = connection.Scalar("PRAGMA user_version"); + if (version > CurrentVersion) + { + throw new InvalidOperationException($"database schema version {version} is newer than this build supports ({CurrentVersion})"); + } + + if (version < 1) + { + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(V1); + connection.Exec("PRAGMA user_version = 1"); + transaction.Commit(); + } + + if (version < 2) + { + // 测试属于一条线路(Probe,Agent):旧测试没有 Probe,留空 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE tests ADD COLUMN probe_id TEXT NOT NULL DEFAULT ''"); + connection.Exec("CREATE INDEX ix_tests_line ON tests (agent_id, probe_id, requested_at)"); + connection.Exec("PRAGMA user_version = 2"); + transaction.Commit(); + } + + if (version < 3) + { + // 主机资源占用的历史(每个 Agent 每分钟一行) + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(""" + CREATE TABLE host_samples ( + agent_id TEXT NOT NULL, + at INTEGER NOT NULL, + cpu REAL, + mem_used INTEGER, + mem_total INTEGER, + disk_used INTEGER, + disk_total INTEGER, + net_rx_bps INTEGER, + net_tx_bps INTEGER, + uptime INTEGER, + PRIMARY KEY (agent_id, at) + ) WITHOUT ROWID; + """); + connection.Exec("PRAGMA user_version = 3"); + transaction.Commit(); + } + } + + /// + /// 新增 Kind 或指标都不需要改表结构:指标是 metric_*.metrics 里的一个键,Kind 只是 raw_chunks.kind 的一个取值。 + /// + private const string V1 = """ + CREATE TABLE agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + pubkey BLOB NOT NULL, + tls_spki BLOB NOT NULL, + public_host TEXT NOT NULL DEFAULT '', + probe_tcp_port INTEGER NOT NULL DEFAULT 7443, + probe_udp_ports TEXT NOT NULL DEFAULT '[7443]', + labels TEXT NOT NULL DEFAULT '[]', + public_visible INTEGER NOT NULL DEFAULT 1, + kinds TEXT NOT NULL DEFAULT '[]', + agent_version TEXT, + local_limits TEXT, + task_version INTEGER NOT NULL DEFAULT 0, + applied_task_version INTEGER NOT NULL DEFAULT 0, + rejected TEXT, + task_hash TEXT, + meta TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER, + row_version INTEGER NOT NULL DEFAULT 0 + ); + -- 同名节点只允许一个已建立的(pending 的可以重名,等待管理员接管或改名) + CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending'; + + CREATE TABLE join_tokens ( + id TEXT PRIMARY KEY, + token_sha256 BLOB NOT NULL UNIQUE, + note TEXT, + labels TEXT NOT NULL DEFAULT '[]', + auto_approve INTEGER NOT NULL, + max_uses INTEGER NOT NULL, + uses INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL + ); + + CREATE TABLE probes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pubkey BLOB NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER + ); + + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash BLOB NOT NULL, + password_salt BLOB NOT NULL, + iterations INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE sessions ( + id_sha256 BLOB PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + scope TEXT NOT NULL, + token_sha256 BLOB NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER + ); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, + base_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + target TEXT, + params TEXT NOT NULL DEFAULT '{}', + schedule TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, + row_version INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE tests ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + plan TEXT NOT NULL, + params TEXT NOT NULL, + trigger TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + phase INTEGER, + phase_count INTEGER NOT NULL DEFAULT 0, + requested_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER + ); + CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at); + CREATE TABLE test_results ( + test_id TEXT PRIMARY KEY, + metrics BLOB NOT NULL, + series BLOB NOT NULL + ) WITHOUT ROWID; + + CREATE TABLE raw_segments ( + id INTEGER PRIMARY KEY, + day TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE raw_chunks ( + chunk_id TEXT PRIMARY KEY, + segment_id INTEGER NOT NULL REFERENCES raw_segments(id), + offset INTEGER NOT NULL, + length INTEGER NOT NULL, + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + kind TEXT NOT NULL, + task_id TEXT, + test_id TEXT, + phase INTEGER, + start_ts INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + partial INTEGER NOT NULL + ) WITHOUT ROWID; + CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts); + CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL; + CREATE INDEX ix_raw_segment ON raw_chunks (segment_id); + + CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + CREATE INDEX ix_metric_10s_time ON metric_10s (window_start); + CREATE INDEX ix_metric_1m_time ON metric_1m (window_start); + CREATE INDEX ix_metric_5m_time ON metric_5m (window_start); + + CREATE TABLE metric_versions ( + metric_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + recompute_from INTEGER, + recompute_cursor INTEGER + ); + + CREATE TABLE analysis_queue ( + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + window_start INTEGER NOT NULL, + not_before INTEGER NOT NULL, + reason TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (agent_id, series, window_start) + ) WITHOUT ROWID; + CREATE INDEX ix_queue_due ON analysis_queue (not_before); + CREATE TABLE rollup_dirty ( + level INTEGER NOT NULL, + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + bucket_start INTEGER NOT NULL, + PRIMARY KEY (level, agent_id, series, bucket_start) + ) WITHOUT ROWID; + + CREATE TABLE events ( + id INTEGER PRIMARY KEY, + agent_id TEXT, + kind TEXT NOT NULL, + at INTEGER NOT NULL, + detail TEXT + ); + CREATE INDEX ix_events_time ON events (at); + CREATE INDEX ix_events_agent ON events (agent_id, at); + """; +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/Sql.cs b/backend/src/Monitor.Backend.Storage.Sqlite/Sql.cs new file mode 100644 index 0000000..6819ac9 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/Sql.cs @@ -0,0 +1,59 @@ +using Microsoft.Data.Sqlite; + +namespace Monitor.Backend.Storage.Sqlite; + +/// 手写的参数化 SQL 的小帮手:禁止字符串拼接值,所有值都经过参数。 +internal static class Sql +{ + public static SqliteCommand Cmd(this SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) + { + SqliteCommand command = connection.CreateCommand(); + command.CommandText = sql; + foreach ((string name, object? value) in parameters) + { + command.Parameters.AddWithValue(name, value ?? DBNull.Value); + } + + return command; + } + + public static int Exec(this SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) + { + using SqliteCommand command = connection.Cmd(sql, parameters); + return command.ExecuteNonQuery(); + } + + public static T? Scalar(this SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) + { + using SqliteCommand command = connection.Cmd(sql, parameters); + object? value = command.ExecuteScalar(); + return value is null or DBNull ? default : (T)Convert.ChangeType(value, Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } + + public static List Query(this SqliteConnection connection, string sql, Func map, params (string Name, object? Value)[] parameters) + { + using SqliteCommand command = connection.Cmd(sql, parameters); + using SqliteDataReader reader = command.ExecuteReader(); + var rows = new List(); + while (reader.Read()) + { + rows.Add(map(reader)); + } + + return rows; + } + + public static T? QueryOne(this SqliteConnection connection, string sql, Func map, params (string Name, object? Value)[] parameters) + where T : class + { + using SqliteCommand command = connection.Cmd(sql, parameters); + using SqliteDataReader reader = command.ExecuteReader(); + return reader.Read() ? map(reader) : null; + } + + public static long? NullableLong(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal); + + public static string? NullableString(this SqliteDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + + public static byte[] Bytes(this SqliteDataReader reader, int ordinal) => (byte[])reader.GetValue(ordinal); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAgentStore.cs new file mode 100644 index 0000000..13454bd --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -0,0 +1,229 @@ +using Fleet.Core; +using Fleet.Server; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Storage.Sqlite; + +/// +/// Agent 表同时是 Fleet 的节点表:实现 (Monitor 的视图)和 (注册与认证)。 +/// 加入令牌的校验也在这里,和 Monitor 的其他数据放在同一个 monitor.db 里,由同一个单写入者写入。 +/// +public sealed class SqliteAgentStore : IAgentStore, IFleetStore +{ + private const string AgentColumns = + "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version"; + + private readonly SqliteDatabase _db; + + public SqliteAgentStore(SqliteDatabase db) + { + _db = db; + } + + // ───────────────────────── IAgentStore ───────────────────────── + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {AgentColumns} FROM agents ORDER BY created_at, id", ReadAgent), ct); + + public ValueTask GetAsync(NodeId id, CancellationToken ct) => + _db.ReadAsync(c => GetInternal(c, id), ct); + + public ValueTask ApplyEnrollmentAsync(NodeId id, AgentEnrollment enrollment, CancellationToken ct) => + _db.ExecuteAsync(c => + { + c.Exec( + "UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, probe_tcp_port = @tcp, probe_udp_ports = @udp, " + + "kinds = CASE WHEN kinds = '[]' THEN @kinds ELSE kinds END, row_version = row_version + 1 WHERE id = @id", + ("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.ProbeTcpPort), + ("@udp", StorageJson.ToJson(enrollment.ProbeUdpPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds))); + }, ct); + + public ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) => + _db.WriteAsync(c => + { + AgentRecord? current = GetInternal(c, id); + if (current is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } + + if (expectedRowVersion is { } expected && expected != current.RowVersion) + { + return Result.Fail(MonitorErrors.PreconditionFailed, "the agent was modified by someone else"); + } + + if (patch.Name is { } name && name != current.Name && current.Status != NodeStatus.Pending && NameTaken(c, name, id)) + { + return Result.Fail(MonitorErrors.NameConflict, "another agent already uses this name"); + } + + c.Exec( + "UPDATE agents SET name = @name, labels = @labels, public_host = @host, public_visible = @visible, row_version = row_version + 1 WHERE id = @id", + ("@id", id.Value), ("@name", patch.Name ?? current.Name), + ("@labels", StorageJson.ToJson(patch.Labels ?? current.Labels)), + ("@host", patch.PublicHost ?? current.PublicHost), ("@visible", (patch.PublicVisible ?? current.PublicVisible) ? 1 : 0)); + return Result.Ok(GetInternal(c, id)!); + }, ct); + + public ValueTask> SetStatusAsync(NodeId id, NodeStatus status, CancellationToken ct) => + _db.WriteAsync(c => + { + AgentRecord? current = GetInternal(c, id); + if (current is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "agent not found"); + } + + if (status != NodeStatus.Pending && NameTaken(c, current.Name, id)) + { + return Result.Fail(MonitorErrors.NameConflict, "another agent already uses this name; rename it or take over the existing node"); + } + + c.Exec("UPDATE agents SET status = @status, row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@status", StatusText(status))); + return Result.Ok(GetInternal(c, id)!); + }, ct); + + public ValueTask> TakeoverAsync(NodeId pendingId, CancellationToken ct) => + _db.WriteAsync(c => + { + AgentRecord? pending = GetInternal(c, pendingId); + if (pending is null || pending.Status != NodeStatus.Pending) + { + return Result.Fail(MonitorErrors.AgentNotFound, "no pending agent with this id"); + } + + string? existingId = c.Scalar("SELECT id FROM agents WHERE name = @name AND status <> 'pending' AND id <> @id", ("@name", pending.Name), ("@id", pendingId.Value)); + if (existingId is null) + { + return Result.Fail(MonitorErrors.AgentNotFound, "there is no established agent with this name to take over"); + } + + // 旧记录换成新公钥,历史数据保留;待审批记录删除 + c.Exec("DELETE FROM agents WHERE id = @id", ("@id", pendingId.Value)); + c.Exec( + "UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id", + ("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki)); + return Result.Ok(GetInternal(c, new NodeId(existingId))!); + }, ct); + + public ValueTask DeleteAsync(NodeId id, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("DELETE FROM agents WHERE id = @id", ("@id", id.Value)), ct); + + public ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct) => + _db.ExecuteAsync(c => + { + c.Exec( + "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), " + + "probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), row_version = row_version + 1 WHERE id = @id", + ("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson), + ("@spki", hello.TlsSpki), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts))); + }, ct); + + public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE agents SET task_version = @v, task_hash = @h WHERE id = @id", ("@id", id.Value), ("@v", version), ("@h", taskHash)), ct); + + public ValueTask GetTaskHashAsync(NodeId id, CancellationToken ct) => + _db.ReadAsync(c => c.Scalar("SELECT task_hash FROM agents WHERE id = @id", ("@id", id.Value)), ct); + + public ValueTask SetAppliedTaskStateAsync(NodeId id, long appliedVersion, string? rejectedJson, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE agents SET applied_task_version = @v, rejected = @r WHERE id = @id", ("@id", id.Value), ("@v", appliedVersion), ("@r", rejectedJson)), ct); + + // ───────────────────────── IFleetStore ───────────────────────── + + public ValueTask FindJoinTokenAsync(ReadOnlyMemory tokenSha256, CancellationToken ct) + { + byte[] hash = tokenSha256.ToArray(); + return _db.ReadAsync( + c => c.QueryOne( + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9)), + ("@h", hash)), + ct); + } + + public ValueTask TryConsumeJoinTokenAsync(string tokenId, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "UPDATE join_tokens SET uses = uses + 1 WHERE id = @id AND revoked_at IS NULL AND expires_at > @now AND uses < max_uses", + ("@id", tokenId), ("@now", nowMs)) == 1, + ct); + + public ValueTask> CreateNodeAsync(NewNode node, CancellationToken ct) => + _db.WriteAsync(c => + { + if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id)) + { + return Result.Fail(FleetErrors.NameConflict, "a node with this name already exists"); + } + + c.Exec( + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now)", + ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs)); + return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); + }, ct); + + async ValueTask IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => + await GetAsync(id, ct) is { } agent ? ToNode(agent, null) : null; + + public ValueTask FindEstablishedNodeByNameAsync(string name, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne($"SELECT {AgentColumns} FROM agents WHERE name = @name AND status <> 'pending'", ReadAgent, ("@name", name)) is { } agent ? ToNode(agent, null) : null, + ct); + + public ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "UPDATE agents SET last_seen_at = @at, agent_version = COALESCE(@version, agent_version) WHERE id = @id", + ("@id", id.Value), ("@at", presence.AtMs), ("@version", presence.NodeVersion)), + ct); + + // ───────────────────────── 内部 ───────────────────────── + + private static AgentRecord? GetInternal(SqliteConnection c, NodeId id) => + c.QueryOne($"SELECT {AgentColumns} FROM agents WHERE id = @id", ReadAgent, ("@id", id.Value)); + + private static bool NameTaken(SqliteConnection c, string name, NodeId except) => + c.Scalar("SELECT COUNT(*) FROM agents WHERE name = @name AND status <> 'pending' AND id <> @id", ("@name", name), ("@id", except.Value)) > 0; + + private static AgentRecord ReadAgent(SqliteDataReader r) => new( + new NodeId(r.GetString(0)), + r.GetString(1), + ParseStatus(r.GetString(2)), + r.Bytes(3), + r.Bytes(4), + r.GetString(5), + r.GetInt32(6), + StorageJson.Ints(r.GetString(7)), + StorageJson.Strings(r.GetString(8)), + r.GetInt64(9) != 0, + StorageJson.Strings(r.GetString(10)), + r.NullableString(11), + r.NullableString(12), + r.GetInt64(13), + r.GetInt64(14), + r.NullableString(15), + r.GetInt64(16), + r.NullableLong(17), + r.GetInt64(18)); + + private static NodeRecord ToNode(AgentRecord a, string? metaJson) => + new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); + + private static string StatusText(NodeStatus status) => status switch + { + NodeStatus.Active => "active", + NodeStatus.Disabled => "disabled", + _ => "pending", + }; + + private static NodeStatus ParseStatus(string text) => text switch + { + "active" => NodeStatus.Active, + "disabled" => NodeStatus.Disabled, + _ => NodeStatus.Pending, + }; +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAuthStore.cs new file mode 100644 index 0000000..683f477 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -0,0 +1,86 @@ +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; + +namespace Monitor.Backend.Storage.Sqlite; + +public sealed class SqliteAuthStore : IAuthStore +{ + private const string UserColumns = "id, username, password_hash, password_salt, iterations, created_at"; + private const string TokenColumns = "id, name, scope, token_sha256, created_at, last_used_at, revoked_at"; + + private readonly SqliteDatabase _db; + + public SqliteAuthStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask CountUsersAsync(CancellationToken ct) => + _db.ReadAsync(c => (int)c.Scalar("SELECT COUNT(*) FROM users"), ct); + + public ValueTask GetUserByNameAsync(string username, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {UserColumns} FROM users WHERE username = @u", ReadUser, ("@u", username)), ct); + + public ValueTask GetUserAsync(string id, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {UserColumns} FROM users WHERE id = @id", ReadUser, ("@id", id)), ct); + + public ValueTask> ListUsersAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {UserColumns} FROM users ORDER BY created_at, id", ReadUser), ct); + + public ValueTask CreateUserAsync(UserRecord user, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "INSERT OR IGNORE INTO users (id, username, password_hash, password_salt, iterations, created_at) VALUES (@id, @u, @h, @s, @i, @at)", + ("@id", user.Id), ("@u", user.Username), ("@h", user.PasswordHash), ("@s", user.PasswordSalt), ("@i", user.Iterations), ("@at", user.CreatedAtMs)) == 1, + ct); + + public ValueTask UpdatePasswordAsync(string userId, byte[] hash, byte[] salt, int iterations, CancellationToken ct) => + _db.ExecuteAsync( + c => + { + c.Exec("UPDATE users SET password_hash = @h, password_salt = @s, iterations = @i WHERE id = @id", ("@id", userId), ("@h", hash), ("@s", salt), ("@i", iterations)); + c.Exec("DELETE FROM sessions WHERE user_id = @id", ("@id", userId)); + }, + ct); + + public ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at) VALUES (@id, @u, @csrf, @c, @e)", + ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs)), + ct); + + public ValueTask GetSessionAsync(byte[] idSha256, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4)), ("@id", idSha256)), + ct); + + public ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("DELETE FROM sessions WHERE id_sha256 = @id", ("@id", idSha256)), ct); + + public ValueTask DeleteExpiredSessionsAsync(long nowMs, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("DELETE FROM sessions WHERE expires_at <= @now", ("@now", nowMs)), ct); + + public ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO api_tokens (id, name, scope, token_sha256, created_at) VALUES (@id, @n, @s, @h, @at)", + ("@id", token.Id), ("@n", token.Name), ("@s", token.Scope), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs)), + ct); + + public ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {TokenColumns} FROM api_tokens WHERE token_sha256 = @h", ReadToken, ("@h", tokenSha256)), ct); + + public ValueTask> ListApiTokensAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {TokenColumns} FROM api_tokens ORDER BY created_at DESC, id", ReadToken), ct); + + public ValueTask RevokeApiTokenAsync(string id, long nowMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("UPDATE api_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); + + public ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE api_tokens SET last_used_at = @now WHERE id = @id", ("@id", id), ("@now", nowMs)), ct); + + private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5)); + + private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6)); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs new file mode 100644 index 0000000..83656f0 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using Fleet.Core; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Storage.Sqlite; + +public sealed class SqliteJoinTokenStore : IJoinTokenStore +{ + private readonly SqliteDatabase _db; + + public SqliteJoinTokenStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) => + _db.WriteAsync(c => + { + c.Exec( + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, max_uses, expires_at, created_at) VALUES (@id, @h, @note, @labels, @auto, @max, @exp, @now)", + ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs)); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs); + }, ct); + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8))), + ct); + + public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("UPDATE join_tokens SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); +} + +public sealed class SqliteProbeStore : IProbeStore +{ + private readonly SqliteDatabase _db; + + public SqliteProbeStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4))), + ct); + + public ValueTask GetAsync(string id, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4)), ("@id", id)), + ct); + + public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("UPDATE probes SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); + + public ValueTask DeleteAsync(string id, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM probes WHERE id = @id", ("@id", id)) == 1, ct); + + public ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct) => + _db.ExecuteAsync(c => + { + c.Exec( + "INSERT INTO probes (id, name, pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @at, @revoked) " + + "ON CONFLICT(id) DO UPDATE SET name = excluded.name, pubkey = excluded.pubkey, created_at = excluded.created_at, revoked_at = excluded.revoked_at", + ("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs)); + }, ct); +} + +public sealed class SqliteSettingsStore : ISettingsStore +{ + private readonly SqliteDatabase _db; + + public SqliteSettingsStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask GetAsync(string key, CancellationToken ct) => + _db.ReadAsync(c => c.Scalar("SELECT value FROM settings WHERE key = @k", ("@k", key)), ct); + + public ValueTask SetAsync(string key, string value, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("INSERT INTO settings (key, value) VALUES (@k, @v) ON CONFLICT(key) DO UPDATE SET value = excluded.value", ("@k", key), ("@v", value)), ct); + + public ValueTask> GetAllAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query("SELECT key, value FROM settings", r => (r.GetString(0), r.GetString(1))).ToDictionary(p => p.Item1, p => p.Item2), ct); + + public ValueTask IncrementAsync(string key, CancellationToken ct) => + _db.WriteAsync( + c => c.Scalar( + "INSERT INTO settings (key, value) VALUES (@k, '1') ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) RETURNING CAST(value AS INTEGER)", + ("@k", key)), + ct); +} + +public sealed class SqliteTaskStore : ITaskStore +{ + private const string Columns = "id, scope, agent_id, base_task_id, kind, target, params, schedule, enabled, updated_at, row_version"; + + private readonly SqliteDatabase _db; + + public SqliteTaskStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {Columns} FROM tasks ORDER BY id", Read), ct); + + public ValueTask GetAsync(TaskId id, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM tasks WHERE id = @id", Read, ("@id", id.Value)), ct); + + public ValueTask CreateAsync(TaskRecord task, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO tasks (id, scope, agent_id, base_task_id, kind, target, params, schedule, enabled, updated_at) VALUES (@id, @scope, @agent, @base, @kind, @target, @params, @schedule, @enabled, @at)", + ("@id", task.Id.Value), ("@scope", task.Scope), ("@agent", task.AgentId?.Value), ("@base", task.BaseTaskId?.Value), ("@kind", task.Kind), + ("@target", task.Target), ("@params", JsonSerializer.Serialize(new Dictionary(task.Params), StorageJson.Default.ParamMap)), + ("@schedule", task.Schedule), ("@enabled", task.Enabled ? 1 : 0), ("@at", task.UpdatedAtMs)), + ct); + + public ValueTask> UpdateAsync(TaskRecord task, long? expectedRowVersion, CancellationToken ct) => + _db.WriteAsync(c => + { + TaskRecord? current = c.QueryOne($"SELECT {Columns} FROM tasks WHERE id = @id", Read, ("@id", task.Id.Value)); + if (current is null) + { + return Result.Fail(MonitorErrors.TaskNotFound, "task not found"); + } + + if (expectedRowVersion is { } expected && expected != current.RowVersion) + { + return Result.Fail(MonitorErrors.PreconditionFailed, "the task was modified by someone else"); + } + + c.Exec( + "UPDATE tasks SET target = @target, params = @params, schedule = @schedule, enabled = @enabled, updated_at = @at, row_version = row_version + 1 WHERE id = @id", + ("@id", task.Id.Value), ("@target", task.Target), ("@params", JsonSerializer.Serialize(new Dictionary(task.Params), StorageJson.Default.ParamMap)), + ("@schedule", task.Schedule), ("@enabled", task.Enabled ? 1 : 0), ("@at", task.UpdatedAtMs)); + return Result.Ok(c.QueryOne($"SELECT {Columns} FROM tasks WHERE id = @id", Read, ("@id", task.Id.Value))!); + }, ct); + + public ValueTask DeleteAsync(TaskId id, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("DELETE FROM tasks WHERE id = @id", ("@id", id.Value)), ct); + + private static TaskRecord Read(SqliteDataReader r) => new( + new TaskId(r.GetString(0)), + r.GetString(1), + r.NullableString(2) is { } agent ? new NodeId(agent) : null, + r.NullableString(3) is { } baseId ? new TaskId(baseId) : null, + r.GetString(4), + r.NullableString(5), + JsonSerializer.Deserialize(r.GetString(6), StorageJson.Default.ParamMap) ?? [], + r.GetString(7), + r.GetInt64(8) != 0, + r.GetInt64(9), + r.GetInt64(10)); +} + +public sealed class SqliteEventStore : IEventStore +{ + private readonly SqliteDatabase _db; + + public SqliteEventStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask AddAsync(NodeId? agentId, string kind, long atMs, string? detail, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("INSERT INTO events (agent_id, kind, at, detail) VALUES (@a, @k, @at, @d)", ("@a", agentId?.Value), ("@k", kind), ("@at", atMs), ("@d", detail)), ct); + + public ValueTask> ListAsync(NodeId? agentId, string? kind, long fromMs, long toMs, long? beforeId, int limit, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT id, agent_id, kind, at, detail FROM events WHERE at >= @from AND at < @to AND (@agent IS NULL OR agent_id = @agent) AND (@kind IS NULL OR kind = @kind) " + + "AND (@before IS NULL OR id < @before) ORDER BY id DESC LIMIT @limit", + r => new EventRecord(r.GetInt64(0), r.NullableString(1) is { } a ? new NodeId(a) : null, r.GetString(2), r.GetInt64(3), r.NullableString(4)), + ("@from", fromMs), ("@to", toMs), ("@agent", agentId?.Value), ("@kind", kind), ("@before", beforeId), ("@limit", limit)), + ct); + + public ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM events WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteDatabase.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteDatabase.cs new file mode 100644 index 0000000..a433ffc --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteDatabase.cs @@ -0,0 +1,150 @@ +using System.Threading.Channels; +using Microsoft.Data.Sqlite; + +namespace Monitor.Backend.Storage.Sqlite; + +/// +/// 连接与单写入者:所有写操作经过一个有界 Channel,由一个后台循环依次执行(每个操作一个事务); +/// 读操作使用独立的只读连接。WAL 模式下读不会阻塞写。 +/// +public sealed class SqliteDatabase : IDisposable, IAsyncDisposable +{ + private const int WriteQueueCapacity = 1024; + + private readonly Channel _writes = Channel.CreateBounded(new BoundedChannelOptions(WriteQueueCapacity) + { + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait, + }); + + private readonly string _readConnectionString; + private readonly SqliteConnection _writer; + private readonly Task _loop; + + public SqliteDatabase(string dataDirectory) + { + DataDirectory = Path.GetFullPath(dataDirectory); + RawDirectory = Path.Combine(DataDirectory, "raw"); + Directory.CreateDirectory(RawDirectory); + + string path = Path.Combine(DataDirectory, "monitor.db"); + _readConnectionString = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = true }.ToString(); + + _writer = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }.ToString()); + try + { + _writer.Open(); + Pragma(_writer, "journal_mode=WAL"); + Pragma(_writer, "synchronous=NORMAL"); + Pragma(_writer, "foreign_keys=ON"); + Pragma(_writer, "busy_timeout=5000"); + Schema.Migrate(_writer); + } + catch + { + // 构造失败(例如数据库来自更新的版本)时不能把写连接和文件句柄留在身后 + _writer.Dispose(); + SqliteConnection.ClearAllPools(); + throw; + } + + _loop = Task.Factory.StartNew(RunWriterAsync, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + public string DataDirectory { get; } + + public string RawDirectory { get; } + + /// 在单写入者上执行一个写操作(一个事务)。 在写入线程上同步执行。 + public async ValueTask WriteAsync(Func work, CancellationToken ct) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _writes.Writer.WriteAsync(new WriteJob(c => work(c), completion), ct); + return (T)(await completion.Task.WaitAsync(ct))!; + } + + /// 没有返回值的写操作(表达式体 lambda 的返回值会被丢弃,避免重载解析到带返回值的版本)。 + public async ValueTask ExecuteAsync(Action work, CancellationToken ct) => + await WriteAsync(c => + { + work(c); + return null; + }, ct); + + public ValueTask WriteAsync(Action work, CancellationToken ct) => ExecuteAsync(work, ct); + + /// 只读操作:每次一个池化的只读连接。 + public T Read(Func work) + { + using var connection = new SqliteConnection(_readConnectionString); + connection.Open(); + Pragma(connection, "busy_timeout=5000"); + return work(connection); + } + + public ValueTask ReadAsync(Func work, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Read(work)); + } + + public async ValueTask DisposeAsync() + { + _writes.Writer.TryComplete(); + await _loop; + SqliteConnection.ClearAllPools(); + await _writer.DisposeAsync(); + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + private static void Pragma(SqliteConnection connection, string pragma) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "PRAGMA " + pragma; + command.ExecuteNonQuery(); + } + + private async Task RunWriterAsync() + { + await foreach (WriteJob job in _writes.Reader.ReadAllAsync()) + { + SqliteTransaction? transaction = null; + try + { + transaction = _writer.BeginTransaction(); + object? result = job.Work(_writer); + transaction.Commit(); + job.Completion.SetResult(result); + } + catch (Exception ex) + { + // 事务里的任何异常都回滚并交给调用方;写入循环本身必须继续运行 + TryRollback(transaction); + job.Completion.SetException(ex); + } + finally + { + transaction?.Dispose(); + } + } + } + + private static void TryRollback(SqliteTransaction? transaction) + { + try + { + transaction?.Rollback(); + } + catch (InvalidOperationException) + { + // 事务已经结束 + } + catch (SqliteException) + { + // 连接状态异常时放弃回滚,SQLite 会在连接关闭时回滚 + } + } + + private sealed record WriteJob(Func Work, TaskCompletionSource Completion); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteHostStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteHostStore.cs new file mode 100644 index 0000000..06d9c89 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteHostStore.cs @@ -0,0 +1,48 @@ +using Fleet.Core; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Storage.Sqlite; + +public sealed class SqliteHostStore : IHostStore +{ + private readonly SqliteDatabase _db; + + public SqliteHostStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask SaveAsync(NodeId agent, long minuteMs, HostSnapshot snapshot, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO host_samples (agent_id, at, cpu, mem_used, mem_total, disk_used, disk_total, net_rx_bps, net_tx_bps, uptime) " + + "VALUES (@a, @at, @cpu, @mu, @mt, @du, @dt, @rx, @tx, @up) ON CONFLICT(agent_id, at) DO UPDATE SET " + + "cpu = excluded.cpu, mem_used = excluded.mem_used, mem_total = excluded.mem_total, disk_used = excluded.disk_used, disk_total = excluded.disk_total, " + + "net_rx_bps = excluded.net_rx_bps, net_tx_bps = excluded.net_tx_bps, uptime = excluded.uptime", + ("@a", agent.Value), ("@at", minuteMs), ("@cpu", snapshot.CpuPercent), ("@mu", snapshot.MemUsed), ("@mt", snapshot.MemTotal), ("@du", snapshot.DiskUsed), ("@dt", snapshot.DiskTotal), + ("@rx", snapshot.NetRxBps), ("@tx", snapshot.NetTxBps), ("@up", snapshot.UptimeSeconds)), + ct); + + public ValueTask> ReadAsync(NodeId agent, long fromMs, long toMs, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT at, cpu, mem_used, mem_total, disk_used, disk_total, net_rx_bps, net_tx_bps, uptime FROM host_samples WHERE agent_id = @a AND at >= @from AND at < @to ORDER BY at", + Read, ("@a", agent.Value), ("@from", fromMs), ("@to", toMs)), + ct); + + public ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM host_samples WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct); + + private static HostSample Read(SqliteDataReader r) => new( + r.GetInt64(0), + r.IsDBNull(1) ? null : r.GetDouble(1), + r.NullableLong(2), + r.NullableLong(3), + r.NullableLong(4), + r.NullableLong(5), + r.NullableLong(6), + r.NullableLong(7), + r.NullableLong(8)); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteMetricStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteMetricStore.cs new file mode 100644 index 0000000..b2cd990 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteMetricStore.cs @@ -0,0 +1,215 @@ +using Fleet.Core; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; + +namespace Monitor.Backend.Storage.Sqlite; + +/// 指标窗口的通用存储:每个桶一行,内容是 指标 id → 累加器状态(对本层不透明,只在合并和补丁时解码)。 +public sealed class SqliteMetricStore : IMetricStore +{ + private readonly SqliteDatabase _db; + + public SqliteMetricStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask UpsertAsync(MetricLevel level, IReadOnlyList rows, CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach (MetricRow row in rows) + { + Upsert(c, level, row, row.Metrics); + } + }, ct); + + public ValueTask MergeAsync(MetricLevel level, IReadOnlyList rows, CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach (MetricRow row in rows) + { + byte[]? existing = Load(c, level, row); + if (existing is null || !AccumulatorCodec.Decode(existing).TryGet(out MetricSet? merged, out _)) + { + Upsert(c, level, row, row.Metrics); + continue; + } + + if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? incoming, out _)) + { + merged.Merge(incoming); + } + + Upsert(c, level, row with { Partial = row.Partial || IsPartial(c, level, row) }, AccumulatorCodec.Encode(merged)); + } + }, ct); + + public ValueTask PatchAsync(MetricLevel level, IReadOnlyList rows, IReadOnlySet replaceMetricIds, CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach (MetricRow row in rows) + { + byte[]? existing = Load(c, level, row); + MetricSet target = new(); + if (existing is not null && AccumulatorCodec.Decode(existing).TryGet(out MetricSet? current, out _)) + { + foreach ((string id, Accumulator acc) in current.Items) + { + if (!replaceMetricIds.Contains(id)) + { + target.Set(id, acc); + } + } + } + + if (AccumulatorCodec.Decode(row.Metrics).TryGet(out MetricSet? fresh, out _)) + { + foreach ((string id, Accumulator acc) in fresh.Items) + { + if (replaceMetricIds.Contains(id)) + { + target.Set(id, acc); + } + } + } + + Upsert(c, level, row, AccumulatorCodec.Encode(target)); + } + }, ct); + + public ValueTask> ReadAsync(MetricLevel level, NodeId agentId, string series, long fromMs, long toMs, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + $"SELECT agent_id, series, window_start, partial, metrics FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start >= @from AND window_start < @to ORDER BY window_start", + ReadRow, ("@a", agentId.Value), ("@s", series), ("@from", fromMs), ("@to", toMs)), + ct); + + public ValueTask EarliestAsync(MetricLevel level, NodeId agentId, string series, CancellationToken ct) => + _db.ReadAsync( + c => c.Query($"SELECT MIN(window_start) FROM {level.TableName()} WHERE agent_id = @a AND series = @s", r => r.NullableLong(0), ("@a", agentId.Value), ("@s", series)).FirstOrDefault(), + ct); + + public ValueTask> ReadAgentsAsync(MetricLevel level, IReadOnlyCollection agentIds, string series, long fromMs, long toMs, CancellationToken ct) => + _db.ReadAsync>( + c => + { + if (agentIds.Count == 0) + { + return []; + } + + // 代理 ID 经参数传入:占位符是固定格式的 @a0、@a1…,不拼接任何外部值 + var parameters = new List<(string, object?)> { ("@s", series), ("@from", fromMs), ("@to", toMs) }; + var placeholders = new List(); + int i = 0; + foreach (NodeId agent in agentIds) + { + placeholders.Add("@a" + i); + parameters.Add(("@a" + i, agent.Value)); + i++; + } + + return c.Query( + $"SELECT agent_id, series, window_start, partial, metrics FROM {level.TableName()} WHERE series = @s AND window_start >= @from AND window_start < @to " + + $"AND agent_id IN ({string.Join(',', placeholders)}) ORDER BY agent_id, window_start", + ReadRow, [.. parameters]); + }, + ct); + + public ValueTask DeleteOlderThanAsync(MetricLevel level, long cutoffMs, CancellationToken ct) => + level == MetricLevel.H1 + ? ValueTask.FromResult(0) + : _db.WriteAsync(c => c.Exec($"DELETE FROM {level.TableName()} WHERE window_start < @cutoff", ("@cutoff", cutoffMs)), ct); + + public ValueTask EnqueueAsync(IReadOnlyList items, CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach (QueueItem item in items) + { + c.Exec( + "INSERT INTO analysis_queue (agent_id, series, window_start, not_before, reason, seq) VALUES (@a, @s, @w, @n, @r, 1) " + + "ON CONFLICT(agent_id, series, window_start) DO UPDATE SET not_before = MIN(not_before, excluded.not_before), reason = excluded.reason, seq = seq + 1", + ("@a", item.AgentId.Value), ("@s", item.Series), ("@w", item.WindowStart), ("@n", item.NotBefore), ("@r", item.Reason)); + } + }, ct); + + public ValueTask> DequeueDueAsync(long nowMs, int limit, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT agent_id, series, window_start, not_before, reason, seq FROM analysis_queue WHERE not_before <= @now ORDER BY not_before LIMIT @limit", + r => new QueueItem(new NodeId(r.GetString(0)), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(4), r.GetInt64(5)), + ("@now", nowMs), ("@limit", limit)), + ct); + + public ValueTask CompleteAsync(IReadOnlyList items, CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach (QueueItem item in items) + { + // 处理期间又有迟到的块入队时序号已经变了,这一项会保留到下一轮 + c.Exec( + "DELETE FROM analysis_queue WHERE agent_id = @a AND series = @s AND window_start = @w AND seq = @seq", + ("@a", item.AgentId.Value), ("@s", item.Series), ("@w", item.WindowStart), ("@seq", item.Seq)); + } + }, ct); + + public ValueTask QueueLengthAsync(CancellationToken ct) => + _db.ReadAsync(c => (int)c.Scalar("SELECT COUNT(*) FROM analysis_queue"), ct); + + public ValueTask MarkRollupDirtyAsync(MetricLevel level, NodeId agentId, string series, long bucketStart, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT OR IGNORE INTO rollup_dirty (level, agent_id, series, bucket_start) VALUES (@l, @a, @s, @b)", + ("@l", (int)level), ("@a", agentId.Value), ("@s", series), ("@b", bucketStart)), + ct); + + public ValueTask> TakeRollupDirtyAsync(int limit, CancellationToken ct) => + _db.WriteAsync>(c => + { + // 先低层后高层:5 分钟层先于 1 小时层,保证 1 小时层读到的是最新的 5 分钟层 + List<(MetricLevel, NodeId, string, long)> taken = c.Query( + "SELECT level, agent_id, series, bucket_start FROM rollup_dirty ORDER BY level, bucket_start LIMIT @limit", + r => ((MetricLevel)r.GetInt32(0), new NodeId(r.GetString(1)), r.GetString(2), r.GetInt64(3)), + ("@limit", limit)); + foreach ((MetricLevel level, NodeId agent, string series, long bucket) in taken) + { + c.Exec( + "DELETE FROM rollup_dirty WHERE level = @l AND agent_id = @a AND series = @s AND bucket_start = @b", + ("@l", (int)level), ("@a", agent.Value), ("@s", series), ("@b", bucket)); + } + + return taken; + }, ct); + + public ValueTask> GetVersionsAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query("SELECT metric_id, fingerprint, recompute_from, recompute_cursor FROM metric_versions", r => new MetricVersionRecord(r.GetString(0), r.GetString(1), r.NullableLong(2), r.NullableLong(3))), + ct); + + public ValueTask SetVersionAsync(MetricVersionRecord version, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO metric_versions (metric_id, fingerprint, recompute_from, recompute_cursor) VALUES (@id, @f, @from, @cur) " + + "ON CONFLICT(metric_id) DO UPDATE SET fingerprint = excluded.fingerprint, recompute_from = excluded.recompute_from, recompute_cursor = excluded.recompute_cursor", + ("@id", version.MetricId), ("@f", version.Fingerprint), ("@from", version.RecomputeFrom), ("@cur", version.RecomputeCursor)), + ct); + + private static byte[]? Load(SqliteConnection c, MetricLevel level, MetricRow row) => + c.Scalar( + $"SELECT metrics FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start = @w", + ("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart)); + + private static bool IsPartial(SqliteConnection c, MetricLevel level, MetricRow row) => + c.Scalar( + $"SELECT partial FROM {level.TableName()} WHERE agent_id = @a AND series = @s AND window_start = @w", + ("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart)) != 0; + + private static void Upsert(SqliteConnection c, MetricLevel level, MetricRow row, byte[] metrics) => + c.Exec( + $"INSERT INTO {level.TableName()} (agent_id, series, window_start, partial, metrics) VALUES (@a, @s, @w, @p, @m) " + + "ON CONFLICT(agent_id, series, window_start) DO UPDATE SET partial = excluded.partial, metrics = excluded.metrics", + ("@a", row.AgentId.Value), ("@s", row.Series), ("@w", row.WindowStart), ("@p", row.Partial ? 1 : 0), ("@m", metrics)); + + private static MetricRow ReadRow(SqliteDataReader r) => new(new NodeId(r.GetString(0)), r.GetString(1), r.GetInt64(2), r.GetInt64(3) != 0, r.Bytes(4)); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteRawStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteRawStore.cs new file mode 100644 index 0000000..9787003 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteRawStore.cs @@ -0,0 +1,238 @@ +using System.Text; +using System.Text.RegularExpressions; +using Fleet.Core; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Storage.Sqlite; + +/// +/// 原始块的存储:块原样追加写入段文件(块本身带 crc 并且自描述),SQLite 只记录 (segment, offset, length)。 +/// 每 10 秒一块也不会产生海量小文件。写入顺序:追加写入 → 刷盘 → 写索引 → 由调用方确认。 +/// +public sealed partial class SqliteRawStore : IRawStore +{ + private const string MetaColumns = + "c.chunk_id, c.agent_id, c.series, c.kind, c.task_id, c.test_id, c.phase, c.start_ts, c.duration_ms, c.partial, s.path, c.offset, c.length"; + + private readonly SqliteDatabase _db; + + public SqliteRawStore(SqliteDatabase db) + { + _db = db; + } + + public long TotalBytes => _db.Read(c => c.Scalar("SELECT COALESCE(SUM(bytes), 0) FROM raw_segments")); + + public ValueTask AppendAsync(RawChunkAppend chunk, CancellationToken ct) + { + if (!DayPattern().IsMatch(chunk.Day)) + { + throw new ArgumentException("day must be yyyy-MM-dd", nameof(chunk)); + } + + return _db.WriteAsync(c => + { + RawChunkMeta meta = chunk.Meta; + if (c.Scalar("SELECT COUNT(*) FROM raw_chunks WHERE chunk_id = @id", ("@id", meta.ChunkId.ToString())) > 0) + { + return false; + } + + string relative = RelativePath(meta, chunk.Day); + string full = FullPath(relative); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + long offset; + using (var file = new FileStream(full, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read)) + { + offset = file.Seek(0, SeekOrigin.End); + file.Write(chunk.Bytes.Span); + file.Flush(flushToDisk: true); + } + + c.Exec( + "INSERT INTO raw_segments (day, path, bytes) VALUES (@day, @path, @bytes) ON CONFLICT(path) DO UPDATE SET bytes = bytes + excluded.bytes", + ("@day", chunk.Day), ("@path", relative), ("@bytes", chunk.Bytes.Length)); + long segmentId = c.Scalar("SELECT id FROM raw_segments WHERE path = @path", ("@path", relative)); + c.Exec( + "INSERT INTO raw_chunks (chunk_id, segment_id, offset, length, agent_id, series, kind, task_id, test_id, phase, start_ts, duration_ms, partial) " + + "VALUES (@id, @seg, @off, @len, @agent, @series, @kind, @task, @test, @phase, @start, @dur, @partial)", + ("@id", meta.ChunkId.ToString()), ("@seg", segmentId), ("@off", offset), ("@len", chunk.Bytes.Length), ("@agent", meta.AgentId.Value), + ("@series", meta.Series), ("@kind", meta.Kind), ("@task", meta.TaskId?.Value), ("@test", meta.TestId?.Value), ("@phase", meta.Phase), + ("@start", meta.StartTs), ("@dur", meta.DurationMs), ("@partial", meta.Partial ? 1 : 0)); + return true; + }, ct); + } + + public ValueTask GetMetaAsync(ChunkId id, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne($"SELECT {MetaColumns} FROM raw_chunks c JOIN raw_segments s ON s.id = c.segment_id WHERE c.chunk_id = @id", ReadMeta, ("@id", id.ToString())), + ct); + + public ValueTask> FindAsync(RawQuery query, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + $"SELECT {MetaColumns} FROM raw_chunks c JOIN raw_segments s ON s.id = c.segment_id " + + "WHERE (@agent IS NULL OR c.agent_id = @agent) AND (@series IS NULL OR c.series = @series) AND (@kind IS NULL OR c.kind = @kind) " + + "AND (@test IS NULL OR c.test_id = @test) AND (@from IS NULL OR c.start_ts >= @from) AND (@to IS NULL OR c.start_ts < @to) " + + "AND (@after IS NULL OR c.start_ts >= @after) ORDER BY c.start_ts, c.chunk_id LIMIT @limit", + ReadMeta, + ("@agent", query.AgentId?.Value), ("@series", query.Series), ("@kind", query.Kind), ("@test", query.TestId?.Value), + ("@from", query.FromMs), ("@to", query.ToMs), ("@after", query.AfterStartTs), ("@limit", Math.Clamp(query.Limit, 1, 5000))), + ct); + + public ValueTask ReadAsync(RawChunkMeta meta, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + string full = FullPath(meta.SegmentPath); + if (!File.Exists(full)) + { + return ValueTask.FromResult(null); + } + + using var file = new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (meta.Offset + meta.Length > file.Length) + { + return ValueTask.FromResult(null); + } + + file.Seek(meta.Offset, SeekOrigin.Begin); + byte[] buffer = new byte[meta.Length]; + file.ReadExactly(buffer); + return ValueTask.FromResult(buffer); + } + + public ValueTask> ListSeriesAsync(long fromMs, long toMs, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT DISTINCT agent_id, series FROM raw_chunks WHERE test_id IS NULL AND start_ts >= @from AND start_ts < @to ORDER BY agent_id, series", + r => (new NodeId(r.GetString(0)), r.GetString(1)), ("@from", fromMs), ("@to", toMs)), + ct); + + public async ValueTask DeleteExpiredAsync(DateOnly continuousBefore, DateOnly testsBefore, CancellationToken ct) + { + List files = await _db.WriteAsync(c => + { + var paths = new List(); + var ids = new List(); + foreach ((long id, string path) in c.Query( + "SELECT id, path FROM raw_segments WHERE (path NOT LIKE '%/tests/%' AND day < @cont) OR (path LIKE '%/tests/%' AND day < @tests)", + r => (r.GetInt64(0), r.GetString(1)), + ("@cont", continuousBefore.ToString("yyyy-MM-dd")), ("@tests", testsBefore.ToString("yyyy-MM-dd")))) + { + ids.Add(id); + paths.Add(path); + } + + foreach (long id in ids) + { + c.Exec("DELETE FROM raw_chunks WHERE segment_id = @id", ("@id", id)); + c.Exec("DELETE FROM raw_segments WHERE id = @id", ("@id", id)); + } + + return paths; + }, ct); + + foreach (string relative in files) + { + string full = FullPath(relative); + if (File.Exists(full)) + { + File.Delete(full); + } + + DeleteEmptyParents(Path.GetDirectoryName(full)!); + } + + return files.Count; + } + + public ValueTask RecoverAsync(CancellationToken ct) => + _db.ExecuteAsync(c => + { + foreach ((string path, long end) in c.Query( + "SELECT s.path, COALESCE(MAX(c.offset + c.length), 0) FROM raw_segments s LEFT JOIN raw_chunks c ON c.segment_id = s.id GROUP BY s.id", + r => (r.GetString(0), r.GetInt64(1)))) + { + string full = FullPath(path); + if (!File.Exists(full)) + { + continue; + } + + using var file = new FileStream(full, FileMode.Open, FileAccess.Write, FileShare.Read); + if (file.Length > end) + { + // 追加之后、写索引之前崩溃留下的尾部:没有索引记录,截断 + file.SetLength(end); + } + } + }, ct); + + private string FullPath(string relative) + { + string full = Path.GetFullPath(Path.Combine(_db.RawDirectory, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!full.StartsWith(_db.RawDirectory, StringComparison.Ordinal)) + { + throw new InvalidOperationException("segment path escapes the raw directory"); + } + + return full; + } + + private void DeleteEmptyParents(string directory) + { + while (directory.StartsWith(_db.RawDirectory, StringComparison.Ordinal) && directory.Length > _db.RawDirectory.Length + && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory)!; + } + } + + /// 目录布局:{day}/{agent}/{kind}.seg;目标任务多一个任务前缀;按需测试在 {day}/tests/{test}/phase{n}.seg。 + private static string RelativePath(RawChunkMeta meta, string day) + { + if (meta.TestId is { } test) + { + return $"{day}/tests/{Safe(test.Value)}/phase{meta.Phase ?? 0}.seg"; + } + + string agent = Safe(meta.AgentId.Value); + string kind = Safe(meta.Kind); + return meta.Series.StartsWith("target:", StringComparison.Ordinal) + ? $"{day}/{agent}/{Safe(meta.TaskId?.Value ?? "task")}_{kind}.seg" + : $"{day}/{agent}/{kind}.seg"; + } + + /// 路径片段只允许字母、数字、下划线和连字符,防止路径穿越。 + private static string Safe(string value) + { + var builder = new StringBuilder(Math.Min(value.Length, 80)); + foreach (char ch in value.Take(80)) + { + builder.Append(char.IsAsciiLetterOrDigit(ch) || ch is '_' or '-' ? ch : '_'); + } + + return builder.Length == 0 ? "_" : builder.ToString(); + } + + private static RawChunkMeta ReadMeta(SqliteDataReader r) => new( + new ChunkId(Guid.Parse(r.GetString(0))), + new NodeId(r.GetString(1)), + r.GetString(2), + r.GetString(3), + r.NullableString(4) is { } task ? new TaskId(task) : null, + r.NullableString(5) is { } test ? new TestId(test) : null, + r.IsDBNull(6) ? null : r.GetInt32(6), + r.GetInt64(7), + r.GetInt32(8), + r.GetInt64(9) != 0, + r.GetString(10), + r.GetInt64(11), + r.GetInt32(12)); + + [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")] + private static partial Regex DayPattern(); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteStorageHealth.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteStorageHealth.cs new file mode 100644 index 0000000..67dc4c7 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteStorageHealth.cs @@ -0,0 +1,47 @@ +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; + +namespace Monitor.Backend.Storage.Sqlite; + +public sealed class SqliteStorageHealth : IStorageHealth +{ + private readonly SqliteDatabase _db; + + public SqliteStorageHealth(SqliteDatabase db) + { + _db = db; + } + + public async ValueTask CheckAsync(CancellationToken ct) + { + bool database; + try + { + // 走单写入者:写得进去才算健康 + database = await _db.WriteAsync(c => c.Scalar("SELECT 1") == 1, ct); + } + catch (SqliteException) + { + database = false; + } + + bool raw; + try + { + string probe = Path.Combine(_db.RawDirectory, ".healthz"); + await File.WriteAllTextAsync(probe, "ok", ct); + File.Delete(probe); + raw = true; + } + catch (IOException) + { + raw = false; + } + catch (UnauthorizedAccessException) + { + raw = false; + } + + return new StorageHealth(database, raw); + } +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/SqliteTestStore.cs b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteTestStore.cs new file mode 100644 index 0000000..fb59d69 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/SqliteTestStore.cs @@ -0,0 +1,92 @@ +using Fleet.Core; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Storage.Sqlite; + +public sealed class SqliteTestStore : ITestStore +{ + private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id"; + + private readonly SqliteDatabase _db; + + public SqliteTestStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask CreateAsync(TestRecord test, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at)", + ("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@probe", test.ProbeId.Value), ("@plan", test.Plan), ("@params", test.ParamsJson), ("@trigger", test.Trigger), + ("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs)), + ct); + + public ValueTask GetAsync(TestId id, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM tests WHERE id = @id", Read, ("@id", id.Value)), ct); + + public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + $"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) ORDER BY requested_at DESC, id DESC LIMIT @limit", + Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit)), + ct); + + public ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "UPDATE tests SET status = @status, phase = COALESCE(@phase, phase), error_code = COALESCE(@err, error_code), " + + "started_at = CASE WHEN @status = 'running' AND started_at IS NULL THEN @now ELSE started_at END, " + + "finished_at = CASE WHEN @status IN ('succeeded', 'failed', 'aborted') THEN @now ELSE finished_at END WHERE id = @id", + ("@id", id.Value), ("@status", status), ("@phase", phase), ("@err", errorCode), ("@now", nowMs)) == 1, + ct); + + public ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE tests SET phase_count = @n WHERE id = @id", ("@id", id.Value), ("@n", phaseCount)), ct); + + public ValueTask HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct) => + _db.ReadAsync(c => c.Scalar("SELECT COUNT(*) FROM tests WHERE agent_id = @a AND probe_id = @p AND status IN ('queued', 'running')", ("@a", agentId.Value), ("@p", probeId.Value)) > 0, ct); + + public ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO test_results (test_id, metrics, series) VALUES (@id, @m, @s) ON CONFLICT(test_id) DO UPDATE SET metrics = excluded.metrics, series = excluded.series", + ("@id", result.TestId.Value), ("@m", result.MetricsJson), ("@s", result.SeriesJson)), + ct); + + public ValueTask GetResultAsync(TestId id, CancellationToken ct) => + _db.ReadAsync( + c => c.QueryOne("SELECT test_id, metrics, series FROM test_results WHERE test_id = @id", r => new TestResultRecord(new TestId(r.GetString(0)), r.Bytes(1), r.Bytes(2)), ("@id", id.Value)), + ct); + + public ValueTask> ListNeedingResultAsync(int limit, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + $"SELECT {Columns} FROM tests WHERE status = 'succeeded' AND id NOT IN (SELECT test_id FROM test_results) ORDER BY finished_at LIMIT @limit", + Read, ("@limit", limit)), + ct); + + public ValueTask FailStaleAsync(long olderThanMs, string errorCode, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "UPDATE tests SET status = 'failed', error_code = @err, finished_at = @now WHERE status IN ('queued', 'running') AND requested_at < @cutoff", + ("@err", errorCode), ("@now", nowMs), ("@cutoff", olderThanMs)), + ct); + + private static TestRecord Read(SqliteDataReader r) => new( + new TestId(r.GetString(0)), + new NodeId(r.GetString(1)), + new ProbeId(r.GetString(12)), + r.GetString(2), + r.GetString(3), + r.GetString(4), + r.GetString(5), + r.NullableString(6), + r.IsDBNull(7) ? null : r.GetInt32(7), + r.GetInt32(8), + r.GetInt64(9), + r.NullableLong(10), + r.NullableLong(11)); +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/StorageJson.cs b/backend/src/Monitor.Backend.Storage.Sqlite/StorageJson.cs new file mode 100644 index 0000000..3259e7a --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/StorageJson.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Monitor.Backend.Storage.Sqlite; + +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")] +internal sealed partial class StorageJson : JsonSerializerContext +{ + public static string ToJson(IEnumerable values) => JsonSerializer.Serialize([.. values], Default.ListString); + + public static string ToJson(IEnumerable values) => JsonSerializer.Serialize([.. values], Default.ListInt32); + + public static List Strings(string? json) => + string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? []; + + public static List Ints(string? json) => + string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? []; +} diff --git a/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs new file mode 100644 index 0000000..889ae14 --- /dev/null +++ b/backend/src/Monitor.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Fleet.Server; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; + +namespace Monitor.Backend.Storage.Sqlite; + +public static class StorageServiceCollectionExtensions +{ + /// 注册 SQLite + 段文件存储:实现 Application 的全部存储端口,同时实现 Fleet 的 。 + public static IServiceCollection AddMonitorSqlite(this IServiceCollection services, string dataDirectory) + { + services.AddSingleton(_ => new SqliteDatabase(dataDirectory)); + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/backend/src/Monitor.Backend/BackendHost.cs b/backend/src/Monitor.Backend/BackendHost.cs new file mode 100644 index 0000000..e7aff5d --- /dev/null +++ b/backend/src/Monitor.Backend/BackendHost.cs @@ -0,0 +1,55 @@ +using Fleet.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Monitor.Backend.Api; +using Monitor.Backend.Application; +using Monitor.Backend.Gateway; +using Monitor.Backend.Storage.Sqlite; + +namespace Monitor.Backend; + +/// +/// 组合根:在这里显式注册所有层。配置来自 appsettings.json、环境变量(Monitor__DataDirectory 等)和命令行。 +/// 测试用 在真实端口上启动整个后端。 +/// +public static class BackendHost +{ + public const string ConfigurationSection = "Monitor"; + public const int MaxRequestBodyBytes = 64 * 1024; + + public static WebApplication Build(string[] args, Action? configureOptions = null, TimeProvider? time = null) + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); + + var options = new BackendOptions(); + builder.Configuration.GetSection(ConfigurationSection).Bind(options); + configureOptions?.Invoke(options); + + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Limits.MaxRequestBodySize = MaxRequestBodyBytes); + builder.Logging.AddSimpleConsole(console => + { + console.SingleLine = true; + console.UseUtcTimestamp = true; + console.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; + }); + + IServiceCollection services = builder.Services; + services.AddSingleton(time ?? TimeProvider.System); + services.AddMonitorSqlite(options.DataDirectory); + services.AddMonitorApplication(options); + services.AddMonitorGateway(); + services.AddMonitorApi(); + + WebApplication app = builder.Build(); + // 静默死亡的 Agent(断电、断网)不会关闭连接:15 秒一个 Ping,20 秒没有 Pong 就关闭,这样面板上的在线状态才可信 + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(15), KeepAliveTimeout = TimeSpan.FromSeconds(20) }); + app.UseMonitorCors(options.CorsOrigins); + app.UseMonitorApi(); + app.MapMonitorApi(); + app.MapFleetEnrollment("/api/agent/v1/enroll"); + app.MapFleetLink("/api/agent/v1/rpc"); + return app; + } +} diff --git a/backend/src/Monitor.Backend/Monitor.Backend.csproj b/backend/src/Monitor.Backend/Monitor.Backend.csproj new file mode 100644 index 0000000..4680b73 --- /dev/null +++ b/backend/src/Monitor.Backend/Monitor.Backend.csproj @@ -0,0 +1,19 @@ + + + Exe + monitor-backend + Monitor.Backend + true + true + true + true + $(MSBuildProjectDirectory)/../../openapi + --document-name v1 --file-name v1 + + + + + + + + diff --git a/backend/src/Monitor.Backend/Program.cs b/backend/src/Monitor.Backend/Program.cs new file mode 100644 index 0000000..2a3a592 --- /dev/null +++ b/backend/src/Monitor.Backend/Program.cs @@ -0,0 +1,3 @@ +using Monitor.Backend; + +await BackendHost.Build(args).RunAsync(); diff --git a/backend/tests/Monitor.Backend.Analysis.Tests/AccumulatorTests.cs b/backend/tests/Monitor.Backend.Analysis.Tests/AccumulatorTests.cs new file mode 100644 index 0000000..eefd44a --- /dev/null +++ b/backend/tests/Monitor.Backend.Analysis.Tests/AccumulatorTests.cs @@ -0,0 +1,162 @@ +using Monitor.Backend.Analysis; + +namespace Monitor.Backend.Analysis.Tests; + +public sealed class AccumulatorTests +{ + [Theory] + [InlineData(0.5)] + [InlineData(0.9)] + [InlineData(0.95)] + [InlineData(0.99)] + public void Histogram_Quantile_WithinRelativeError(double q) + { + var rng = new Random(42); + var values = new List(); + var hist = new HistogramAcc(); + for (int i = 0; i < 20_000; i++) + { + // 对数正态分布:中位数约 50 ms,长尾 + double v = 50 * Math.Exp(rng.NextDouble() * 2.5 - 1.2); + values.Add(v); + hist.Add(v); + } + + values.Sort(); + double exact = values[(int)(q * (values.Count - 1))]; + double estimate = hist.Quantile(q)!.Value; + Assert.InRange(Math.Abs(estimate - exact) / exact, 0, 0.045); + } + + [Fact] + public void Histogram_Merge_EqualsSingleAccumulator() + { + var rng = new Random(7); + var whole = new HistogramAcc(); + var left = new HistogramAcc(); + var right = new HistogramAcc(); + for (int i = 0; i < 5000; i++) + { + double v = rng.NextDouble() * 200; + whole.Add(v); + (i % 2 == 0 ? left : right).Add(v); + } + + left.Merge(right); + Assert.Equal(whole.Count, left.Count); + Assert.Equal(whole.Quantile(0.95), left.Quantile(0.95)); + Assert.Equal(whole.Quantile(0.5), left.Quantile(0.5)); + Assert.Equal(whole.Max, left.Max); + Assert.Equal(whole.NonEmptyBuckets, left.NonEmptyBuckets); + } + + [Fact] + public void Histogram_Empty_HasNoQuantiles() + { + var hist = new HistogramAcc(); + Assert.Null(hist.Quantile(0.5)); + Assert.Null(hist.Mean); + } + + [Fact] + public void Histogram_SingleSample_QuantilesClampToSample() + { + var hist = new HistogramAcc(); + hist.Add(42); + Assert.Equal(42, hist.Quantile(0.01)); + Assert.Equal(42, hist.Quantile(0.99)); + } + + [Fact] + public void Histogram_IgnoresNegativeAndNaN() + { + var hist = new HistogramAcc(); + hist.Add(-1); + hist.Add(double.NaN); + Assert.Equal(0UL, hist.Count); + } + + [Fact] + public void Histogram_ValuesBeyondRange_LandInEdgeBuckets() + { + var hist = new HistogramAcc(); + hist.Add(0.01); + hist.Add(1_000_000); + Assert.Equal(2UL, hist.Count); + Assert.Equal(2, hist.NonEmptyBuckets); + } + + [Fact] + public void Ratio_Merge_SumsNumeratorAndDenominator() + { + var a = new RatioAcc(); + a.Add(1, 10); + var b = new RatioAcc(); + b.Add(3, 10); + a.Merge(b); + Assert.Equal(20.0, a.Percent); + Assert.Null(new RatioAcc().Percent); + } + + [Fact] + public void Merge_DifferentKinds_Throws() + { + Assert.Throws(() => new CounterAcc().Merge(new RatioAcc())); + } + + [Fact] + public void Codec_RoundTrip_PreservesEveryKind() + { + var set = new MetricSet(); + set.GetOrAdd("c", AccKind.Counter).Add(5); + set.GetOrAdd("r", AccKind.Ratio).Add(2, 9); + set.GetOrAdd("s", AccKind.Sum).Add(2.5); + set.GetOrAdd("n", AccKind.Min).Add(-3); + set.GetOrAdd("x", AccKind.Max).Add(11); + set.GetOrAdd("l", AccKind.RunLength).Add(4); + var hist = (HistogramAcc)set.GetOrAdd("h", AccKind.Histogram); + foreach (double v in new[] { 1.0, 20, 20, 300 }) + { + hist.Add(v); + } + + set.GetOrAdd("t", AccKind.Series).Add(2, 7.5); + + MetricSet decoded = AccumulatorCodec.Decode(AccumulatorCodec.Encode(set)).Value; + + Assert.Equal(5UL, ((CounterAcc)decoded.Items["c"]).Value); + Assert.Equal(9UL, ((RatioAcc)decoded.Items["r"]).Denominator); + Assert.Equal(2.5, ((SumAcc)decoded.Items["s"]).Sum); + Assert.Equal(-3, ((MinAcc)decoded.Items["n"]).Value); + Assert.Equal(11, ((MaxAcc)decoded.Items["x"]).Value); + Assert.Equal(4, ((RunLengthAcc)decoded.Items["l"]).Max); + var h2 = (HistogramAcc)decoded.Items["h"]; + Assert.Equal(hist.Count, h2.Count); + Assert.Equal(hist.Quantile(0.5), h2.Quantile(0.5)); + Assert.Equal(7.5, ((SeriesAcc)decoded.Items["t"]).Points[2]); + } + + [Fact] + public void Codec_Decode_RejectsTruncatedData() + { + var set = new MetricSet(); + set.GetOrAdd("h", AccKind.Histogram).Add(10); + byte[] bytes = AccumulatorCodec.Encode(set); + Assert.False(AccumulatorCodec.Decode(bytes.AsSpan(0, bytes.Length - 3)).IsSuccess); + Assert.False(AccumulatorCodec.Decode([200, 200]).IsSuccess); + } + + [Fact] + public void MetricSet_Merge_CombinesDisjointAndSharedMetrics() + { + var a = new MetricSet(); + a.GetOrAdd("x", AccKind.Counter).Add(1); + var b = new MetricSet(); + b.GetOrAdd("x", AccKind.Counter).Add(2); + b.GetOrAdd("y", AccKind.Counter).Add(4); + a.Merge(b); + Assert.Equal(3UL, ((CounterAcc)a.Items["x"]).Value); + Assert.Equal(4UL, ((CounterAcc)a.Items["y"]).Value); + Assert.Equal(2UL, ((CounterAcc)b.Items["x"]).Value); + } +} diff --git a/backend/tests/Monitor.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs b/backend/tests/Monitor.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs new file mode 100644 index 0000000..535351e --- /dev/null +++ b/backend/tests/Monitor.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs @@ -0,0 +1,166 @@ +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Analysis.Tests; + +public sealed class ModelAndTestEvaluatorTests +{ + private const long T0 = 1_700_000_000_000; + private static readonly NodeId s_agent = new("agt_test"); + + private static MetricView View(params (string Id, double Value)[] values) => + new(values.ToDictionary(v => v.Id, v => v.Value)); + + [Fact] + public void Registry_ExportIds_AreUnique_AndPresetsResolve() + { + Assert.Equal(MetricRegistry.AllExports().Count(), MetricRegistry.AllExports().Select(e => e.Export.Id).Distinct().Count()); + foreach (PresetDef preset in MetricRegistry.Presets) + { + foreach (string id in preset.MetricIds) + { + Assert.True( + MetricRegistry.TryFindExport(id, out _, out _) || id.StartsWith("score.", StringComparison.Ordinal), + $"preset {preset.Id} references unknown metric {id}"); + } + } + + Assert.All(MetricRegistry.All, m => Assert.NotEmpty(m.Fingerprint)); + } + + [Fact] + public void Fingerprint_ChangesWhenTheOpChanges() + { + var a = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count)); + var b = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 200), "x", "x", new MetricExport("x", Stat.Count, Unit.Count)); + Assert.NotEqual(a.Fingerprint, b.Fingerprint); + } + + [Fact] + public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow() + { + double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value; + double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value; + Assert.True(good > 95); + Assert.True(bad < 10); + } + + [Fact] + public void Models_WithNoData_ReturnNullScore() + { + foreach (IServiceModel model in ModelRegistry.All) + { + Assert.Null(model.Score(View()).Score); + } + } + + [Fact] + public void McModel_WithoutTcpData_EstimatesFromUdp() + { + ModelScore score = new McModel().Score(View(("rtt.p95", 60), ("loss.total", 2), ("loss.run_max", 2))); + Assert.True(score.Estimated); + Assert.NotNull(score.Score); + Assert.Contains("estimated_from_udp", score.Caveats); + Assert.False(new McModel().Score(View(("tcp.rtt.p95", 60), ("tcp.stall.per_min", 0))).Estimated); + } + + [Fact] + public void Hy2Model_TestResultsDominate_AndNoTestIsEstimated() + { + ModelScore withTest = new Hy2Model().Score(View(("hy2.sustainable_mbps", 100), ("hy2.effective_mbps", 95), ("loss.total", 0.2))); + ModelScore without = new Hy2Model().Score(View(("loss.total", 0.2), ("rtt.p95", 50))); + Assert.False(withTest.Estimated); + Assert.True(without.Estimated); + Assert.Contains("no_load_test", without.Caveats); + Assert.True(withTest.Score > 90); + } + + [Fact] + public void Models_AreMonotonic_WorseMetricsNeverRaiseTheScore() + { + // 对每个模型的每个所需指标:在“好”和“差”之间扫描,分数不能随指标变差而升高。 + var ranges = new Dictionary + { + ["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30], + ["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60], + ["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000], + ["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30], + ["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0], + ["hy2.effective_mbps"] = [100, 50, 25, 10, 0], ["hy2.qos_onset_mbps"] = [100, 50, 25, 10], + }; + + foreach (IServiceModel model in ModelRegistry.All) + { + foreach (string need in model.Needs.Where(ranges.ContainsKey)) + { + var baseline = ranges.Where(r => model.Needs.Contains(r.Key)).ToDictionary(r => r.Key, r => r.Value[0]); + double previous = double.MaxValue; + foreach (double value in ranges[need]) + { + var values = new Dictionary(baseline) { [need] = value }; + double score = model.Score(new MetricView(values)).Score ?? 0; + Assert.True(score <= previous + 1e-9, $"{model.Id}: {need}={value} raised the score from {previous} to {score}"); + previous = score; + } + } + } + + } + + [Fact] + public void TestEvaluator_Hy2Ladder_APhaseSplitIntoSeveralChunksIsOneStep() + { + // 真实的 Probe 把一个阶段切成几块(一块最多 50000 条记录):阶梯里的一步是一个阶段,不是一个块 + RawChunk[] chunks = + [ + ChunkFactory.Train(s_agent, T0, 10, 0), + ChunkFactory.Train(s_agent, T0 + 10_000, 50, 1, seconds: 5, lossEvery: 5), + ChunkFactory.Train(s_agent, T0 + 15_000, 50, 1, seconds: 5, lossEvery: 5), + ]; + + TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks); + + Assert.Equal(2, result.Steps.Count); + Assert.Equal(new double[] { 10, 50 }, result.Series["step_mbps"]); + Assert.InRange(result.Steps[1].LossPct, 19, 21); + Assert.InRange(result.Steps[1].DeliveredMbps, 38, 42); // 50 Mbit/s × (1 − 20% 丢包),按整个阶段的时长算 + } + + [Fact] + public void TestEvaluator_Hy2Ladder_FindsSustainableRateAndOnset() + { + // 10/25 Mbps 无丢包;50 Mbps 起被限速,丢包 20%、40% + RawChunk[] chunks = + [ + ChunkFactory.Train(s_agent, T0, 10, 0), + ChunkFactory.Train(s_agent, T0 + 10_000, 25, 1), + ChunkFactory.Train(s_agent, T0 + 20_000, 50, 2, lossEvery: 5), + ChunkFactory.Train(s_agent, T0 + 30_000, 100, 3, lossEvery: 2), + ]; + + TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks); + + Assert.Equal(4, result.Steps.Count); + Assert.Equal(25, result.Metrics["hy2.sustainable_mbps"]); + Assert.Equal(50, result.Metrics["hy2.qos_onset_mbps"]); + Assert.InRange(result.Steps[2].LossPct, 19, 21); + Assert.InRange(result.Steps[3].LossPct, 49, 51); + Assert.InRange(result.Steps[0].DeliveredMbps, 9.5, 10.5); + Assert.Equal(new double[] { 10, 25, 50, 100 }, result.Series["step_mbps"]); + Assert.Contains("port_differs_from_service", result.Caveats); + + // 模型能直接消费测试结果 + ModelScore score = new Hy2Model().Score(new MetricView(result.Metrics)); + Assert.False(score.Estimated); + Assert.InRange(score.Score!.Value, 20, 80); + } + + [Fact] + public void TestEvaluator_PartialChunks_AddCaveat() + { + RawChunk chunk = ChunkFactory.Train(s_agent, T0, 10, 0); + chunk.Header.Partial = true; + Assert.Contains("partial_data", TestEvaluator.Evaluate("hy2_ladder", [chunk]).Caveats); + } +} diff --git a/backend/tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj b/backend/tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj new file mode 100644 index 0000000..6435f43 --- /dev/null +++ b/backend/tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj @@ -0,0 +1,15 @@ + + + Exe + false + $(NoWarn);CA1861 + + + + + + + + + + diff --git a/backend/tests/Monitor.Backend.Analysis.Tests/OpTests.cs b/backend/tests/Monitor.Backend.Analysis.Tests/OpTests.cs new file mode 100644 index 0000000..5fc1c18 --- /dev/null +++ b/backend/tests/Monitor.Backend.Analysis.Tests/OpTests.cs @@ -0,0 +1,254 @@ +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Analysis.Tests; + +public sealed class OpTests +{ + private const long T0 = 1_700_000_000_000; + private static readonly NodeId s_agent = new("agt_test"); + + private static MetricSet EvaluateOneBucket(RawChunk chunk, params string[] only) + { + var buckets = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, only.Length == 0 ? null : new HashSet(only)); + var merged = new MetricSet(); + foreach (MetricSet set in buckets.Values) + { + merged.Merge(set); + } + + return merged; + } + + [Fact] + public void Rtt_RemovesAgentProcessingTime() + { + RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, _ => new EchoPacket(false, false, 20_000, 30_000, ProcessUs: 5_000)); + var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "rtt").Items["rtt"]; + Assert.Equal(100UL, rtt.Count); + Assert.InRange(rtt.Quantile(0.5)!.Value, 50 * 0.955, 50 * 1.045); + } + + [Fact] + public void Loss_SeparatesUpAndDown() + { + RawChunk chunk = ChunkFactory.Echo( + s_agent, "udp_echo", T0, 100, 100, + i => new EchoPacket(LostUp: i < 10, LostDown: i is >= 10 and < 15, 20_000, 20_000)); + MetricSet set = EvaluateOneBucket(chunk, "loss.up", "loss.down", "loss.total"); + + Assert.Equal(10.0, ((RatioAcc)set.Items["loss.up"]).Percent); + // 下行只统计上行已到达的 90 个包里的 5 个 + Assert.Equal(100.0 * 5 / 90, ((RatioAcc)set.Items["loss.down"]).Percent!.Value, 6); + Assert.Equal(15.0, ((RatioAcc)set.Items["loss.total"]).Percent); + } + + [Fact] + public void Jitter_ConstantDelayWithClockOffset_IsZero() + { + RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0); + var jitter = (HistogramAcc)EvaluateOneBucket(chunk, "jitter.up", "jitter.down").Items["jitter.up"]; + Assert.True(jitter.Count > 0); + Assert.True(jitter.Quantile(0.95)!.Value < 0.2, "a constant delay must not look like jitter despite the 5 s clock offset"); + } + + [Fact] + public void Jitter_OnlyOneDirectionVaries_IsAttributedToThatDirection() + { + // 下行时延每隔一个包多 20 ms + RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, i => new EchoPacket(false, false, 20_000, 20_000 + (i % 2 == 0 ? 20_000 : 0))); + MetricSet set = EvaluateOneBucket(chunk, "jitter.up", "jitter.down"); + Assert.True(((HistogramAcc)set.Items["jitter.up"]).Quantile(0.95) < 0.5); + Assert.InRange(((HistogramAcc)set.Items["jitter.down"]).Quantile(0.95)!.Value, 15, 25); + } + + [Fact] + public void Jitter_TooFewSamples_ProducesNoObservations() + { + RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0, count: 5); + MetricSet set = EvaluateOneBucket(chunk, "jitter.up"); + Assert.False(set.Items.ContainsKey("jitter.up")); + } + + [Fact] + public void LossRuns_ReportsConsecutiveLosses() + { + bool[] lost = [false, true, true, true, false, true, false, false, true, true]; + RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, lost.Length, 100, i => new EchoPacket(false, lost[i], 20_000, 20_000)); + var runs = (RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]; + Assert.Equal(3UL, runs.Runs); + Assert.Equal(3, runs.Max); + Assert.Equal(2.0, runs.Mean); + } + + [Fact] + public void LossRuns_TrailingRunIsReported() + { + RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 4, 100, i => new EchoPacket(false, i >= 2, 20_000, 20_000)); + Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max); + } + + [Fact] + public void Reorder_CountsLateArrivals_AndIgnoresDuplicates() + { + // 包 3 的回复比包 4 晚到 + RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 6, 100, i => new EchoPacket(false, false, 20_000, i == 3 ? 150_000 : 20_000)); + MetricSet set = EvaluateOneBucket(chunk, "reorder", "dup"); + Assert.Equal(1UL, ((CounterAcc)set.Items["reorder"]).Value); + Assert.False(set.Items.ContainsKey("dup")); + } + + [Fact] + public void Stall_DetectsHeadOfLineBlocking() + { + // 第 40 个包之后的 10 个包被队头阻塞,全部在 2540 ms 之后几乎同时到达 + RawChunk chunk = ChunkFactory.Echo( + s_agent, "tcp_echo", T0, 100, 50, + i => new EchoPacket(false, false, 20_000, i is >= 40 and < 50 ? ((2540 + (i - 40) - (50 * i) - 20) * 1000) - 50 : 20_000)); + MetricSet set = EvaluateOneBucket(chunk, "tcp.stall", "tcp.stall_max"); + Assert.Equal(1UL, ((CounterAcc)set.Items["tcp.stall"]).Value); + Assert.InRange(((MaxAcc)set.Items["tcp.stall_max"]).Value, 500, 600); + } + + [Fact] + public void Stall_SteadyArrivals_AreNotStalls() + { + RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 20_000, 20_000)); + MetricSet set = EvaluateOneBucket(chunk, "tcp.stall"); + Assert.False(set.Items.ContainsKey("tcp.stall")); + } + + [Fact] + public void Slow_CountsEchoesBeyondTheChunkLocalLongTailThreshold() + { + // 基线 50 ms(单程 25 ms),阈值 = max(90, 50 + max(15, 17.5)) = 90 ms;第 10、20、30 个回显的 RTT 是 200 ms + RawChunk chunk = ChunkFactory.Echo( + s_agent, "tcp_echo", T0, 100, 50, + i => new EchoPacket(false, false, 25_000, i % 10 == 0 && i > 0 && i < 40 ? 175_000 : 25_000, ProcessUs: 0)); + var slow = (RatioAcc)EvaluateOneBucket(chunk, "tcp.slow").Items["tcp.slow"]; + Assert.Equal(100UL, slow.Denominator); + Assert.Equal(3UL, slow.Numerator); + } + + [Fact] + public void Slow_ASteadyConnection_HasNoLongTail_AndTooFewSamplesGiveNoObservation() + { + RawChunk steady = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 25_000, 25_000)); + Assert.Equal(0UL, ((RatioAcc)EvaluateOneBucket(steady, "tcp.slow").Items["tcp.slow"]).Numerator); + + RawChunk few = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 50, i => new EchoPacket(false, false, 25_000, i == 5 ? 500_000 : 25_000)); + Assert.False(EvaluateOneBucket(few, "tcp.slow").Items.ContainsKey("tcp.slow")); + } + + [Fact] + public void RttTail_P999AndTailRatioComeFromTheSameMergedHistogram() + { + // 1000 个回显,其中 5 个 1 s 的长尾:p50 约 50 ms,p99 仍在 50 ms 附近,p99.9 落在长尾上 + RawChunk chunk = ChunkFactory.Echo( + s_agent, "tcp_echo", T0, 1000, 10, + i => new EchoPacket(false, false, 25_000, i % 200 == 0 && i > 0 ? 975_000 : 25_000, ProcessUs: 0), durationMs: 20_000); + var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "tcp.rtt").Items["tcp.rtt"]; + MetricDef def = TcpMetrics.Rtt; + + double? p999 = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.P999), 20); + double? tail = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.Tail), 20); + double? max = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.Max), 20); + Assert.InRange(p999!.Value, 500, 1100); + Assert.InRange(max!.Value, 999, 1001); + Assert.InRange(tail!.Value, 0.9, 1.2); // p99 / p50:偶发的尖峰不改变 p99,所以长尾比接近 1,真正的长尾由 p99.9 和 max 暴露 + Assert.Equal(Unit.None, def.Exports.Single(e => e.Stat == Stat.Tail).Unit); + Assert.Equal("tcp.rtt.p999", def.Exports.Single(e => e.Stat == Stat.P999).Id); + } + + [Fact] + public void Retrans_UsesKernelCounterDeltas() + { + long baseUs = ChunkFactory.ProbeUs(0); + RawStream info = ChunkFactory.TcpInfo( + [ + (baseUs, 0, 40_000u, 0u, 100u), + (baseUs + 1_000_000, 0, 40_000u, 2u, 300u), + (baseUs + 2_000_000, 0, 40_000u, 2u, 500u), + ]); + RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]); + var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"]; + Assert.Equal(2UL, retrans.Numerator); + Assert.Equal(400UL, retrans.Denominator); + } + + [Fact] + public void Retrans_CounterResetNeverGoesNegative() + { + long baseUs = ChunkFactory.ProbeUs(0); + RawStream info = ChunkFactory.TcpInfo([(baseUs, 0, 1u, 10u, 100u), (baseUs + 1_000_000, 0, 1u, 0u, 50u), (baseUs + 2_000_000, 0, 1u, 1u, 150u)]); + RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]); + var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"]; + Assert.Equal(1UL, retrans.Numerator); + Assert.Equal(100UL, retrans.Denominator); + } + + [Fact] + public void Connect_Outcomes_AreCountedByClass() + { + RawChunk chunk = ChunkFactory.Connect( + s_agent, T0, + [ + (30_000, 80_000, ConnectOutcomeCode.Ok), + (31_000, 90_000, ConnectOutcomeCode.Ok), + (0, null, ConnectOutcomeCode.Timeout), + (30_000, null, ConnectOutcomeCode.Reset), + ]); + MetricSet set = EvaluateOneBucket(chunk); + Assert.Equal(50.0, ((RatioAcc)set.Items["connect.success"]).Percent); + Assert.Equal(1UL, ((CounterAcc)set.Items["connect.fail.timeout"]).Value); + Assert.Equal(1UL, ((CounterAcc)set.Items["connect.fail.rst"]).Value); + Assert.False(set.Items.ContainsKey("connect.fail.tls")); + Assert.Equal(3UL, ((HistogramAcc)set.Items["connect.tcp_ms"]).Count); + Assert.Equal(2UL, ((HistogramAcc)set.Items["connect.tls_ms"]).Count); + } + + [Fact] + public void Evaluate_AssignsObservationsToTheirOwnBuckets() + { + // 10 秒的块,按 1 秒分桶:每个桶 10 个包 + RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0); + var buckets = new SortedDictionary(); + WindowEvaluator.Default.Evaluate(chunk, 1000, buckets, new HashSet { "n.udp_echo" }); + Assert.Equal(10, buckets.Count); + Assert.All(buckets.Values, set => Assert.Equal(10UL, ((CounterAcc)set.Items["n.udp_echo"]).Value)); + Assert.Equal(T0, buckets.Keys.First()); + } + + [Fact] + public void Evaluate_ChunkOfAnotherKind_ProducesNothingForUnrelatedMetrics() + { + RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0); + MetricSet set = EvaluateOneBucket(chunk); + Assert.False(set.Items.ContainsKey("tcp.rtt.p95")); + Assert.False(set.Items.ContainsKey("tcp.rtt")); + Assert.True(set.Items.ContainsKey("rtt")); + } + + [Fact] + public void ChangePoint_FindsTheShift_AndIgnoresNoise() + { + double[] shifted = [10, 10, 11, 10, 9, 10, 4, 4, 5, 4, 4, 3]; + Assert.Equal(6, ChangePoint.Detect(shifted, minShift: 3)); + double[] flat = [10, 10.2, 9.8, 10.1, 9.9, 10, 10.1, 9.9]; + Assert.Equal(-1, ChangePoint.Detect(flat, minShift: 3)); + Assert.Equal(-1, ChangePoint.Detect([1, 2], minShift: 1)); + Assert.Equal(2, ChangePoint.FirstJump([0, 1, 20, 50], minJump: 5)); + Assert.Equal(-1, ChangePoint.FirstJump([0, 1, 2, 3], minJump: 5)); + } + + [Fact] + public void ClockDriftFit_RemovesLinearDrift() + { + double[] t = Enumerable.Range(0, 50).Select(i => i * 100_000.0).ToArray(); + double[] delay = t.Select(x => 5_000_000 + (x * 50e-6) + 20_000).ToArray(); // 偏移 + 50 ppm 漂移 + 固定时延 + double[] jitter = ClockDriftFit.Jitter(t, delay)!; + Assert.All(jitter, j => Assert.True(j < 1)); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs new file mode 100644 index 0000000..5c41db0 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/AgentEndToEndTests.cs @@ -0,0 +1,308 @@ +using System.Text.Json.Nodes; +using Monitor.Measurement; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// 真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。 +[Collection("backend")] +public sealed class AgentEndToEndTests +{ + private readonly BackendFixture _backend; + + public AgentEndToEndTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync() + { + AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(rateHz: 30); + ProbeSimulator probe = await agent.ConnectProbeAsync(); + LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0])); + return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo")); + } + + private async Task AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id); + + [Fact] + public async Task Agent_ConnectsAndReportsItsRealPorts_AndTheProbeSessionIsVisibleToTheBackend() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + JsonNode view = await AgentViewAsync(agent); + Assert.Equal("active", view["status"]!.GetValue()); + Assert.False(view["probe_connected"]!.GetValue()); + + await using ProbeSimulator probe = await agent.ConnectProbeAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + } + + [Fact] + public async Task LocalLimits_RejectTasksOutsideTheAllowedPorts_AndTheReasonReachesTheAdmin() + { + // 默认模板的端口(7443/7444)不在这个 Agent 实际监听的端口里:任务被拒绝,原因是 policy_denied + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 2); + JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!; + Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue())); + + // 覆盖成实际端口之后恢复 + await agent.OverrideLineTasksAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 0); + } + + [Fact] + public async Task UdpEcho_EndToEnd_FragmentsAreMergedWithAgentTimestamps_AndStoredByTheBackend() + { + (AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync(); + await using (agent) + await using (probe) + { + (int sent, int received) = await probe.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40)); + Assert.Equal(40, sent); + Assert.Equal(40, received); + + JsonNode stored = null!; + await BackendFixture.EventuallyAsync(async () => + { + stored = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"); + return stored["items"]!.AsArray().Count >= 1; + }, because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} outbox={agent.App.Link.Outbox.PendingCount}"); + JsonNode chunk = stored["items"]![0]!; + Assert.True(chunk["kind"] is not null, stored.ToJsonString()); + Assert.Equal("udp_echo", chunk["kind"]!.GetValue()); + Assert.True(agent.App.Counters.Fragments >= 1); + Assert.True(agent.App.Counters.PacketsIn >= 40); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpEcho_EndToEnd_AuthenticatedFirstFrame_PlainOrTls(bool tls) + { + (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); + await using (agent) + await using (probe) + { + LineTask tcp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo"); + (int sent, int received) = await probe.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls); + Assert.Equal(15, sent); + Assert.Equal(15, received); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue() == "tcp_echo")); + } + } + + [Fact] + public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame() + { + (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); + await using (agent) + await using (probe) + { + using var client = new System.Net.Sockets.TcpClient(); + await client.ConnectAsync(System.Net.IPAddress.Loopback, agent.App.TcpPorts[0]); + await client.GetStream().WriteAsync(new byte[TcpWire.AuthBytes]); // 全零:magic 不对 + byte[] buffer = new byte[16]; + Assert.Equal(0, await client.GetStream().ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10))); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid >= 1)); + } + } + + [Fact] + public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering() + { + (AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync(); + await using (agent) + await using (probe) + { + long before = agent.App.Counters.DroppedInvalid; + await probe.SendRawUdpAsync([1, 2, 3]); + await probe.SendRawUdpAsync(new byte[64]); + await probe.SendForgedEchoAsync(udp, 1); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted"); + + // 重放:同一个序号发两次,只有第一次被接受 + await probe.SendEchoAsync(udp, 500); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1)); + long dropped = agent.App.Counters.DroppedInvalid; + await probe.SendEchoAsync(udp, 500); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped"); + } + } + + [Fact] + public async Task ARestartedProbe_NeverInheritsTheAgentRecordsOfItsPreviousSession() + { + (AgentHarness agent, ProbeSimulator first, LineTask udp) = await LineAsync(); + await using (agent) + { + await first.RunUdpEchoAsync(udp, 20, TimeSpan.FromMilliseconds(40)); + RowStore records = agent.App.Streams.Records(udp.StreamId, "msg")!; + Assert.True(records.Contains(5), "the agent recorded t2/t3 for the first session's packets"); + await first.DisposeAsync(); + + // The probe process restarts: a new session with its own streams; sequence numbers start from 0 again + await using ProbeSimulator second = first.Restarted(); + await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId); + LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0])); + LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo"); + Assert.NotEqual(udp.StreamId, udp2.StreamId); + + // The previous session's streams are released with it ... + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Streams.Records(udp.StreamId, "msg") is null), because: "the previous session's streams must be released"); + + // ... and a packet the new session never sent has no agent record: if the old t2/t3 survived, a lost uplink packet would look like a downlink loss + RowStore fresh = agent.App.Streams.Records(udp2.StreamId, "msg")!; + Assert.False(fresh.Contains(3)); + Assert.False(fresh.Contains(5)); + } + } + + [Fact] + public async Task SeveralProbes_AreReportedTogether_CanBeDroppedOneByOne_AndARevokedOneIsKickedOut() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(rateHz: 30); + await using ProbeSimulator a = await agent.ConnectProbeAsync(); + await using ProbeSimulator b = await agent.ConnectProbeAsync(); + + // The agent reports both sessions with version and address; the probe view shows the agent it is connected to + JsonNode view = null!; + await BackendFixture.EventuallyAsync( + async () => + { + view = await AgentViewAsync(agent); + return view["probes"]!.AsArray().Count == 2; + }, + because: "the agent reports every connected probe"); + Assert.All(view["probes"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue())); + Assert.Equal("127.0.0.1", view["probes"]![0]!["remote"]!.GetValue()); + JsonNode probeView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + a.ProbeId); + Assert.Equal(agent.Id, probeView["connections"]![0]!["agent_id"]!.GetValue()); + + // Both get their own task set (own streams) + LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo")); + LineTaskSet setB = await b.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo")); + Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "udp_echo").StreamId, setB.Tasks.Single(t => t.Kind == "udp_echo").StreamId); + + // Drop one: the other stays + using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null); + Assert.Equal(System.Net.HttpStatusCode.NoContent, dropped.StatusCode); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 1, because: "only the dropped probe leaves"); + Assert.True(b.IsConnected); + using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null); + Assert.Equal(System.Net.HttpStatusCode.Conflict, again.StatusCode); + + // Revoke the remaining one: the agent closes its session + using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{b.ProbeId}/revoke", null); + revoked.EnsureSuccessStatusCode(); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 0, because: "a revoked probe is disconnected"); + } + + [Fact] + public async Task PeerHandshake_RejectsAProbeWhoseKeyTheBackendNeverIssued() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.CreateProbeAsync(); + await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate()); + await Assert.ThrowsAnyAsync(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsSpki, agent.App.UdpPorts[0], agent.NodeId)); + + // 证书指纹不对(中间人)也不会连上 + await using ProbeSimulator probe = await agent.CreateProbeAsync(); + await Assert.ThrowsAnyAsync(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, new byte[32], agent.App.UdpPorts[0], agent.NodeId)); + } + + [Fact] + public async Task ProbeDisconnect_IsReportedToTheBackend() + { + (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); + await using (agent) + { + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + await probe.DisposeAsync(); + await BackendFixture.EventuallyAsync(async () => !(await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + } + } + + [Fact] + public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe() + { + (AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync(); + await using (agent) + await using (probe) + { + JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks"); + JsonNode mine = tasks["items"]!.AsArray().Single(t => t!["agent_id"]?.GetValue() == agent.Id && t["kind"]!.GetValue() == "udp_echo")!; + using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json")); + disabled.EnsureSuccessStatusCode(); + + LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Tasks.Count >= 0 && s.Version > 0); + Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id); + } + } + + [Fact] + public async Task Hy2Ladder_EndToEnd_AgentSendsTheTrain_ProbeReportsReceipts_AndTheTestSucceeds() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(); + await using ProbeSimulator probe = await agent.ConnectProbeAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + JsonNode test = null!; + await BackendFixture.EventuallyAsync( + async () => + { + test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); + return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; + }, + TimeSpan.FromSeconds(90)); + Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); + Assert.Equal(4, probe.Phases.Count); + Assert.All(probe.Phases, phase => Assert.Equal("udp_train", phase.Kind)); + + // 单块最多 50000 条记录:50 和 100 Mbit/s 阶段(10 s、1000 B 的包)各被 Probe 切成多个列块 + Assert.True(agent.App.Counters.Fragments > 4); + await BackendFixture.EventuallyAsync( + async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == "udp_train") == agent.App.Counters.Fragments, + because: (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.Id}")).ToJsonString() + $" fragments={agent.App.Counters.Fragments} discarded={agent.App.Counters.FragmentsDiscarded} pending={agent.App.Link.Outbox.PendingCount}/{agent.App.Link.Outbox.PendingBytes} dropped={agent.App.Link.Outbox.Dropped} link={agent.App.Link.IsConnected}"); + } + + [Fact] + public async Task Plan_UsingTcpBulk_IsAcceptedByARealAgentAndStartsItsFirstPhase() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await using ProbeSimulator probe = await agent.ConnectProbeAsync(); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + + // reality_session 需要 connect、tcp_bulk、hold:真实的 Agent 都实现了 + using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode); + await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.Phases.IsEmpty), because: "the first phase should start"); + Assert.True(probe.Phases.TryPeek(out var first)); + Assert.Equal("connect", first.Kind); + } + + [Fact] + public async Task Test_IsRefusedWhenNoProbeIsConnected_AndWhenAPhaseExceedsTheLocalLimits() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20); + await agent.OverrideLineTasksAsync(); + + var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.UdpPorts[0]) } }; + var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] }; + Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value); + + await using ProbeSimulator probe = await agent.ConnectProbeAsync(); + run.ProbeId = new ProbeId(probe.ProbeId); + phase.Params["mbps"] = System.Text.Json.JsonSerializer.SerializeToElement(50); + Assert.Equal("budget_exceeded", agent.App.Tests.Start(run).Error.Code.Value); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/AgentHarness.cs b/backend/tests/Monitor.Backend.Tests/AgentHarness.cs new file mode 100644 index 0000000..84d0687 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/AgentHarness.cs @@ -0,0 +1,141 @@ +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Enrollment.Client; +using Fleet.Hosting; +using Fleet.Identity; +using Monitor.Agent; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// +/// 进程内的真实 Agent:用 注册(和 fleet-enroll 同一份代码)、写出身份目录、 +/// 在随机端口上启动 并连接真实的后端。 +/// +public sealed class AgentHarness : IAsyncDisposable +{ + private readonly CancellationTokenSource _stop = new(); + private readonly string _directory; + private readonly LoadedNodeIdentity _identity; + private readonly Task _run; + + private AgentHarness(BackendFixture backend, string directory, LoadedNodeIdentity identity, AgentApp app) + { + Backend = backend; + _directory = directory; + _identity = identity; + App = app; + _run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None); + } + + public BackendFixture Backend { get; } + + public AgentApp App { get; } + + public NodeId NodeId => _identity.NodeId; + + public string Id => _identity.NodeId.Value; + + public string DataDirectory => _directory; + + public byte[] TlsSpki => Convert.FromBase64String(_identity.File.TlsSpki); + + public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null) + { + string directory = Path.Combine(Path.GetTempPath(), "monitor-agent-" + Guid.NewGuid().ToString("N")); + string token = await backend.CreateJoinTokenAsync(); + using var http = new HttpClient(); + Result enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None); + Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); + + var config = new AgentConfig + { + Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory }, + Listen = { Bind = "127.0.0.1", ControlPort = 0, UdpPorts = [0], TcpPorts = [0] }, + }; + configure?.Invoke(config); + LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); + var app = new AgentApp(config, identity, TimeProvider.System, new ConsoleNodeLog(TimeProvider.System, Console.Out)); + var harness = new AgentHarness(backend, directory, identity, app); + await app.Ready.WaitAsync(TimeSpan.FromSeconds(10)); + await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend"); + return harness; + } + + /// 在后端生成 Probe 身份(私钥只返回一次),并构造连到这个 Agent 的假 Probe。 + public async Task CreateProbeAsync() + { + JsonNode identity = await Backend.CreateProbeAsync(); + NodeIdentity key = NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue())); + string publicKey = Convert.ToBase64String(key.PublicKeySpki); + + // 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝) + await BackendFixture.EventuallyAsync( + () => Task.FromResult(App.IsPeerKnown(identity["probe_id"]!.GetValue(), publicKey)), + because: "the agent should learn the probe's key"); + return new ProbeSimulator(identity["probe_id"]!.GetValue(), key); + } + + public async Task ConnectProbeAsync() + { + ProbeSimulator probe = await CreateProbeAsync(); + await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsSpki, App.UdpPorts[0], NodeId); + return probe; + } + + /// 为这个 Agent 创建覆盖任务,让线路任务使用它实际监听的端口(默认模板里是 7443/7444,不在本地允许的端口里)。 + public async Task OverrideLineTasksAsync(int? rateHz = null) + { + JsonNode tasks = await Backend.GetJsonAsync(Backend.Admin, "/api/admin/tasks"); + foreach (JsonNode? task in tasks["items"]!.AsArray()) + { + if (task!["scope"]!.GetValue() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue() is not ("udp_echo" or "tcp_echo")) + { + continue; + } + + string kind = task["kind"]!.GetValue(); + var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] }; + if (rateHz is { } hz) + { + parameters["rate_hz"] = hz; + } + + using HttpResponseMessage created = await Backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "line", + ["kind"] = kind, + ["agent_id"] = Id, + ["base_task_id"] = task["id"]!.GetValue(), + ["params"] = parameters, + })); + Assert.True(created.StatusCode == System.Net.HttpStatusCode.Created, await created.Content.ReadAsStringAsync()); + } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + try + { + await _run.WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + // 停止中 + } + + App.Dispose(); + _identity.Dispose(); + _stop.Dispose(); + try + { + Directory.Delete(_directory, recursive: true); + } + catch (IOException) + { + // 临时目录清理失败不影响测试结果 + } + } +} diff --git a/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs b/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs new file mode 100644 index 0000000..108acd1 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/AotSmokeTests.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// +/// 对一个正在运行的 NativeAOT 发布产物跑一遍真实的 Agent 流程(注册、WebSocket、状态同步、二进制收件箱、查询、SSE、按需测试)。 +/// 只有设置了 MONITOR_AOT_URL 才会运行: +/// dotnet publish backend/src/Monitor.Backend -r win-x64 -p:PublishAot=true,启动产物,再设置该环境变量后运行本测试。 +/// 管理员凭据与测试夹具相同(用户 admin,口令见 )。 +/// +public sealed class AotSmokeTests +{ + private static string? Url => Environment.GetEnvironmentVariable("MONITOR_AOT_URL"); + + [Fact] + public async Task TheNativeBinary_ServesTheWholeAgentWorkflow() + { + Assert.SkipUnless(Url is not null, "set MONITOR_AOT_URL to the address of a running NativeAOT build to run this test"); + var baseAddress = new Uri(Url!); + using var http = new HttpClient { BaseAddress = baseAddress }; + + // 管理员:登录 → 令牌 + using HttpResponseMessage login = await http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); + login.EnsureSuccessStatusCode(); + string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; + string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["scope"] = "admin" }) }; + tokenRequest.Headers.Add("Cookie", cookie); + tokenRequest.Headers.Add("X-CSRF-Token", csrf); + using HttpResponseMessage tokenResponse = await http.SendAsync(tokenRequest); + tokenResponse.EnsureSuccessStatusCode(); + string token = JsonNode.Parse(await tokenResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态 + using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 })); + string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + using var anonymous = new HttpClient { BaseAddress = baseAddress }; + await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]); + await agent.ConnectAsync(baseAddress); + TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2); + Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo"); + + // 原始块:二进制通道 → 段文件与索引 → ACK + long minute = 60_000; + long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute; + for (int k = 0; k < 6; k++) + { + Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000)))); + } + + // 查询:热层的精确汇总,随后是持久层的分钟窗口 + string range = $"from={Iso(baseMs)}&to={Iso(baseMs + minute)}&metrics=loss.total,rtt.p95"; + JsonNode hot = await GetAsync(http, $"/api/public/lines/{agent.NodeId.Value}/series?{range}&step=1s"); + Assert.Equal("hot", hot["source"]!.GetValue()); + Assert.Equal(600, hot["summary"]!["samples"]!["udp_echo"]!.GetValue()); + Assert.Equal(10.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + long deadline = TimeProvider.System.GetTimestamp(); + JsonNode persisted; + do + { + persisted = await GetAsync(http, $"/api/public/lines/{agent.NodeId.Value}/series?{range}&step=1m"); + if (persisted["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600) + { + break; + } + + await Task.Delay(100); + } + while (TimeProvider.System.GetElapsedTime(deadline) < TimeSpan.FromSeconds(15)); + Assert.Equal("1m", persisted["source"]!.GetValue()); + Assert.Equal(600, persisted["summary"]!["samples"]!["udp_echo"]!.GetValue()); + + // 原始块可以按 ID 下载并解码 + JsonNode chunks = await GetAsync(http, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=1"); + byte[] bytes = await http.GetByteArrayAsync("/api/admin/raw/chunks/" + chunks["items"]![0]!["chunk_id"]!.GetValue()); + Assert.True(RawChunkCodec.Decode(bytes).IsSuccess); + + // SSE:快照 + 增量 + await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{agent.NodeId.Value}:1s:udp"); + await sse.WaitForAsync(e => e.Name == "series"); + + // 按需测试:Probe 事件 → 下发 → 状态 + await agent.SendEventAsync(AgentEventKinds.ProbeConnected); + JsonNode started = null!; + long begun = TimeProvider.System.GetTimestamp(); + while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10)) + { + using HttpResponseMessage response = await http.PostAsync($"/api/admin/lines/{agent.NodeId.Value}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + if (response.StatusCode == System.Net.HttpStatusCode.Accepted) + { + started = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + break; + } + + await Task.Delay(100); + } + + Assert.NotNull(started); + await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); + await agent.SendTestStatusAsync(new TestId(started["id"]!.GetValue()), TestStatuses.Running, 0); + await sse.WaitForAsync(e => e.Name == "node"); + + // 安全:匿名不能访问管理接口 + using HttpResponseMessage denied = await anonymous.GetAsync("/api/admin/agents"); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, denied.StatusCode); + } + + private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + + private static async Task GetAsync(HttpClient http, string url) + { + using HttpResponseMessage response = await http.GetAsync(url); + string text = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}"); + return JsonNode.Parse(text)!; + } +} diff --git a/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs new file mode 100644 index 0000000..304dcfb --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/ApiBasicsTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class ApiBasicsTests +{ + private readonly BackendFixture _backend; + + public ApiBasicsTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public async Task Healthz_ReportsStorageHealth_AndOpenApiIsServed() + { + JsonNode health = await _backend.GetJsonAsync(_backend.Anonymous, "/healthz"); + Assert.Equal("ok", health["status"]!.GetValue()); + Assert.True(health["database"]!.GetValue()); + Assert.True(health["raw_directory"]!.GetValue()); + + JsonNode openapi = await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json"); + Assert.NotNull(openapi["paths"]!["/api/public/overview"]); + Assert.Equal("overview_get", openapi["paths"]!["/api/public/overview"]!["get"]!["operationId"]!.GetValue()); + } + + [Fact] + public async Task Admin_WithoutCredentials_Returns401ProblemJson_WithCodeAndTraceId() + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/agents"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType); + JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("unauthorized", problem["code"]!.GetValue()); + Assert.Equal(401, problem["status"]!.GetValue()); + Assert.False(string.IsNullOrEmpty(problem["trace_id"]!.GetValue())); + } + + [Fact] + public async Task Login_WrongPassword_IsGeneric401_AndSuccessSetsAHardenedCookie() + { + using HttpClient client = _backend.NewClient(); + using HttpResponseMessage wrong = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "admin", ["password"] = "nope" })); + using HttpResponseMessage unknown = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "nobody", ["password"] = "nope" })); + Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); + Assert.Equal( + JsonNode.Parse(await wrong.Content.ReadAsStringAsync())!["detail"]!.GetValue(), + JsonNode.Parse(await unknown.Content.ReadAsStringAsync())!["detail"]!.GetValue()); + + using HttpResponseMessage ok = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + string cookie = ok.Headers.GetValues("Set-Cookie").Single(); + Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("samesite=strict", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Equal("no-store", ok.Headers.CacheControl!.ToString()); + } + + [Fact] + public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods() + { + using HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); + string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); + string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; + + using var get = new HttpRequestMessage(HttpMethod.Get, "/api/admin/auth/me"); + get.Headers.Add("Cookie", cookie); + using HttpResponseMessage me = await client.SendAsync(get); + Assert.Equal(HttpStatusCode.OK, me.StatusCode); + + using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + withoutCsrf.Headers.Add("Cookie", cookie); + using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf); + Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); + Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + wrongCsrf.Headers.Add("Cookie", cookie); + wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x"); + using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf); + Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); + + using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + good.Headers.Add("Cookie", cookie); + good.Headers.Add("X-CSRF-Token", csrf); + using HttpResponseMessage created = await client.SendAsync(good); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + } + + [Fact] + public async Task ReadScopedToken_CanReadButNotManage() + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "read" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("no-store", created.Headers.CacheControl!.ToString()); + string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + + using HttpClient reader = _backend.NewClient(); + reader.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + using HttpResponseMessage manage = await reader.GetAsync("/api/admin/agents"); + Assert.Equal(HttpStatusCode.Forbidden, manage.StatusCode); + using HttpResponseMessage read = await reader.GetAsync("/api/public/catalog/kinds"); + Assert.Equal(HttpStatusCode.OK, read.StatusCode); + + using HttpClient bogus = _backend.NewClient(); + bogus.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "mon_bogus"); + using HttpResponseMessage unknown = await bogus.GetAsync("/api/admin/agents"); + Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode); + } + + [Fact] + public async Task PrivateSite_HidesThePublicApiFromAnonymousVisitors_UntilMadePublic() + { + using HttpResponseMessage makePrivate = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = false }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, makePrivate.StatusCode); + + using HttpResponseMessage hidden = await _backend.Anonymous.GetAsync("/api/public/overview"); + Assert.Equal(HttpStatusCode.Forbidden, hidden.StatusCode); + Assert.Equal("site_private", JsonNode.Parse(await hidden.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage authed = await _backend.Admin.GetAsync("/api/public/overview"); + Assert.Equal(HttpStatusCode.OK, authed.StatusCode); + + await _backend.SiteMustBePublicAsync(); + using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/overview"); + Assert.Equal(HttpStatusCode.OK, visible.StatusCode); + } + + [Fact] + public async Task Settings_RejectUnknownKeysAndOutOfRangeValues() + { + using HttpResponseMessage response = await _backend.Admin.PatchAsync( + "/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 0, ["nope"] = 1 }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Equal("validation_failed", problem["code"]!.GetValue()); + Assert.NotNull(problem["errors"]!["retention.raw_days"]); + Assert.NotNull(problem["errors"]!["nope"]); + } + + [Fact] + public async Task Catalog_IsGeneratedFromTheRegistries_WithCachingHeaders() + { + await _backend.SiteMustBePublicAsync(); + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/catalog/metrics"); + Assert.Equal("public, max-age=300", response.Headers.CacheControl!.ToString()); + string etag = response.Headers.ETag!.Tag; + JsonNode metrics = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Contains(metrics["items"]!.AsArray(), m => m!["id"]!.GetValue() == "rtt.p95" && m["unit"]!.GetValue() == "ms"); + + using var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/public/catalog/metrics"); + conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag)); + using HttpResponseMessage notModified = await _backend.Anonymous.SendAsync(conditional); + Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode); + + JsonNode kinds = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/catalog/kinds"); + JsonNode udp = kinds["items"]!.AsArray().Single(k => k!["id"]!.GetValue() == "udp_echo")!; + JsonNode rate = udp["params"]!.AsArray().Single(p => p!["name"]!.GetValue() == "rate_hz")!; + Assert.Equal(1, rate["min"]!.GetValue()); + Assert.Equal(200, rate["max"]!.GetValue()); + Assert.Equal(10, rate["default"]!.GetValue()); + } + + [Fact] + public async Task UnknownRoutesAndBadBodies_AreProblemJsonWithCodes() + { + using HttpResponseMessage notFound = await _backend.Admin.GetAsync("/api/admin/nope"); + Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode); + Assert.Equal("not_found", JsonNode.Parse(await notFound.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage badBody = await _backend.Admin.PostAsync("/api/admin/tasks", new StringContent("{not json", System.Text.Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.BadRequest, badBody.StatusCode); + Assert.Equal("validation_failed", JsonNode.Parse(await badBody.Content.ReadAsStringAsync())!["code"]!.GetValue()); + } + + [Fact] + public async Task Metrics_RequireAnAdminToken_AndExposePrometheusText() + { + using HttpResponseMessage denied = await _backend.Anonymous.GetAsync("/metrics"); + Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); + using HttpResponseMessage allowed = await _backend.Admin.GetAsync("/metrics"); + string text = await allowed.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Contains("monitor_agents_online", text); + Assert.Contains("monitor_analysis_queue_length", text); + } + + [Fact] + public async Task InstallScripts_AreNotShippedYet() + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh"); + Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode); + Assert.Equal("not_implemented", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); + } +} + +[CollectionDefinition("backend")] +public sealed class BackendCollection : ICollectionFixture; diff --git a/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs b/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs new file mode 100644 index 0000000..1c54695 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs @@ -0,0 +1,226 @@ +using System.Xml.Linq; + +namespace Monitor.Backend.Tests; + +/// +/// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制,等价于文档里的 eng/check-deps。 +/// 读取仓库里所有 .csproj 和源文件,违反规则就失败。 +/// +public sealed class ArchitectureTests +{ + private static readonly string s_root = FindRoot(); + + private static readonly string[] s_allowedAnalyzers = ["Microsoft.CodeAnalysis.BannedApiAnalyzers"]; + + private sealed record Project(string Name, string Path, string Directory, IReadOnlyList ProjectReferences, IReadOnlyList Packages); + + private static string FindRoot() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null && !File.Exists(Path.Combine(dir, "global.json"))) + { + dir = Path.GetDirectoryName(dir); + } + + return dir ?? throw new InvalidOperationException("repository root not found"); + } + + private static List LoadProjects() => + [.. Directory.EnumerateFiles(s_root, "*.csproj", SearchOption.AllDirectories) + .Where(p => !p.Contains(Path.DirectorySeparatorChar + "artifacts" + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !p.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + .Select(path => + { + XDocument doc = XDocument.Load(path); + IEnumerable items = doc.Descendants("ItemGroup").Elements(); + return new Project( + Path.GetFileNameWithoutExtension(path), + path, + Path.GetDirectoryName(path)!, + [.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension((string?)e.Attribute("Include") ?? ""))], + [.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]); + })]; + + private static bool IsTest(Project p) => p.Path.Contains(Path.DirectorySeparatorChar + "tests" + Path.DirectorySeparatorChar, StringComparison.Ordinal); + + private static bool IsFleet(Project p) => p.Name.StartsWith("Fleet.", StringComparison.Ordinal) && !IsTest(p); + + [Fact] + public void Fleet_NeverReferencesMonitor_InProjectsOrCode() + { + foreach (Project project in LoadProjects().Where(IsFleet)) + { + Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("Monitor.", StringComparison.Ordinal)); + foreach (string file in Directory.EnumerateFiles(project.Directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + { + string text = File.ReadAllText(file); + Assert.False(text.Contains("using Monitor.", StringComparison.Ordinal) || text.Contains("namespace Monitor.", StringComparison.Ordinal), $"{file} refers to Monitor.*"); + Assert.False(text.Contains("monitor.", StringComparison.Ordinal) && text.Contains("\"monitor.", StringComparison.Ordinal), $"{file} hard-codes a Monitor method or channel name"); + Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a Monitor port"); + } + } + } + + [Fact] + public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList() + { + string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "Monitor.Protocol", "Monitor.Backend.Analysis"]; + foreach (Project project in LoadProjects().Where(p => nodeSide.Contains(p.Name))) + { + Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers)); + } + + foreach (string name in new[] { "Fleet.Core", "Fleet.Identity", "Fleet.Rpc" }) + { + Project project = LoadProjects().Single(p => p.Name == name); + Assert.Contains("BannedSymbols.Node.txt", File.ReadAllText(project.Path), StringComparison.Ordinal); + } + } + + [Fact] + public void Protocol_DependsOnlyOnFleetCoreAndRpc_AndAnalysisOnlyOnProtocol() + { + List projects = LoadProjects(); + Assert.Equal(["Fleet.Core", "Fleet.Rpc"], projects.Single(p => p.Name == "Monitor.Protocol").ProjectReferences.Order().ToArray()); + Assert.Equal(["Monitor.Protocol"], projects.Single(p => p.Name == "Monitor.Backend.Analysis").ProjectReferences.ToArray()); + } + + [Fact] + public void NodeProcesses_AreCollectorsOnly_NoBackendNoServerLibrariesNoRuntimePackages() + { + List projects = LoadProjects(); + Project Get(string name) => projects.Single(p => p.Name == name); + + // Agent 是纯采集进程:不引用后端的任何层,也不引用 Fleet 的服务端库 + Project agent = Get("Monitor.Agent"); + Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("Monitor.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal)); + Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], agent.ProjectReferences.Order().ToArray()); + + // Probe 同理,并且不认识 Backend 和 Agent + Project probe = Get("Monitor.Probe"); + Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], probe.ProjectReferences.Order().ToArray()); + + // 两个节点的共享库只依赖协议 + Assert.Equal(["Monitor.Protocol"], Get("Monitor.Measurement").ProjectReferences.ToArray()); + + // 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore) + foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "Monitor.Measurement", "Monitor.Agent", "Monitor.Probe" }) + { + Project project = Get(name); + Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers)); + string settings = File.ReadAllText(project.Path); + for (string? dir = project.Directory; dir is not null && dir.StartsWith(s_root, StringComparison.Ordinal); dir = Path.GetDirectoryName(dir)) + { + string props = Path.Combine(dir, "Directory.Build.props"); + settings += File.Exists(props) ? File.ReadAllText(props) : ""; + } + + Assert.Contains("BannedSymbols.Node.txt", settings, StringComparison.Ordinal); + } + } + + [Fact] + public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns() + { + // 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列; + // REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。 + string[] collectionRoots = ["shared/Monitor.Protocol", "shared/Monitor.Measurement", "agent/src", "probe/src", "fleet/src"]; + string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"]; + foreach (string root in collectionRoots) + { + string directory = Path.Combine(s_root, root.Replace('/', Path.DirectorySeparatorChar)); + foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + { + string text = File.ReadAllText(file); + foreach (string word in forbidden) + { + Assert.False(text.Contains(word, StringComparison.OrdinalIgnoreCase), $"{file} mentions '{word}': business concepts belong to the backend only"); + } + } + } + + // 派生量(丢包率、RTT、抖动)由 Backend 的分析层算:采集层的积木不引用分析层 + Assert.DoesNotContain(LoadProjects().Single(p => p.Name == "Monitor.Measurement").ProjectReferences, r => r.Contains("Analysis", StringComparison.Ordinal)); + } + + [Fact] + public void BackendLayers_ObeyTheDependencyRules() + { + List projects = LoadProjects(); + Project Get(string name) => projects.Single(p => p.Name == name); + + // Api 不能引用 Storage(也不引用 Gateway):只调用 Application + Assert.DoesNotContain(Get("Monitor.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); + Assert.Equal(["Monitor.Backend.Application"], Get("Monitor.Backend.Api").ProjectReferences.ToArray()); + + // Application 不依赖任何下游层 + Assert.DoesNotContain(Get("Monitor.Backend.Application").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); + + // Gateway 只通过 Application 与 Fleet.Server 工作 + Assert.DoesNotContain(Get("Monitor.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal)); + + // Storage 实现 Application 的端口,不认识 Api 和 Gateway + Assert.DoesNotContain(Get("Monitor.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); + + // 只有宿主(组合根)认识所有层 + Assert.Equivalent( + new[] { "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Storage.Sqlite" }, + Get("Monitor.Backend").ProjectReferences.ToArray()); + } + + [Fact] + public void BackendPackages_AreLimitedToTheDocumentedSet() + { + string[] allowed = + [ + "Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "Microsoft.AspNetCore.OpenApi", + "Microsoft.Extensions.ApiDescription.Server", "xunit.v3", "Microsoft.Extensions.TimeProvider.Testing", + ]; + foreach (Project project in LoadProjects()) + { + Assert.All(project.Packages, package => Assert.Contains(package, allowed)); + } + } + + [Fact] + public void ApplicationAndApi_NeverTouchSqliteOrTheFileSystemDirectly() + { + foreach (string layer in new[] { "Monitor.Backend.Application", "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Analysis" }) + { + string dir = Path.Combine(s_root, "src", layer); + foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + { + string text = File.ReadAllText(file); + Assert.DoesNotContain("Microsoft.Data.Sqlite", text, StringComparison.Ordinal); + Assert.DoesNotContain("File.WriteAll", text, StringComparison.Ordinal); + Assert.DoesNotContain("new FileStream", text, StringComparison.Ordinal); + } + } + } + + [Fact] + public void NoProductionCode_BuildsSqlByConcatenatingValues() + { + // 手写的参数化 SQL:只有表名可以来自常量;值一律通过参数 + string dir = Path.Combine(s_root, "src", "Monitor.Backend.Storage.Sqlite"); + foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + { + string text = File.ReadAllText(file); + Assert.DoesNotContain("+ id +", text, StringComparison.Ordinal); + Assert.DoesNotContain("+ name +", text, StringComparison.Ordinal); + Assert.DoesNotContain("+ agent", text, StringComparison.Ordinal); + Assert.DoesNotContain("'\" +", text, StringComparison.Ordinal); + } + } + + [Fact] + public void EveryProject_ImportsTheSharedBuildSettings() + { + // backend/ 下的 Directory.Build.props 对所有项目生效:严格的警告、BannedSymbols、.editorconfig 的风格规则 + string props = File.ReadAllText(Path.Combine(s_root, "Directory.Build.props")); + Assert.Contains("true", props, StringComparison.Ordinal); + Assert.Contains("true", props, StringComparison.Ordinal); + Assert.Contains("BannedSymbols.txt", props, StringComparison.Ordinal); + Assert.Contains("DateTime.UtcNow", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.txt")), StringComparison.Ordinal); + Assert.Contains("System.Diagnostics.Process", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.Node.txt")), StringComparison.Ordinal); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/BackendFixture.cs b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs new file mode 100644 index 0000000..ea0d71e --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/BackendFixture.cs @@ -0,0 +1,229 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Monitor.Backend.Application; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// +/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询), +/// 并提供管理员客户端(Bearer 令牌)和假 Agent。 +/// +public class BackendFixture : IAsyncLifetime +{ + public const string AdminUser = "admin"; + public const string AdminPassword = "correct horse battery"; + + private readonly List _agents = []; + private string _adminToken = ""; + private int _counter; + + /// 所有测试都从同一个回环地址登录,所以共享的后端放宽限流;专门测试限流的类用 。 + protected virtual int LoginAttemptsPerMinute => 1000; + + public WebApplication App { get; private set; } = null!; + + public Uri BaseAddress { get; private set; } = null!; + + public string DataDirectory { get; private set; } = ""; + + public HttpClient Anonymous { get; private set; } = null!; + + /// 带 admin 作用域的 API 令牌的客户端。 + public HttpClient Admin { get; private set; } = null!; + + public IServiceProvider Services => App.Services; + + /// 夹具创建的默认 Probe:合成块()和单线路的查询都用它。 + public string DefaultProbeId { get; private set; } = ""; + + /// 默认 Probe 与这个 Agent 组成的线路 id(agent~probe)。 + public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}"; + + public string Line(string agentId) => $"{agentId}~{DefaultProbeId}"; + + /// 在后端创建一个新的 Probe 身份,返回响应里的 JSON(含私钥,只此一次)。 + public async Task CreateProbeAsync(string? name = null) + { + using HttpResponseMessage response = await Admin.PostAsync("/api/admin/probes", Json(new JsonObject { ["name"] = name ?? UniqueName("probe") })); + string body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, body); + return JsonNode.Parse(body)!; + } + + public async ValueTask InitializeAsync() + { + DataDirectory = Path.Combine(Path.GetTempPath(), "monitor-tests-" + Guid.NewGuid().ToString("N")); + await StartAsync(); + _adminToken = await CreateAdminTokenAsync(); + CreateClients(); + DefaultProbeId = (await CreateProbeAsync("default"))["probe_id"]!.GetValue(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + foreach (AgentSimulator agent in _agents) + { + await agent.DisposeAsync(); + } + + Anonymous.Dispose(); + Admin.Dispose(); + await StopAsync(); + try + { + Directory.Delete(DataDirectory, recursive: true); + } + catch (IOException) + { + // 临时目录清理失败不影响测试结果 + } + } + + /// 重启后端(保留数据目录):用来验证数据是否真的落盘。 + public async Task RestartAsync() + { + Anonymous.Dispose(); + Admin.Dispose(); + await StopAsync(); + await StartAsync(); + CreateClients(); // 新的端口:HttpClient 的 BaseAddress 只能在第一次请求之前设置 + } + + /// 用同一个身份重新连接一个已经断开的假 Agent。 + public async Task ReconnectAsync(AgentSimulator previous) + { + AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId); + agent.ProbeForChunks = new Monitor.Protocol.ProbeId(DefaultProbeId); + _agents.Add(agent); + await agent.ConnectAsync(BaseAddress); + return agent; + } + + private void CreateClients() + { + Anonymous = NewClient(); + Admin = NewClient(); + Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken); + } + + public HttpClient NewClient() => new() { BaseAddress = BaseAddress }; + + public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}"; + + public async Task CreateJoinTokenAsync(bool autoApprove = true, int maxUses = 1, int expiresInHours = 1) + { + using HttpResponseMessage response = await Admin.PostAsync("/api/admin/join-tokens", Json(new JsonObject { ["auto_approve"] = autoApprove, ["max_uses"] = maxUses, ["expires_in_hours"] = expiresInHours })); + response.EnsureSuccessStatusCode(); + return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + } + + /// 注册并连接一个假 Agent。 + public async Task ConnectAgentAsync(string? name = null, bool autoApprove = true, string[]? kinds = null, bool hello = true) + { + string token = await CreateJoinTokenAsync(autoApprove); + AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"), kinds); + agent.ProbeForChunks = new Monitor.Protocol.ProbeId(DefaultProbeId); + _agents.Add(agent); + await agent.ConnectAsync(BaseAddress, hello, kinds); + return agent; + } + + public async Task EnrollOnlyAsync(string? name = null, bool autoApprove = true) + { + string token = await CreateJoinTokenAsync(autoApprove); + AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent")); + _agents.Add(agent); + return agent; + } + + public async Task GetJsonAsync(HttpClient client, string url) + { + using HttpResponseMessage response = await client.GetAsync(url); + string text = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET {url} -> {(int)response.StatusCode} {text}"); + return JsonNode.Parse(text)!; + } + + public static StringContent Json(JsonNode node, string mediaType = "application/json") => + new(node.ToJsonString(), Encoding.UTF8, mediaType); + + public async Task SiteMustBePublicAsync() + { + using HttpResponseMessage response = await Admin.PatchAsync("/api/admin/settings", Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); + response.EnsureSuccessStatusCode(); + } + + /// 轮询直到条件成立(后台分析、状态推送都是异步的)。 + public static async Task EventuallyAsync(Func> condition, TimeSpan? timeout = null, string? because = null) + { + long started = TimeProvider.System.GetTimestamp(); + TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15); + while (TimeProvider.System.GetElapsedTime(started) < limit) + { + if (await condition()) + { + return; + } + + await Task.Delay(50); + } + + Assert.Fail("condition was not met in time" + (because is null ? "" : ": " + because)); + } + + private async Task StartAsync() + { + App = BackendHost.Build( + ["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"], + options => + { + options.DataDirectory = DataDirectory; + options.Pbkdf2Iterations = 1000; + options.SecureCookies = false; + options.WindowGrace = TimeSpan.Zero; + options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100); + options.InitialAdminUser = AdminUser; + options.InitialAdminPassword = AdminPassword; + options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; + }); + await App.StartAsync(); + string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); + BaseAddress = new Uri(address); + } + + private async Task StopAsync() + { + await App.StopAsync(); + await App.DisposeAsync(); + } + + private async Task CreateAdminTokenAsync() + { + using HttpClient session = NewClient(); + using HttpResponseMessage login = await session.PostAsync( + "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword })); + login.EnsureSuccessStatusCode(); + JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; + string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) }; + request.Headers.Add("Cookie", cookie); + request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue()); + using HttpResponseMessage created = await session.SendAsync(request); + created.EnsureSuccessStatusCode(); + return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + } +} + +/// 登录限流为每分钟 10 次(默认值)的独立后端。 +public sealed class StrictLoginFixture : BackendFixture +{ + protected override int LoginAttemptsPerMinute => 10; +} diff --git a/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs new file mode 100644 index 0000000..446346d --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/EnrollmentAndTaskSetTests.cs @@ -0,0 +1,399 @@ +using System.Net; +using System.Text.Json.Nodes; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class EnrollmentAndTaskSetTests +{ + private readonly BackendFixture _backend; + + public EnrollmentAndTaskSetTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task AgentAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + id); + + [Fact] + public async Task NewAgent_EnrollsConnectsAndReceivesTheDefaultLineTasks() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + + Assert.Equal("active", agent.EnrollmentStatus); + Assert.Equal("active", agent.AuthStatus); + TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + Assert.Equal(["tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray()); + Assert.All(set.Tasks, t => + { + Assert.Equal("line", t.Scope); + Assert.Equal(TaskSchedule.Continuous, t.Schedule); + Assert.NotEmpty(t.Params); // 参数已经补全了默认值 + }); + Assert.Equal(10, set.Tasks.Single(t => t.Kind == "udp_echo").Params["rate_hz"].GetInt64()); + Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_echo").Params["port"].GetInt64()); + Assert.True(set.Version >= 1); + + JsonNode view = await AgentAsync(agent.NodeId.Value); + Assert.True(view["online"]!.GetValue()); + Assert.Equal("203.0.113.10", view["public_host"]!.GetValue()); + Assert.Equal("0.0.1-sim", view["agent_version"]!.GetValue()); + Assert.Equal(200, view["local_limits"]!["max_total_mbps"]!.GetValue()); + await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["applied_task_version"]!.GetValue() == set.Version); + } + + [Fact] + public async Task TaskSet_OnlyContainsKindsTheAgentSupports() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["udp_echo"]); + TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 1); + Assert.Equal(["udp_echo"], set.Tasks.Select(t => t.Kind).ToArray()); + } + + [Fact] + public async Task Reconnecting_AlwaysGetsTheCurrentSnapshotAgain() + { + AgentSimulator first = await _backend.ConnectAgentAsync(); + TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + await first.DisposeAsync(); + + // 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本 + await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(first.NodeId.Value))["online"]!.GetValue()); + Assert.NotNull(before); + } + + [Fact] + public async Task Enrollment_RejectsBadTokens_ExhaustedTokens_AndBadProofs() + { + using HttpResponseMessage unknown = await PostEnrollAsync(new JsonObject { ["join_token"] = "jt_nope", ["name"] = "x", ["pubkey"] = "AAAA", ["tls_spki"] = "AAAA", ["proof"] = "AAAA" }); + Assert.True(unknown.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest); + + string token = await _backend.CreateJoinTokenAsync(maxUses: 1); + await using AgentSimulator first = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("once")); + InvalidOperationException reuse = await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("twice"))); + Assert.Contains("401", reuse.Message); + Assert.Contains("fleet_join_token_invalid", reuse.Message); + + // 吊销之后不能再用 + string revoked = await _backend.CreateJoinTokenAsync(maxUses: 5); + JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); + string id = tokens["items"]!.AsArray().First(t => t!["revoked_at"] is null && t["max_uses"]!.GetValue() == 5)!["id"]!.GetValue(); + using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + id); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + await Assert.ThrowsAsync(() => AgentSimulator.EnrollAsync(_backend.Anonymous, revoked, _backend.UniqueName("revoked"))); + } + + [Fact] + public async Task Enrollment_RejectsAProofSignedByAnotherKey() + { + string token = await _backend.CreateJoinTokenAsync(); + using var other = Fleet.Identity.NodeIdentity.Generate(); + using var mine = Fleet.Identity.NodeIdentity.Generate(); + string pubkey = Convert.ToBase64String(mine.PublicKeySpki); + string hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); + string forged = Convert.ToBase64String(other.Sign(Fleet.Identity.SignaturePurpose.Enroll, hash, pubkey)); + using HttpResponseMessage response = await PostEnrollAsync(new JsonObject + { + ["join_token"] = token, + ["name"] = _backend.UniqueName("forged"), + ["pubkey"] = pubkey, + ["tls_spki"] = Convert.ToBase64String(new byte[32]), + ["proof"] = forged, + }); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal("fleet_proof_invalid", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + // 令牌没有被消耗:用正确的证明仍然可以注册 + await using AgentSimulator ok = await AgentSimulator.EnrollAsync(_backend.Anonymous, token, _backend.UniqueName("honest")); + Assert.Equal("active", ok.EnrollmentStatus); + } + + [Fact] + public async Task ConnectionWithAWrongSignature_IsRejected_AndLeaksNothing() + { + await using AgentSimulator real = await _backend.EnrollOnlyAsync(); + await using AgentSimulator stranger = await _backend.EnrollOnlyAsync(); + + // stranger 用自己的私钥冒充 real 的节点 ID:签名对不上 + AuthAttempt impostor = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => stranger.SignAuth(real.NodeId, nonce)); + Assert.False(impostor.Ok); + Assert.Equal("fleet_unauthorized", impostor.ErrorCode); + + // 节点不存在与签名错误返回同样的错误,不泄露节点是否存在 + var ghost = new Fleet.Core.NodeId("agt_does_not_exist"); + AuthAttempt unknown = await AgentWire.AuthenticateAsync(_backend.BaseAddress, ghost, nonce => stranger.SignAuth(ghost, nonce)); + Assert.Equal(impostor.ErrorCode, unknown.ErrorCode); + + // 正主仍然可以连接 + AuthAttempt genuine = await AgentWire.AuthenticateAsync(_backend.BaseAddress, real.NodeId, nonce => real.SignAuth(real.NodeId, nonce)); + Assert.True(genuine.Ok); + } + + [Fact] + public async Task PendingAgent_ConnectsButGetsNoStateAndItsChunksAreIgnored_UntilApproved() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false); + Assert.Equal("pending", agent.EnrollmentStatus); + Assert.Equal("pending", agent.AuthStatus); + await Task.Delay(300); + Assert.Empty(agent.TaskSets); + + RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 30_000); + chunk.Header.AgentId = agent.NodeId; + await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk))); + await Task.Delay(300); + JsonNode chunks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.NodeId.Value); + Assert.Empty(chunks["items"]!.AsArray()); + + using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/approve", null); + Assert.Equal(HttpStatusCode.OK, approve.StatusCode); + TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + Assert.NotNull(set); + } + + [Fact] + public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + + using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/disable", null); + Assert.Equal(HttpStatusCode.OK, disable.StatusCode); + await agent.WaitUntilAsync(() => agent.Revoked); + await BackendFixture.EventuallyAsync(async () => !(await AgentAsync(agent.NodeId.Value))["online"]!.GetValue()); + + AuthAttempt again = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => agent.SignAuth(agent.NodeId, nonce)); + Assert.False(again.Ok); + Assert.Equal("fleet_node_disabled", again.ErrorCode); + + using HttpResponseMessage enable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/enable", null); + Assert.Equal(HttpStatusCode.OK, enable.StatusCode); + } + + [Fact] + public async Task SameNameEnrollment_GoesPending_ApprovalConflicts_AndTakeoverReplacesTheKey() + { + string name = _backend.UniqueName("jp"); + await using AgentSimulator original = await _backend.ConnectAgentAsync(name); + await using AgentSimulator replacement = await _backend.EnrollOnlyAsync(name); + Assert.Equal("pending", replacement.EnrollmentStatus); + + using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/approve", null); + Assert.Equal(HttpStatusCode.Conflict, approve.StatusCode); + Assert.Equal("name_conflict", JsonNode.Parse(await approve.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage takeover = await _backend.Admin.PostAsync($"/api/admin/agents/{replacement.NodeId.Value}/takeover", null); + Assert.Equal(HttpStatusCode.OK, takeover.StatusCode); + Assert.Equal(original.NodeId.Value, JsonNode.Parse(await takeover.Content.ReadAsStringAsync())!["id"]!.GetValue()); // 旧 ID 保留,历史数据不丢 + + using HttpResponseMessage pendingGone = await _backend.Admin.GetAsync("/api/admin/agents/" + replacement.NodeId.Value); + Assert.Equal(HttpStatusCode.NotFound, pendingGone.StatusCode); + await original.WaitUntilAsync(() => original.Revoked); // 旧密钥的连接被断开 + } + + [Fact] + public async Task Patch_RenamesWithConflictDetection_AndHonoursIfMatch() + { + await using AgentSimulator a = await _backend.ConnectAgentAsync(); + await using AgentSimulator b = await _backend.ConnectAgentAsync(); + JsonNode view = await AgentAsync(b.NodeId.Value); + string name = view["name"]!.GetValue(); + + using HttpResponseMessage conflict = await _backend.Admin.PatchAsync( + $"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = name }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); + + using HttpResponseMessage get = await _backend.Admin.GetAsync("/api/admin/agents/" + a.NodeId.Value); + string etag = get.Headers.ETag!.Tag; + using var stale = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk") }, "application/merge-patch+json") }; + stale.Headers.TryAddWithoutValidation("If-Match", "\"999999\""); + using HttpResponseMessage precondition = await _backend.Admin.SendAsync(stale); + Assert.Equal(HttpStatusCode.PreconditionFailed, precondition.StatusCode); + + using var fresh = new HttpRequestMessage(HttpMethod.Patch, "/api/admin/agents/" + a.NodeId.Value) { Content = BackendFixture.Json(new JsonObject { ["labels"] = new JsonArray("hk"), ["public_visible"] = false }, "application/merge-patch+json") }; + fresh.Headers.TryAddWithoutValidation("If-Match", etag); + using HttpResponseMessage ok = await _backend.Admin.SendAsync(fresh); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + JsonNode updated = JsonNode.Parse(await ok.Content.ReadAsStringAsync())!; + Assert.Equal("hk", updated["labels"]![0]!.GetValue()); + Assert.False(updated["public_visible"]!.GetValue()); + Assert.NotEqual(etag, ok.Headers.ETag!.Tag); + + using HttpResponseMessage invalid = await _backend.Admin.PatchAsync( + $"/api/admin/agents/{a.NodeId.Value}", BackendFixture.Json(new JsonObject { ["name"] = "bad name!" }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + Assert.NotNull(JsonNode.Parse(await invalid.Content.ReadAsStringAsync())!["errors"]!["name"]); + } + + [Fact] + public async Task TaskOverride_ChangesTheDesiredStateAndBumpsTheVersion() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo"); + + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "line", + ["kind"] = "udp_echo", + ["agent_id"] = agent.NodeId.Value, + ["base_task_id"] = udp.Id.Value, + ["params"] = new JsonObject { ["rate_hz"] = 20 }, + })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString()); + + TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20)); + Assert.Equal(2, updated.Tasks.Count); + Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64()); + + // 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响) + string overrideId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue(); + using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, disable.StatusCode); + TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo")); + Assert.Single(disabled.Tasks); + } + + [Fact] + public async Task Tasks_ValidateParamsKindsBudgetAndTargets() + { + using HttpResponseMessage badParams = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 }, + })); + Assert.Equal(HttpStatusCode.BadRequest, badParams.StatusCode); + JsonNode problem = JsonNode.Parse(await badParams.Content.ReadAsStringAsync())!; + Assert.Contains("between 1 and 200", problem["errors"]!["params.rate_hz"]![0]!.GetValue()); + Assert.NotNull(problem["errors"]!["params.bogus"]); + + using HttpResponseMessage unknownKind = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "warp_drive" })); + Assert.Equal(HttpStatusCode.NotFound, unknownKind.StatusCode); + Assert.Equal("unknown_kind", JsonNode.Parse(await unknownKind.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage overBudget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 }, + })); + Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode); + Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_echo" })); + Assert.Equal(HttpStatusCode.BadRequest, wrongScope.StatusCode); + + // 目标任务:只接受 IP(不做 DNS 解析),需要指定 Agent + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + using HttpResponseMessage dns = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "example.com", + })); + Assert.Equal(HttpStatusCode.BadRequest, dns.StatusCode); + using HttpResponseMessage target = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1", + })); + Assert.Equal(HttpStatusCode.Created, target.StatusCode); + TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target")); + Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target").Target); + + using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["schedule"] = "every:30" })); + Assert.Equal(HttpStatusCode.BadRequest, onceSchedule.StatusCode); + } + + [Fact] + public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + agent.RejectNextState = [new Fleet.Rpc.RejectedItem { Id = "tsk_x", Reason = "policy_denied" }]; + + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject + { + ["scope"] = "target", ["kind"] = "connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443", + })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["rejected"]!.AsArray().Count == 1); + Assert.Equal("policy_denied", (await AgentAsync(agent.NodeId.Value))["rejected"]![0]!["reason"]!.GetValue()); + } + + [Fact] + public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("no-store", created.Headers.CacheControl!.ToString()); + JsonNode identity = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; + string id = identity["probe_id"]!.GetValue(); + using var key1 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(identity["private_key"]!.GetValue())); + Assert.Contains(identity["bootstrap"]!.AsArray(), b => b!["agent_id"]!.GetValue() == agent.NodeId.Value); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key1.PublicKeySpki))); + + // The panel lists it; two probes can coexist and a duplicate name is refused + JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes"); + Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue() == id && !p["revoked"]!.GetValue()); + string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + id))["name"]!.GetValue(); + using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = name })); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + + // Rename + using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" })); + Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue()); + + // Rotate: same id, new key; the old key leaves every agent's list + using HttpResponseMessage rotated = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null); + JsonNode regenerated = JsonNode.Parse(await rotated.Content.ReadAsStringAsync())!; + Assert.Equal(id, regenerated["probe_id"]!.GetValue()); + Assert.NotEqual(identity["private_key"]!.GetValue(), regenerated["private_key"]!.GetValue()); + using var key2 = Fleet.Identity.NodeIdentity.FromPkcs8(Convert.FromBase64String(regenerated["private_key"]!.GetValue())); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == Convert.ToBase64String(key2.PublicKeySpki))); + + // Revoke: gone from the peer list, kept in the panel as revoked; it cannot be rotated any more + using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null); + Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue()); + await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id)); + using HttpResponseMessage rotateRevoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null); + Assert.Equal(HttpStatusCode.Conflict, rotateRevoked.StatusCode); + + // Delete + using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id); + Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); + } + + [Fact] + public async Task ProbeAgentList_IsServedToAgents_AndPushedWhenTheSetOfAgentsChanges() + { + await using AgentSimulator first = await _backend.ConnectAgentAsync(); + ProbeAgentsResult list = await first.RequestProbeAgentsAsync(); + Assert.Contains(list.Agents, a => a.AgentId == first.NodeId && a.Host == "203.0.113.10" && a.TcpPort == 7443); + Assert.All(list.Agents, a => Assert.Equal(32, Convert.FromBase64String(a.Spki).Length)); + + await using AgentSimulator second = await _backend.ConnectAgentAsync(); + ProbeAgentsResult pushed = await WaitForAsync(first, l => l.Agents.Any(a => a.AgentId == second.NodeId)); + Assert.True(pushed.Version > list.Version); + + using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{second.NodeId.Value}/disable", null); + Assert.Equal(HttpStatusCode.OK, disable.StatusCode); + await WaitForAsync(first, l => l.Agents.All(a => a.AgentId != second.NodeId)); + } + + private static async Task WaitForAsync(AgentSimulator agent, Func predicate) + { + ProbeAgentsResult? found = null; + await BackendFixture.EventuallyAsync(() => + { + found = agent.ProbeAgentLists.ToArray().LastOrDefault(predicate); + return Task.FromResult(found is not null); + }); + return found!; + } + + private Task PostEnrollAsync(JsonNode body) => + _backend.Anonymous.PostAsync("/api/agent/v1/enroll", BackendFixture.Json(body)); +} diff --git a/backend/tests/Monitor.Backend.Tests/HostTests.cs b/backend/tests/Monitor.Backend.Tests/HostTests.cs new file mode 100644 index 0000000..6babd41 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/HostTests.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Nodes; +using Monitor.Agent; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +public sealed class HostSamplerTests +{ + private const string Stat1 = "cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 0 0 0 0 0\n"; + private const string Stat2 = "cpu 200 0 200 800 100 0 0 0 0 0\n"; // 比上一次:总时间 +300,其中空闲 +100 + + private const string Dev1 = "Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 10 0 0 0 0 0 0 5000 10 0 0 0 0 0 0\ndocker0: 777 1 0 0 0 0 0 0 777 1 0 0 0 0 0 0\n"; + private const string Dev2 = "Inter-| Receive\n face |bytes\n eth0: 3000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0\n"; + + [Fact] + public void Parsers_ReadTheProcFormats_AndSkipLoopbackAndContainerInterfaces() + { + Assert.Equal((1000L, 800L), HostSampler.ParseCpu(Stat1)); + Assert.Equal((8L * 1024 * 1024, 2L * 1024 * 1024), HostSampler.ParseMemory("MemTotal: 8192 kB\nMemFree: 100 kB\nMemAvailable: 6144 kB\n")); + Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1)); + Assert.Equal(3600L, HostSampler.ParseUptime("3600.55 7000.1\n")); + Assert.Null(HostSampler.ParseNetwork("nothing useful")); + } + + [Fact] + public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads() + { + var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider(); + string stat = Stat1; + string dev = Dev1; + var sampler = new HostSampler(time, path => path switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250)); + HostSnapshot first = sampler.Sample(); + Assert.Null(first.CpuPercent); // 第一次没有基线 + Assert.Null(first.NetRxBps); + Assert.Equal(250, first.DiskUsed); + Assert.Equal(100, first.UptimeSeconds); + + time.Advance(TimeSpan.FromSeconds(2)); + stat = Stat2; + dev = Dev2; + HostSnapshot second = sampler.Sample(); + Assert.Equal(66.7, second.CpuPercent); // 总时间 +300、空闲 +100:忙碌 200/300 + Assert.Equal(1000, second.NetRxBps); // (3000 - 1000) / 2 s + Assert.Equal(2000, second.NetTxBps); + Assert.Equal(3000, second.NetRxTotal); + } +} + +[Collection("backend")] +public sealed class HostStatusTests +{ + private readonly BackendFixture _backend; + + public HostStatusTests(BackendFixture backend) + { + _backend = backend; + } + + private static HostSnapshot Snapshot(double cpu) => new() + { + CpuPercent = cpu, MemUsed = 2L << 30, MemTotal = 8L << 30, DiskUsed = 10L << 30, DiskTotal = 40L << 30, + NetRxTotal = 600L << 30, NetTxTotal = 180L << 30, NetRxBps = 2500, NetTxBps = 4800, UptimeSeconds = 3_000_000, + }; + + [Fact] + public async Task AgentHostSnapshots_AreVisibleToTheDisplayInRealTime_StoredAsHistory_AndNeverCarryModelsOrAddresses() + { + await _backend.SiteMustBePublicAsync(); + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + string id = agent.NodeId.Value; + + await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts"); + await agent.SendHostAsync(Snapshot(18.5)); + SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["agent_id"]!.GetValue() == id); + Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue()); + Assert.True(live.Data["online"]!.GetValue()); + + // REST:最新值,没有过期 + JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); + JsonNode item = list["items"]!.AsArray().Single(i => i!["agent_id"]!.GetValue() == id)!; + Assert.False(item["stale"]!.GetValue()); + Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue()); + + // 展示页能拿到的只有汇总数字:整份响应里没有型号、主机名、地址之类的字段 + string text = list.ToJsonString(); + foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" }) + { + Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase); + } + + // 线路详情也不泄漏 Probe 的地址和版本 + string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString(); + Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("probe_version", line, StringComparison.OrdinalIgnoreCase); + + // 历史:每分钟一个样本 + JsonNode history = null!; + await BackendFixture.EventuallyAsync(async () => + { + history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/agents/{id}/host?range=1h"); + return history["values"]!["cpu"]!.AsArray().Any(v => v is not null); + }); + Assert.Equal(25.0, history["values"]!["mem"]!.AsArray().Last(v => v is not null)!.GetValue()); + Assert.Equal(2500, history["values"]!["rx_bps"]!.AsArray().Last(v => v is not null)!.GetValue()); + + // 隐藏的 Agent 对匿名访客不可见 + await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false })); + JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); + Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["agent_id"]!.GetValue() == id); + using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/agents/{id}/host"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/IngestAndQueryTests.cs b/backend/tests/Monitor.Backend.Tests/IngestAndQueryTests.cs new file mode 100644 index 0000000..6b6292b --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/IngestAndQueryTests.cs @@ -0,0 +1,408 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class IngestAndQueryTests +{ + private const long Minute = 60_000; + + private readonly BackendFixture _backend; + + public IngestAndQueryTests(BackendFixture backend) + { + _backend = backend; + } + + /// 当前分钟的起点:测试数据放在已经结束的分钟里,这样持久路径会立即计算这些窗口。 + private static long ClosedMinuteStart(int minutesAgo) => + ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / Minute) - minutesAgo) * Minute; + + /// 把 [baseMs, baseMs + chunks × 10 s) 的数据送给后端:每个块 10 s、100 个包;每 个包丢一个回复。 + private static async Task SendLineAsync(AgentSimulator agent, long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20) + { + for (int k = 0; k < chunks; k++) + { + long start = baseMs + (k * 10_000L); + RawChunk udp = ChunkFactory.Echo( + agent.NodeId, "udp_echo", start, 100, 100, + i => new EchoPacket(false, lossEvery > 0 && i % lossEvery == 0, (long)(owdMs * 1000), (long)(owdMs * 1000))); + Assert.True(await agent.SendChunkAsync(udp)); + if (tcp) + { + long probeBase = ChunkFactory.ProbeUs(0); + RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (probeBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400)))); + RawChunk tcpChunk = ChunkFactory.Echo( + agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]); + Assert.True(await agent.SendChunkAsync(tcpChunk)); + } + } + } + + private async Task<(AgentSimulator Agent, string Name)> LineAsync(long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20) + { + AgentSimulator agent = await _backend.ConnectAgentAsync(); + await SendLineAsync(agent, baseMs, chunks, lossEvery, tcp, owdMs); + string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["name"]!.GetValue(); + return (agent, name); + } + + private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + + private async Task SeriesAsync(string agentId, string query) => + await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agentId)}/series?{query}"); + + [Fact] + public async Task Line_ReportsWhenItsDataStarted_SoTheDisplayOnlyOffersRangesThatHaveData() + { + await _backend.SiteMustBePublicAsync(); + long baseMs = ClosedMinuteStart(30); + (AgentSimulator agent, _) = await LineAsync(baseMs, 6); + await using (agent) + { + JsonNode before = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}"); + DateTimeOffset since = before["data_since"]!.GetValue(); + // 精确到秒(细的层);如果细的层已经被保留策略清掉,只剩 5 分钟层,起点最多早 5 分钟 + Assert.InRange(since.ToUnixTimeMilliseconds(), baseMs - 300_000, baseMs + 70_000); + + // 一条没有任何数据的线路:没有 data_since + await using AgentSimulator empty = await _backend.ConnectAgentAsync(); + JsonNode none = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(empty.NodeId.Value)}"); + Assert.Null(none["data_since"]); + } + } + + [Fact] + public async Task Series_AnchorLatest_ShowsTheLastWindowOfALineThatHasStoppedReporting() + { + long baseMs = ClosedMinuteStart(20); + (AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6, tcp: false); // one minute of data, 20 minutes ago + await using (agent) + { + string id = agent.NodeId.Value; + + // Anchored at now: the last 5 minutes are empty + JsonNode atNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total"); + Assert.Null(atNow["summary"]!["samples"]!["udp_echo"]); + + // Anchored at the latest sample: the window ends where the data ends and carries all of it + JsonNode anchored = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=latest"); + long to = DateTimeOffset.Parse(anchored["to"]!.GetValue(), System.Globalization.CultureInfo.InvariantCulture).ToUnixTimeMilliseconds(); + Assert.InRange(to, baseMs + (50 * 1000), baseMs + (80 * 1000)); + Assert.Equal(600, anchored["summary"]!["samples"]!["udp_echo"]!.GetValue()); + Assert.False(anchored["live"]!.GetValue()); + + // anchor=now is the default behaviour; anything else is rejected + JsonNode explicitNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=now"); + Assert.Null(explicitNow["summary"]!["samples"]!["udp_echo"]); + using HttpResponseMessage bad = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&anchor=yesterday"); + Assert.Equal(System.Net.HttpStatusCode.BadRequest, bad.StatusCode); + } + } + + [Fact] + public async Task Chunks_AreStoredAndAcknowledged_DuplicatesAndGarbageAreDiscardedWithAReason() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + long start = ClosedMinuteStart(3); + RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, start); + + Assert.True(await agent.SendChunkAsync(chunk), "the first delivery is stored"); + Assert.False(await agent.SendChunkAsync(chunk), "the second delivery is acknowledged as a duplicate"); + + // 损坏的块:确认掉(否则节点会无限重发)并记入事件 + var garbage = new ChunkId(Guid.CreateVersion7()); + var ack = new TaskCompletionSource(); + byte[] corrupt = RawChunkCodec.Encode(ChunkFactory.UdpEchoClean(agent.NodeId, start)); + corrupt[^1] ^= 0xFF; + await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, garbage.Value, corrupt)); + + // 冒名的块:agent_id 与认证的节点不一致 + RawChunk spoofed = ChunkFactory.UdpEchoClean(new NodeId("agt_someone_else"), start); + await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.RawChunk, spoofed.Header.ChunkId.Value, RawChunkCodec.Encode(spoofed))); + + await BackendFixture.EventuallyAsync(async () => + { + JsonNode events = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected"); + return events["items"]!.AsArray().Count >= 2; + }); + JsonNode rejected = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected"); + string details = string.Join('|', rejected["items"]!.AsArray().Select(e => e!["detail"]!.GetValue())); + Assert.Contains("undecodable chunk", details); + Assert.Contains("does not match the authenticated node", details); + + JsonNode stored = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}"); + Assert.Single(stored["items"]!.AsArray()); + } + + [Fact] + public async Task Series_HotAndPersistedLayersGiveTheSameExactSummary() + { + long baseMs = ClosedMinuteStart(4); + (AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 18, lossEvery: 10); + await using (agent) + { + long to = baseMs + (3 * Minute); + string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp,tcp"; + + JsonNode hot = await SeriesAsync(agent.NodeId.Value, range + "&step=1s"); + Assert.Equal("hot", hot["source"]!.GetValue()); + Assert.Equal(180, hot["t"]!.AsArray().Count); + + JsonNode minute = null!; + await BackendFixture.EventuallyAsync(async () => + { + minute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m"); + // 窗口会随着后续的块到达而重算:等到 18 个块的样本都计入之后再比较 + return minute["source"]!.GetValue() == "1m" && minute["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1800 + && minute["summary"]!["samples"]!["tcp_echo"]?.GetValue() == 3600; + }); + + JsonNode hotSummary = hot["summary"]!["metrics"]!; + JsonNode minuteSummary = minute["summary"]!["metrics"]!; + Assert.Equal(10.0, hotSummary["loss.total"]!.GetValue(), 6); + foreach (string id in new[] { "loss.total", "loss.up", "loss.down", "rtt.p50", "rtt.p95", "rtt.p99", "tcp.rtt.p95", "tcp.retrans", "jitter.down.p95" }) + { + Assert.True( + hotSummary[id] is null == minuteSummary[id] is null && (hotSummary[id] is null || Math.Abs(hotSummary[id]!.GetValue() - minuteSummary[id]!.GetValue()) < 1e-9), + $"{id}: hot={hotSummary[id]} persisted={minuteSummary[id]}"); + } + + Assert.Equal(1800, hot["summary"]!["samples"]!["udp_echo"]!.GetValue()); + Assert.Equal(hot["summary"]!["samples"]!["udp_echo"]!.GetValue(), minute["summary"]!["samples"]!["udp_echo"]!.GetValue()); + Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_echo"]!.GetValue()); + + // RTT = 2 × 20 ms,直方图的相对误差 ≤ 4.5% + Assert.InRange(minuteSummary["rtt.p95"]!.GetValue(), 40 * 0.955, 40 * 1.045); + Assert.Equal(3, minute["t"]!.AsArray().Count); + Assert.Equal(1.0, minute["summary"]!["coverage"]!.GetValue(), 3); + + // 汇总是精确合并,不是各点的平均:每个分钟桶的丢包率都是 10%,整体也是 10% + Assert.All(minute["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(10.0, v!.GetValue(), 6)); + } + } + + [Fact] + public async Task Series_TenSecondLayer_AndTheFivePointFiveSecondStepsDegradeGracefully() + { + long baseMs = ClosedMinuteStart(3); + (AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6); + await using (agent) + { + string range = $"from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&preset=udp"; + JsonNode tenSeconds = await SeriesAsync(agent.NodeId.Value, range + "&step=10s"); + Assert.Equal("10s", tenSeconds["source"]!.GetValue()); + Assert.Equal(6, tenSeconds["t"]!.AsArray().Count); + Assert.All(tenSeconds["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(0.0, v!.GetValue())); + + // 15 s 不能整除任何层的宽度(热层以外):对更早的数据降级到可用的最细层,并如实标记 + long farBase = ClosedMinuteStart(120); + JsonNode degraded = await SeriesAsync(agent.NodeId.Value, $"from={Iso(farBase)}&to={Iso(farBase + (10 * Minute))}&step=15s&preset=udp"); + Assert.True(degraded["degraded"]!.GetValue()); + Assert.Equal("30s", degraded["step"]!.GetValue()); + } + } + + [Fact] + public async Task Overview_RanksLinesByScore_AndTheLossyLineScoresLower() + { + await _backend.SiteMustBePublicAsync(); + long baseMs = ClosedMinuteStart(3); + (AgentSimulator good, string goodName) = await LineAsync(baseMs, 12); + (AgentSimulator bad, string badName) = await LineAsync(baseMs, 12, lossEvery: 5, owdMs: 60); + await using (good) + await using (bad) + { + string range = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&preset=udp,scores"; + JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range); + JsonNode[] lines = [.. overview["lines"]!.AsArray().Select(l => l!)]; + int goodIndex = Array.FindIndex(lines, l => l["name"]!.GetValue() == goodName); + int badIndex = Array.FindIndex(lines, l => l["name"]!.GetValue() == badName); + Assert.True(goodIndex >= 0 && badIndex >= 0); + Assert.True(goodIndex < badIndex, "the clean line must rank above the lossy one (default sort: first requested score, descending)"); + + double goodScore = lines[goodIndex]["summary"]!["metrics"]!["score.base"]!.GetValue(); + double badScore = lines[badIndex]["summary"]!["metrics"]!["score.base"]!.GetValue(); + Assert.True(goodScore > 90, $"clean line scored {goodScore}"); + Assert.True(badScore < goodScore - 30, $"lossy line scored {badScore}"); + Assert.Equal(20.0, lines[badIndex]["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["udp_echo"]!.GetValue()); + + // 按指标排序:loss.total 升序时好线路在前 + JsonNode byLoss = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range + "&sort=loss.total&order=asc"); + string[] names = [.. byLoss["lines"]!.AsArray().Select(l => l!["name"]!.GetValue())]; + Assert.True(Array.IndexOf(names, goodName) < Array.IndexOf(names, badName)); + } + } + + [Fact] + public async Task Compare_Distribution_AndHeatmap_AreConsistentWithTheSeries() + { + long baseMs = ClosedMinuteStart(3); + (AgentSimulator a, _) = await LineAsync(baseMs, 12, owdMs: 20); + (AgentSimulator b, _) = await LineAsync(baseMs, 12, owdMs: 40); + await using (a) + await using (b) + { + string window = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}"; + JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={_backend.Line(a.NodeId.Value)},{_backend.Line(b.NodeId.Value)}&metric=rtt.p50&{window}&step=10s"); + Assert.Equal(12, compare["t"]!.AsArray().Count); + double rttA = compare["summary"]![_backend.Line(a.NodeId.Value)]!.GetValue(); + double rttB = compare["summary"]![_backend.Line(b.NodeId.Value)]!.GetValue(); + Assert.InRange(rttA, 40 * 0.955, 40 * 1.045); + Assert.InRange(rttB, 80 * 0.955, 80 * 1.045); + + JsonNode distribution = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=rtt&{window}"); + Assert.Equal(1200, distribution["count"]!.GetValue()); + Assert.Equal(distribution["count"]!.GetValue(), distribution["buckets"]!.AsArray().Sum(x => x!["count"]!.GetValue())); + Assert.InRange(distribution["p50"]!.GetValue(), 80 * 0.955, 80 * 1.045); + + // step=1m 读的是持久层,由后台分析异步算出:等它完整 + JsonNode heatmap = null!; + await BackendFixture.EventuallyAsync(async () => + { + heatmap = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/heatmap?metric=rtt&{window}&step=1m"); + return heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue())) == 1200; + }); + Assert.Equal(2, heatmap["t"]!.AsArray().Count); + Assert.Equal(2, heatmap["counts"]!.AsArray().Count); + Assert.All(heatmap["counts"]!.AsArray(), row => Assert.Equal(heatmap["edges"]!.AsArray().Count, row!.AsArray().Count)); + Assert.Equal(1200, heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue()))); + + using HttpResponseMessage notHistogram = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=loss.up&{window}"); + Assert.Equal(HttpStatusCode.UnprocessableEntity, notHistogram.StatusCode); + } + } + + [Fact] + public async Task Queries_ValidateTheirParameters_WithActionableErrors() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + string id = agent.NodeId.Value; + + async Task<(HttpStatusCode Status, string Code, string Detail)> Fail(string url) + { + using HttpResponseMessage response = await _backend.Admin.GetAsync(url); + JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return (response.StatusCode, problem["code"]!.GetValue(), problem["detail"]?.GetValue() ?? ""); + } + + Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=abc"))); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z"))); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&step=7s"))); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_metric"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&metrics=bogus.metric"))); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&preset=zzz"))); + Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&from=2026-01-01T00:00:00Z"))); + Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), Trim(await Fail("/api/public/lines/agt_nope/series?range=1h"))); + + (HttpStatusCode, string, string Detail) tooLarge = await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=30d&step=1m"); + Assert.Equal(HttpStatusCode.UnprocessableEntity, tooLarge.Item1); + Assert.Equal("range_too_large", tooLarge.Item2); + Assert.Contains("step=", tooLarge.Detail); // 给出可行的替代方案,而不是悄悄截断 + + // 重复的指标 id 只算一个;真正超过 32 个不同的指标才报错 + string repeated = string.Join(',', Enumerable.Range(0, 40).Select(_ => "rtt.p95")); + using HttpResponseMessage deduplicated = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={repeated}"); + Assert.Equal(HttpStatusCode.OK, deduplicated.StatusCode); + JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/public/catalog/metrics"); + string distinct = string.Join(',', catalog["items"]!.AsArray().Select(m => m!["id"]!.GetValue()).Take(40)); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "too_many_metrics"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={distinct}"))); + + static (HttpStatusCode, string) Trim((HttpStatusCode Status, string Code, string Detail) f) => (f.Status, f.Code); + } + + [Fact] + public async Task ClosedRanges_AreImmutableAndConditional_RelativeRangesAreNeverCached() + { + long baseMs = ClosedMinuteStart(10); + (AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false); + await using (agent) + { + string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=10s&preset=udp"; + using HttpResponseMessage closed = await _backend.Admin.GetAsync(url); + Assert.Equal("public, max-age=31536000, immutable", closed.Headers.CacheControl!.ToString()); + string etag = closed.Headers.ETag!.Tag; + + using var conditional = new HttpRequestMessage(HttpMethod.Get, url); + conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag)); + using HttpResponseMessage notModified = await _backend.Admin.SendAsync(conditional); + Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode); + + using HttpResponseMessage relative = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m&preset=udp"); + Assert.Equal("no-store", relative.Headers.CacheControl!.ToString()); + Assert.Null(relative.Headers.ETag); + } + } + + [Fact] + public async Task HiddenLines_AreInvisibleToAnonymousVisitors_ButNotToSignedInOnes() + { + await _backend.SiteMustBePublicAsync(); + long baseMs = ClosedMinuteStart(3); + (AgentSimulator agent, string name) = await LineAsync(baseMs, 3, tcp: false); + await using (agent) + { + using HttpResponseMessage hide = await _backend.Admin.PatchAsync( + "/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, hide.StatusCode); + + JsonNode anonymousOverview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m"); + Assert.DoesNotContain(anonymousOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue() == name); + using HttpResponseMessage anonymousSeries = await _backend.Anonymous.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m"); + Assert.Equal(HttpStatusCode.NotFound, anonymousSeries.StatusCode); // 404,不是 403:不暴露线路是否存在 + + JsonNode adminOverview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=15m"); + Assert.Contains(adminOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue() == name); + } + } + + [Fact] + public async Task LateChunks_UpdateAlreadyComputedWindows() + { + long baseMs = ClosedMinuteStart(5); + (AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false); + await using (agent) + { + string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=1m&preset=udp"; + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600); + + // Agent 重连后补传一个迟到的块(落在同一个分钟窗口里) + RawChunk late = ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + 5_000, 100, 10, _ => new EchoPacket(false, false, 20_000, 20_000)); + Assert.True(await agent.SendChunkAsync(late)); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 700, because: "the late chunk must re-enter the persistent path"); + } + } + + [Fact] + public async Task RolledUpLayers_AgreeWithTheMinuteLayer() + { + long baseMs = ClosedMinuteStart(7) / (5 * Minute) * (5 * Minute); // 对齐到 5 分钟,整段都在已经结束的时间里 + (AgentSimulator agent, _) = await LineAsync(baseMs, 30, lossEvery: 20, tcp: false); + await using (agent) + { + long to = baseMs + (5 * Minute); + string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp"; + JsonNode oneMinute = null!; + await BackendFixture.EventuallyAsync(async () => + { + oneMinute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m"); + return oneMinute["summary"]!["samples"]!["udp_echo"]?.GetValue() == 3000; + }); + + // 5 分钟层由降采样把 5 个 1 分钟窗口精确合并而来 + JsonNode fiveMinutes = null!; + await BackendFixture.EventuallyAsync(async () => + { + fiveMinutes = await SeriesAsync(agent.NodeId.Value, range + "&step=5m"); + return fiveMinutes["source"]!.GetValue() == "5m" && fiveMinutes["summary"]!["samples"]!["udp_echo"]?.GetValue() == 3000; + }); + Assert.Equal(oneMinute["summary"]!["metrics"]!["loss.total"]!.GetValue(), fiveMinutes["summary"]!["metrics"]!["loss.total"]!.GetValue(), 9); + Assert.Equal(oneMinute["summary"]!["metrics"]!["rtt.p99"]!.GetValue(), fiveMinutes["summary"]!["metrics"]!["rtt.p99"]!.GetValue(), 9); + } + } +} diff --git a/backend/tests/Monitor.Backend.Tests/LiveTests.cs b/backend/tests/Monitor.Backend.Tests/LiveTests.cs new file mode 100644 index 0000000..f29d959 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/LiveTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Text.Json.Nodes; +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class LiveTests +{ + private readonly BackendFixture _backend; + + public LiveTests(BackendFixture backend) + { + _backend = backend; + } + + private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + + [Fact] + public async Task Stream_StartsWithASnapshot_ThenPushesNewBucketsAndNodeChanges() + { + await _backend.SiteMustBePublicAsync(); + AgentSimulator agent = await _backend.ConnectAgentAsync(); + string id = agent.NodeId.Value; + + await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, $"/api/public/live?topics=nodes,series:{_backend.Line(id)}:1s:udp,tcp,overview:15m:udp"); + Assert.Equal("text/event-stream", sse.ContentType); + + // 快照:当前在线状态、汇总排行、该线路的序列 + SseEvent node = await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue() == id); + Assert.True(node.Data!["online"]!.GetValue()); + await sse.WaitForAsync(e => e.Name == "overview"); + await sse.WaitForAsync(e => e.Name == "series" && e.Data!["agent_id"]!.GetValue() == id); + + // 新块到达之后:推送新的 1s 桶(列式)和最新的汇总 + RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", Now() - 10_000, 100, 100, i => new EchoPacket(false, i % 10 == 0, 20_000, 20_000)); + Assert.True(await agent.SendChunkAsync(chunk)); + SseEvent series = await sse.WaitForAsync(e => e.Name == "series" && e.Data!["summary"]!["samples"]!["udp_echo"]?.GetValue() >= 100); + Assert.True(series.Data!["live"]!.GetValue()); + Assert.Equal("1s", series.Data["step"]!.GetValue()); + Assert.Equal(series.Data["t"]!.AsArray().Count, series.Data["values"]!["loss.total"]!.AsArray().Count); + Assert.Equal(10.0, series.Data["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + + // 掉线:推送 node 事件 + await agent.DisposeAsync(); + await sse.WaitForAsync(e => e.Name == "node" && e.Data!["id"]!.GetValue() == id && !e.Data["online"]!.GetValue()); + } + + [Fact] + public async Task Stream_PushesTestStatusChanges() + { + await _backend.SiteMustBePublicAsync(); + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + using HttpResponseMessage accepted = await _backend.Admin.PostAsync( + $"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=test:" + testId); + SseEvent initial = await sse.WaitForAsync(e => e.Name == "test"); + Assert.Equal("queued", initial.Data!["status"]!.GetValue()); + Assert.Equal(3, initial.Data["phases"]!.GetValue()); + + await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Running, phase: 1); + SseEvent running = await sse.WaitForAsync(e => e.Name == "test" && e.Data!["status"]!.GetValue() == "running"); + Assert.Equal(1, running.Data!["phase"]!.GetValue()); + } + + [Fact] + public async Task Stream_RejectsBadTopics_UnknownLines_AndTooManyConnections() + { + await _backend.SiteMustBePublicAsync(); + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + + async Task<(HttpStatusCode, string)> Probe(string query) + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/public/live?" + query, HttpCompletionOption.ResponseHeadersRead); + string code = response.IsSuccessStatusCode ? "" : JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue(); + return (response.StatusCode, code); + } + + Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe("")); + Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), await Probe("topics=bogus")); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:5m:udp")); + Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), await Probe("topics=series:agt_nope:1s:udp")); + Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), await Probe($"topics=series:{_backend.Line(agent.NodeId.Value)}:1s:zzz")); + string many = string.Join(',', Enumerable.Range(1, 40).Select(i => $"test:tst_{i}")); + Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=" + many)); + + // 每个客户端最多 4 个 SSE 连接 + var open = new List(); + try + { + for (int i = 0; i < 4; i++) + { + open.Add(await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes")); + } + + Assert.Equal((HttpStatusCode.TooManyRequests, "too_many_streams"), await Probe("topics=nodes")); + } + finally + { + foreach (SseClient client in open) + { + await client.DisposeAsync(); + } + } + + // 连接关闭之后配额释放 + await BackendFixture.EventuallyAsync(async () => (await Probe("topics=nodes")).Item1 == HttpStatusCode.OK); + } + + [Fact] + public async Task Stream_FollowsTheSameVisibilityRulesAsRest() + { + await _backend.SiteMustBePublicAsync(); + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + using HttpResponseMessage hide = await _backend.Admin.PatchAsync( + "/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, hide.StatusCode); + + using HttpResponseMessage anonymous = await _backend.Anonymous.GetAsync($"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp", HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.NotFound, anonymous.StatusCode); + + await using SseClient admin = await SseClient.ConnectAsync(_backend.Admin, $"/api/public/live?topics=series:{_backend.Line(agent.NodeId.Value)}:1s:udp"); + await admin.WaitForAsync(e => e.Name == "series"); + + // 匿名的 nodes 订阅看不到被隐藏线路的上下线 + await using SseClient anonymousNodes = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=nodes"); + await Task.Delay(300); + Assert.DoesNotContain(anonymousNodes.Events, e => e.Data?["id"]?.GetValue() == agent.NodeId.Value); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/LoginRateLimitTests.cs b/backend/tests/Monitor.Backend.Tests/LoginRateLimitTests.cs new file mode 100644 index 0000000..ef1dee7 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/LoginRateLimitTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace Monitor.Backend.Tests; + +/// 登录限流按来源地址计,会影响同一地址上的其他登录,所以用独立的后端实例。 +public sealed class LoginRateLimitTests : IClassFixture +{ + private readonly StrictLoginFixture _backend; + + public LoginRateLimitTests(StrictLoginFixture backend) + { + _backend = backend; + } + + [Fact] + public async Task LoginAttempts_AreRateLimited_WithRetryAfter_AndASuccessfulLoginIsAlsoCounted() + { + using HttpClient client = _backend.NewClient(); + HttpResponseMessage? limited = null; + int attempts = 0; + for (; attempts < 15 && limited is null; attempts++) + { + HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "admin", ["password"] = "wrong" })); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + limited = response; + } + else + { + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + response.Dispose(); + } + } + + Assert.NotNull(limited); + using (limited) + { + // 前一次登录(创建管理员令牌)也占了一次配额:第 10 次之内的都是 401 + Assert.InRange(attempts, 9, 10); + Assert.Equal("too_many_requests", JsonNode.Parse(await limited.Content.ReadAsStringAsync())!["code"]!.GetValue()); + Assert.True(limited.Headers.RetryAfter!.Delta!.Value.TotalSeconds is >= 1 and <= 60); + } + + // 被限流的来源连正确的密码也不行:限流发生在校验密码之前,不能被用来猜密码 + using HttpResponseMessage correct = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); + Assert.Equal(HttpStatusCode.TooManyRequests, correct.StatusCode); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj b/backend/tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj new file mode 100644 index 0000000..d36d4b8 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + false + $(NoWarn);CA1861;CA2234;CA1860 + false + + + + + + + + + + + + + + diff --git a/backend/tests/Monitor.Backend.Tests/OnDemandTestsTests.cs b/backend/tests/Monitor.Backend.Tests/OnDemandTestsTests.cs new file mode 100644 index 0000000..f344ee8 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/OnDemandTestsTests.cs @@ -0,0 +1,277 @@ +using System.Net; +using System.Text.Json.Nodes; +using Fleet.Core; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class OnDemandTestsTests +{ + private readonly BackendFixture _backend; + + public OnDemandTestsTests(BackendFixture backend) + { + _backend = backend; + } + + private Task StartAsync(AgentSimulator agent, JsonObject body) => + _backend.Admin.PostAsync($"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(body)); + + private static JsonObject Plan(string plan, JsonObject? parameters = null) => + parameters is null ? new JsonObject { ["plan"] = plan } : new JsonObject { ["plan"] = plan, ["params"] = parameters }; + + private async Task AgentWithProbeAsync() + { + AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + return agent; + } + + private async Task TestAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id); + + [Fact] + public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + + using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder")); + Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode); + Assert.Equal("probe_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + + using HttpResponseMessage unknownPlan = await StartAsync(agent, Plan("warp_ladder")); + Assert.Equal(HttpStatusCode.NotFound, unknownPlan.StatusCode); + Assert.Equal("unknown_plan", JsonNode.Parse(await unknownPlan.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage badParam = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["nope"] = 1 })); + Assert.Equal(HttpStatusCode.BadRequest, badParam.StatusCode); + Assert.NotNull(JsonNode.Parse(await badParam.Content.ReadAsStringAsync())!["errors"]!["params.nope"]); + + using HttpResponseMessage outOfRange = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["size"] = 9000 })); + Assert.Equal(HttpStatusCode.BadRequest, outOfRange.StatusCode); + + // 超过 Agent 本地声明的码率上限(hello 里上报 200 Mbit/s) + using HttpResponseMessage overBudget = await StartAsync(agent, Plan("hy2_ladder", new JsonObject { ["mbps"] = 500 })); + Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode); + Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + using HttpResponseMessage missingAgent = await _backend.Admin.PostAsync("/api/admin/lines/agt_nope/tests", BackendFixture.Json(Plan("hy2_ladder"))); + Assert.Equal(HttpStatusCode.NotFound, missingAgent.StatusCode); + } + + [Fact] + public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores() + { + await using AgentSimulator agent = await AgentWithProbeAsync(); + using HttpResponseMessage accepted = await StartAsync(agent, Plan("hy2_ladder")); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + JsonNode started = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!; + string testId = started["id"]!.GetValue(); + Assert.Equal("/api/public/tests/" + testId, accepted.Headers.Location!.ToString()); + Assert.Equal("queued", started["status"]!.GetValue()); + + // Agent 收到按需测试:四个阶段,码率阶梯,参数已补全默认值 + await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); + TestRunParams run = agent.TestRuns.Single(); + Assert.Equal(testId, run.TestId.Value); + Assert.Equal([10d, 25d, 50d, 100d], run.Phases.Select(p => p.Params["mbps"].GetDouble()).ToArray()); + Assert.All(run.Phases, p => Assert.Equal("udp_train", p.Kind)); + Assert.All(run.Phases, p => Assert.Equal(1250, p.Params["size"].GetInt64())); + + // 同一条线路同时只能跑一个测试 + using HttpResponseMessage busy = await StartAsync(agent, Plan("hy2_ladder")); + Assert.Equal(HttpStatusCode.Conflict, busy.StatusCode); + Assert.Equal("test_busy", JsonNode.Parse(await busy.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + await agent.SendTestStatusAsync(run.TestId, TestStatuses.Running, phase: 0); + await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue() == "running"); + + long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + double[] mbps = [10, 25, 50, 100]; + int[] lossEvery = [0, 0, 5, 2]; // 50 Mbit/s 起被限速 + for (int phase = 0; phase < 4; phase++) + { + RawChunk chunk = ChunkFactory.Train(agent.NodeId, now - 60_000 + (phase * 2_000), mbps[phase], phase, seconds: 2, lossEvery: lossEvery[phase]); + chunk.Header.TestId = run.TestId; + chunk.Header.Phase = phase; + Assert.True(await agent.SendChunkAsync(chunk)); + await agent.SendTestStatusAsync(run.TestId, TestStatuses.PhaseDone, phase); + } + + await agent.SendTestStatusAsync(run.TestId, TestStatuses.Succeeded); + JsonNode done = null!; + await BackendFixture.EventuallyAsync(async () => + { + done = await TestAsync(testId); + return done["result"] is not null; + }, because: "the finalizer computes the result once every phase's chunks have arrived"); + + Assert.Equal("succeeded", done["status"]!.GetValue()); + JsonNode metrics = done["result"]!["metrics"]!; + Assert.Equal(25, metrics["hy2.sustainable_mbps"]!.GetValue()); + Assert.Equal(50, metrics["hy2.qos_onset_mbps"]!.GetValue()); + Assert.Equal(4, done["result"]!["steps"]!.AsArray().Count); + Assert.InRange(done["result"]!["steps"]![2]!["loss_pct"]!.GetValue(), 19, 21); + Assert.Equal(new double[] { 10, 25, 50, 100 }, done["result"]!["series"]!["step_mbps"]!.AsArray().Select(v => v!.GetValue()).ToArray()); + JsonNode hy2 = done["result"]!["scores"]!["hy2"]!; + Assert.False(hy2["estimated"]!.GetValue()); + Assert.InRange(hy2["score"]!.GetValue(), 20, 90); + Assert.Contains(hy2["caveats"]!.AsArray(), c => c!.GetValue() == "traffic_shape_only"); + + // 测试结果会进入线路的 hy2 评分(持续数据里没有这些指标) + await _backend.SiteMustBePublicAsync(); + JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m&metrics=score.hy2"); + JsonNode line = overview["lines"]!.AsArray().Single(l => l!["id"]!.GetValue() == _backend.Line(agent.NodeId.Value))!; + Assert.NotNull(line["summary"]!["metrics"]!["score.hy2"]); + Assert.Equal(testId, line["last_test"]!["id"]!.GetValue()); + + // 测试用的原始块不进入持续线路的指标 + JsonNode series = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=15m&metrics=train.loss"); + Assert.Null(series["summary"]!["metrics"]!["train.loss"]); + } + + [Fact] + public async Task AgentRefusal_IsSurfacedAndMarksTheTestFailed() + { + await using AgentSimulator agent = await AgentWithProbeAsync(); + agent.TestRunHandler = _ => (false, "test_busy"); + using HttpResponseMessage response = await StartAsync(agent, Plan("hy2_ladder")); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("test_busy", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); + + JsonNode tests = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests"); + JsonNode latest = tests["items"]![0]!; + Assert.Equal("failed", latest["status"]!.GetValue()); + Assert.Equal("test_busy", latest["error_code"]!.GetValue()); + + // 失败的测试不再占用线路 + agent.TestRunHandler = _ => (true, null); + using HttpResponseMessage retry = await StartAsync(agent, Plan("hy2_ladder")); + Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode); + } + + [Fact] + public async Task Cancel_ReachesTheAgent_AndOnlyTheOwningAgentCanReportStatus() + { + await using AgentSimulator agent = await AgentWithProbeAsync(); + await using AgentSimulator other = await _backend.ConnectAgentAsync(); + using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + await agent.WaitUntilAsync(() => !agent.TestRuns.IsEmpty); + + // 别的 Agent 不能汇报这个测试的状态 + await other.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded); + await Task.Delay(200); + Assert.Equal("queued", (await TestAsync(testId))["status"]!.GetValue()); + + using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null); + Assert.Equal(HttpStatusCode.OK, cancel.StatusCode); + await agent.WaitUntilAsync(() => agent.TestCancels.Any(t => t.Value == testId)); + await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Aborted); + await BackendFixture.EventuallyAsync(async () => (await TestAsync(testId))["status"]!.GetValue() == "aborted"); + + // 终态之后的状态汇报被忽略 + await agent.SendTestStatusAsync(new TestId(testId), TestStatuses.Succeeded); + await Task.Delay(200); + Assert.Equal("aborted", (await TestAsync(testId))["status"]!.GetValue()); + + using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/tests/tst_nope/cancel", null); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + } + + [Fact] + public async Task CancellingATestOfAnOfflineAgent_AbortsItImmediately() + { + AgentSimulator agent = await AgentWithProbeAsync(); + using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + await agent.DisposeAsync(); + await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue()); + + using HttpResponseMessage cancel = await _backend.Admin.PostAsync($"/api/admin/tests/{testId}/cancel", null); + Assert.Equal(HttpStatusCode.OK, cancel.StatusCode); + Assert.Equal("aborted", JsonNode.Parse(await cancel.Content.ReadAsStringAsync())!["status"]!.GetValue()); + } + + [Fact] + public async Task TestsOfHiddenLines_AreNotFoundForAnonymousVisitors() + { + await _backend.SiteMustBePublicAsync(); + await using AgentSimulator agent = await AgentWithProbeAsync(); + using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + using HttpResponseMessage visible = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId); + Assert.Equal(HttpStatusCode.OK, visible.StatusCode); + + using HttpResponseMessage hide = await _backend.Admin.PatchAsync( + "/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, hide.StatusCode); + using HttpResponseMessage hidden = await _backend.Anonymous.GetAsync("/api/public/tests/" + testId); + Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode); + } + + [Fact] + public async Task RawChunks_AreListedAndDownloadable_ExpiredOnesReportGone() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, now - 120_000); + Assert.True(await agent.SendChunkAsync(chunk)); + + JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo"); + JsonNode item = list["items"]!.AsArray().Single()!; + Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue()); + Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue()); + + using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId); + Assert.Equal("application/vnd.monitor.rawchunk", download.Content.Headers.ContentType!.MediaType); + RawChunk decoded = RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).Value; + Assert.Equal(chunk.Header.ChunkId, decoded.Header.ChunkId); + Assert.Equal(100, decoded.Find("msg")!.Count); + + using HttpResponseMessage missing = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + Guid.CreateVersion7()); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + + // UUIDv7 里的时间戳早于保留期:索引已经清理,所以是 410 而不是 404 + var longAgo = Guid.CreateVersion7(TimeProvider.System.GetUtcNow().AddDays(-400)); + using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + longAgo); + Assert.Equal(HttpStatusCode.Gone, gone.StatusCode); + Assert.Equal("raw_expired", JsonNode.Parse(await gone.Content.ReadAsStringAsync())!["code"]!.GetValue()); + } + + [Fact] + public async Task RawChunkList_PagesWithAStableCursor() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + long baseMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 300_000; + for (int k = 0; k < 5; k++) + { + Assert.True(await agent.SendChunkAsync(ChunkFactory.UdpEchoClean(agent.NodeId, baseMs + (k * 10_000L)))); + } + + var seen = new List(); + string? cursor = null; + int pages = 0; + do + { + JsonNode page = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor)); + seen.AddRange(page["items"]!.AsArray().Select(i => i!["chunk_id"]!.GetValue())); + cursor = page["next_cursor"]?.GetValue(); + pages++; + } + while (cursor is not null && pages < 10); + + Assert.Equal(5, seen.Count); + Assert.Equal(5, seen.Distinct().Count()); + Assert.Equal(3, pages); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/Monitor.Backend.Tests/OpenApiConformanceTests.cs new file mode 100644 index 0000000..955c80d --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/OpenApiConformanceTests.cs @@ -0,0 +1,150 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Monitor.Backend.Tests; + +/// +/// 对照 docs/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。 +/// 新增端点违反规范时,这里会直接失败(等价于文档里的 eng/check-openapi)。 +/// +[Collection("backend")] +public sealed partial class OpenApiConformanceTests +{ + private readonly BackendFixture _backend; + + public OpenApiConformanceTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task DocumentAsync() => + (await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json")).AsObject(); + + private static IEnumerable<(string Path, string Method, JsonObject Operation)> Operations(JsonObject doc) + { + foreach ((string path, JsonNode? item) in doc["paths"]!.AsObject()) + { + foreach ((string method, JsonNode? operation) in item!.AsObject()) + { + yield return (path, method, operation!.AsObject()); + } + } + } + + [Fact] + public async Task EveryOperation_HasAUniqueSnakeCaseOperationId_ASummaryAndATag() + { + JsonObject doc = await DocumentAsync(); + var ids = new HashSet(); + foreach ((string path, string method, JsonObject operation) in Operations(doc)) + { + string id = operation["operationId"]?.GetValue() ?? ""; + Assert.True(OperationIdPattern().IsMatch(id), $"{method} {path}: operationId '{id}' must be snake_case (resource_action)"); + Assert.True(ids.Add(id), $"duplicate operationId {id}"); + Assert.False(string.IsNullOrWhiteSpace(operation["summary"]?.GetValue()), $"{method} {path}: summary"); + Assert.NotEmpty(operation["tags"]!.AsArray()); + } + } + + [Fact] + public async Task Paths_AreLowercaseKebabCase_UnderTheDocumentedPrefixes() + { + JsonObject doc = await DocumentAsync(); + foreach ((string path, _) in doc["paths"]!.AsObject()) + { + string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; + string[] roots = ["/healthz", "/metrics", "/install.sh", "/install.ps1"]; + Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); + foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + Assert.True(segment.StartsWith('{') || PathSegmentPattern().IsMatch(segment), $"{path}: '{segment}' must be lowercase kebab-case"); + } + } + } + + [Fact] + public async Task ErrorResponses_AreProblemJsonWithTheSharedSchema() + { + JsonObject doc = await DocumentAsync(); + foreach ((string path, string method, JsonObject operation) in Operations(doc)) + { + foreach ((string status, JsonNode? response) in operation["responses"]!.AsObject()) + { + if (!status.StartsWith('4') && !status.StartsWith('5')) + { + continue; + } + + JsonObject? content = response?["content"]?.AsObject(); + if (content is null) + { + continue; // 没有声明正文的框架错误 + } + + Assert.True(content.ContainsKey("application/problem+json"), $"{method} {path} {status} must use application/problem+json"); + Assert.Equal("#/components/schemas/ProblemDto", content["application/problem+json"]!["schema"]!["$ref"]!.GetValue()); + } + } + + JsonObject problem = doc["components"]!["schemas"]!["ProblemDto"]!.AsObject(); + foreach (string required in new[] { "type", "title", "status", "code", "trace_id" }) + { + Assert.NotNull(problem["properties"]![required]); + } + } + + [Fact] + public async Task SchemaProperties_AreSnakeCase_AndResourcesAreStronglyTyped() + { + JsonObject doc = await DocumentAsync(); + foreach ((string name, JsonNode? schema) in doc["components"]!["schemas"]!.AsObject()) + { + JsonObject? properties = schema?["properties"]?.AsObject(); + if (properties is null || name is "ProblemDetails" or "HttpValidationProblemDetails") + { + continue; + } + + foreach ((string property, _) in properties) + { + Assert.True(PropertyPattern().IsMatch(property), $"{name}.{property} must be snake_case"); + } + } + + // 指标值是字典(metrics: { id: number }),新增指标不需要改 OpenAPI 和前端类型 + JsonObject summary = doc["components"]!["schemas"]!["SummaryDto"]!["properties"]!["metrics"]!.AsObject(); + Assert.True(summary.ContainsKey("additionalProperties")); + JsonObject series = doc["components"]!["schemas"]!["SeriesDto"]!["properties"]!["values"]!.AsObject(); + Assert.True(series.ContainsKey("additionalProperties")); + } + + [Fact] + public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures() + { + JsonObject doc = await DocumentAsync(); + foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path != "/api/admin/auth/login")) + { + JsonObject responses = operation["responses"]!.AsObject(); + Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401"); + } + } + + [Fact] + public async Task CreatedResources_DeclareA201_AndAcceptedOnesA202() + { + JsonObject doc = await DocumentAsync(); + Assert.True(doc["paths"]!["/api/admin/tasks"]!["post"]!["responses"]!.AsObject().ContainsKey("201")); + Assert.True(doc["paths"]!["/api/admin/join-tokens"]!["post"]!["responses"]!.AsObject().ContainsKey("201")); + Assert.True(doc["paths"]!["/api/admin/lines/{line_id}/tests"]!["post"]!["responses"]!.AsObject().ContainsKey("202")); + Assert.True(doc["paths"]!["/api/admin/tasks/{id}"]!["delete"]!["responses"]!.AsObject().ContainsKey("204")); + } + + [GeneratedRegex("^[a-z0-9]+(_[a-z0-9]+)+$")] + private static partial Regex OperationIdPattern(); + + [GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*(\\.[a-z0-9]+)?$")] + private static partial Regex PathSegmentPattern(); + + [GeneratedRegex("^[a-z0-9_]+$")] + private static partial Regex PropertyPattern(); +} diff --git a/backend/tests/Monitor.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/Monitor.Backend.Tests/OperationsAndSecurityTests.cs new file mode 100644 index 0000000..a5526ae --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/OperationsAndSecurityTests.cs @@ -0,0 +1,296 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Rpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +[Collection("backend")] +public sealed class OperationsAndSecurityTests +{ + private const long Minute = 60_000; + + private readonly BackendFixture _backend; + + public OperationsAndSecurityTests(BackendFixture backend) + { + _backend = backend; + } + + private static long Now() => TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + + private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + + private T Service() + where T : class => _backend.Services.GetRequiredService(); + + private T HostedService() + where T : class => _backend.Services.GetServices().OfType().Single(); + + // ───────────────────────── 指标版本与回填 ───────────────────────── + + [Fact] + public async Task ANewOrChangedMetric_IsBackfilledOverTheRetainedRawData() + { + long baseMs = ((Now() / Minute) - 4) * Minute; + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + for (int k = 0; k < 12; k++) + { + Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, _ => new EchoPacket(false, false, 30_000, 30_000)))); + } + + string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&step=1m&metrics=rtt.p95,loss.total"; + JsonNode before = null!; + await BackendFixture.EventuallyAsync(async () => + { + before = await _backend.GetJsonAsync(_backend.Admin, url); + return before["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1200 && before["source"]!.GetValue() == "1m"; + }); + double rtt = before["summary"]!["metrics"]!["rtt.p95"]!.GetValue(); + Assert.InRange(rtt, 60 * 0.955, 60 * 1.045); + + // 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上 + var store = Service(); + foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default)) + { + await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet { "rtt" }, default); + } + + await store.SetVersionAsync(new MetricVersionRecord("rtt", "stale-fingerprint", null, null), default); + JsonNode erased = await _backend.GetJsonAsync(_backend.Admin, url); + Assert.Null(erased["summary"]!["metrics"]!["rtt.p95"]); + Assert.NotNull(erased["summary"]!["metrics"]!["loss.total"]); // 其他指标不受影响 + + // 启动时的指纹比对发现 rtt 变了:在保留期内的原始块上重算它(限速、低优先级) + await HostedService().RunAsync(default); + RecomputeProgress progress = HostedService().Progress; + Assert.Equal("done", progress.State); + Assert.Equal(["rtt"], progress.Metrics.ToArray()); + Assert.True(progress.WindowsTotal >= 2); + Assert.Equal(progress.WindowsTotal, progress.WindowsDone); + + JsonNode after = await _backend.GetJsonAsync(_backend.Admin, url); + Assert.Equal(rtt, after["summary"]!["metrics"]!["rtt.p95"]!.GetValue(), 9); + Assert.Equal(MetricRegistry.Find("rtt")!.Fingerprint, (await store.GetVersionsAsync(default)).Single(v => v.MetricId == "rtt").Fingerprint); + + // 完成之后再比对:没有变化,不再重算 + await HostedService().RunAsync(default); + Assert.Equal("idle", HostedService().Progress.State); + + JsonNode api = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/analysis/recompute"); + Assert.Equal("idle", api["state"]!.GetValue()); + } + + // ───────────────────────── 保留 ───────────────────────── + + [Fact] + public async Task Retention_RemovesRawChunksPastTheConfiguredPeriod_AndTheSettingTakesEffectImmediately() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + RawChunk old = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (45L * 24 * 60 * Minute)); + RawChunk recent = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (2L * 24 * 60 * Minute)); + RawChunk fresh = ChunkFactory.UdpEchoClean(agent.NodeId, Now() - (5 * Minute)); + foreach (RawChunk chunk in new[] { old, recent, fresh }) + { + Assert.True(await agent.SendChunkAsync(chunk)); + } + + async Task CountAsync() => + (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}"))["items"]!.AsArray().Count; + + Assert.Equal(3, await CountAsync()); + await HostedService().RunOnceAsync(default); + Assert.Equal(2, await CountAsync()); // 默认保留 30 天:45 天前的块被删除 + + using HttpResponseMessage patch = await _backend.Admin.PatchAsync( + "/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 1 }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, patch.StatusCode); + await HostedService().RunOnceAsync(default); + Assert.Equal(1, await CountAsync()); + + using HttpResponseMessage restore = await _backend.Admin.PatchAsync( + "/api/admin/settings", BackendFixture.Json(new JsonObject { ["retention.raw_days"] = 30 }, "application/merge-patch+json")); + Assert.Equal(HttpStatusCode.OK, restore.StatusCode); + } + + // ───────────────────────── 节点连接的安全边界 ───────────────────────── + + [Fact] + public async Task AnAuthSignatureCannotBeReplayedOnAnotherConnection() + { + await using AgentSimulator agent = await _backend.EnrollOnlyAsync(); + byte[]? captured = null; + AuthAttempt first = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, nonce => captured = agent.SignAuth(agent.NodeId, nonce)); + Assert.True(first.Ok); + + AuthAttempt replay = await AgentWire.AuthenticateAsync(_backend.BaseAddress, agent.NodeId, _ => captured!); + Assert.False(replay.Ok); + Assert.Equal("fleet_unauthorized", replay.ErrorCode); + } + + [Fact] + public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + try + { + await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]); + } + catch (System.Net.WebSockets.WebSocketException) + { + // 服务端可能在发送完成之前就关闭了连接 + } + + await BackendFixture.EventuallyAsync(async () => !(await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["online"]!.GetValue()); + + // 服务端仍然正常 + await using AgentSimulator another = await _backend.ConnectAgentAsync(); + Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == 2)); + } + + [Fact] + public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(); + await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + + await agent.SendTextAsync("{not json"); + await agent.SendTextAsync("[]"); + await agent.SendBinaryAsync([]); + await agent.SendBinaryAsync([200, 1, 2, 3]); // 未知的二进制通道,长度不够一个完整的头部 + await agent.SendBinaryAsync(OutboxFrame.Build(99, Guid.NewGuid(), [1])); // 没有注册的通道 + + // 节点不能调用任何“执行”类的方法:Fleet 根本没有这样的路由 + foreach (string method in new[] { "fleet.exec", "fleet.shell", "monitor.exec", "monitor.write_file", "fleet.update" }) + { + RpcMessage reply = await agent.RequestRawAsync(method); + Assert.Equal("fleet_unsupported", reply.Error!.Data!.Code); + } + + // 连接仍然可用 + ProbeAgentsResult list = await agent.RequestProbeAgentsAsync(); + Assert.Contains(list.Agents, a => a.AgentId == agent.NodeId); + } + + [Fact] + public async Task WithoutHello_NoStateIsPushed_UntilTheNodeIntroducesItself() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(hello: false); + await Task.Delay(400); + Assert.Empty(agent.TaskSets); + + await agent.HelloAsync(); + Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2)); + } + + [Fact] + public async Task PendingNodes_CannotCallProductMethods() + { + await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false); + RpcMessage reply = await agent.RequestRawAsync(MonitorMethods.ProbeAgents, JsonSerializer.SerializeToElement(new ProbeAgentsParams(), MonitorJsonContext.Default.ProbeAgentsParams)); + Assert.Equal("fleet_node_pending", reply.Error!.Data!.Code); + + // 状态与事件也不会被接受:监控数据里没有它 + await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); + await Task.Delay(200); + JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value); + Assert.False(view["probe_connected"]!.GetValue()); + } + + [Fact] + public async Task ReconnectingTheSameNode_ReplacesTheOldConnection() + { + AgentSimulator first = await _backend.ConnectAgentAsync(); + await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + await using AgentSimulator second = await _backend.ReconnectAsync(first); + await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2); + + // 旧连接被替换掉,但节点仍然在线(在线状态属于新连接) + await first.WaitUntilAsync(() => first.CloseReason is not null); + JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + first.NodeId.Value); + Assert.True(view["online"]!.GetValue()); + } + + // ───────────────────────── 用户、会话与令牌 ───────────────────────── + + [Fact] + public async Task Users_CanBeCreated_PasswordChangeEndsTheirSessions_AndLogoutInvalidatesTheCookie() + { + string name = "user" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage weak = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "short" })); + Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); + Assert.NotNull(JsonNode.Parse(await weak.Content.ReadAsStringAsync())!["errors"]!["password"]); + + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + string userId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue(); + using HttpResponseMessage duplicate = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "another password" })); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + + using HttpClient client = _backend.NewClient(); + (string cookie, string csrf) = await LoginAsync(client, name, "first password"); + Assert.Equal(HttpStatusCode.OK, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); + + using HttpResponseMessage change = await _backend.Admin.PatchAsync($"/api/admin/users/{userId}", BackendFixture.Json(new JsonObject { ["password"] = "second password" })); + Assert.Equal(HttpStatusCode.NoContent, change.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); // 旧会话失效 + + using HttpResponseMessage oldPassword = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "first password" })); + Assert.Equal(HttpStatusCode.Unauthorized, oldPassword.StatusCode); + (cookie, csrf) = await LoginAsync(client, name, "second password"); + + using HttpResponseMessage logout = await Send(client, HttpMethod.Post, "/api/admin/auth/logout", cookie, csrf); + Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await Send(client, HttpMethod.Get, "/api/admin/auth/me", cookie)).StatusCode); + + JsonNode users = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/users"); + Assert.DoesNotContain("password", users.ToJsonString(), StringComparison.OrdinalIgnoreCase); // 响应里没有任何口令信息 + } + + [Fact] + public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed() + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" })); + JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; + string plaintext = body["plaintext"]!.GetValue(); + string id = body["token"]!["id"]!.GetValue(); + + using HttpClient client = _backend.NewClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", plaintext); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/public/catalog/kinds")).StatusCode); + + string listing = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/api-tokens")).ToJsonString(); + Assert.DoesNotContain(plaintext, listing, StringComparison.Ordinal); + + using HttpResponseMessage revoke = await _backend.Admin.DeleteAsync("/api/admin/api-tokens/" + id); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/agents")).StatusCode); + } + + private static async Task<(string Cookie, string Csrf)> LoginAsync(HttpClient client, string user, string password) + { + using HttpResponseMessage response = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password })); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (response.Headers.GetValues("Set-Cookie").Single().Split(';')[0], JsonNode.Parse(await response.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue()); + } + + private static Task Send(HttpClient client, HttpMethod method, string url, string cookie, string? csrf = null) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Add("Cookie", cookie); + if (csrf is not null) + { + request.Headers.Add("X-CSRF-Token", csrf); + } + + return client.SendAsync(request); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/PersistenceTests.cs b/backend/tests/Monitor.Backend.Tests/PersistenceTests.cs new file mode 100644 index 0000000..6a8c000 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/PersistenceTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Nodes; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// 重启后端:数据、身份和令牌都必须还在;热层从原始块回填;段文件尾部没有索引的数据被截断。 +public sealed class PersistenceTests : IAsyncLifetime +{ + private readonly BackendFixture _backend = new(); + public ValueTask InitializeAsync() => _backend.InitializeAsync(); + + public ValueTask DisposeAsync() => _backend.DisposeAsync(); + + [Fact] + public async Task EverythingSurvivesARestart_AndTheHotLayerIsRebuiltFromRawChunks() + { + long minute = 60_000; + long baseMs = ((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / minute) - 3) * minute; + AgentSimulator agent = await _backend.ConnectAgentAsync(); + string id = agent.NodeId.Value; + for (int k = 0; k < 12; k++) + { + Assert.True(await agent.SendChunkAsync(ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + (k * 10_000L), 100, 100, i => new EchoPacket(false, i % 4 == 0, 20_000, 20_000)))); + } + + string from = DateTimeOffset.FromUnixTimeMilliseconds(baseMs).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + string to = DateTimeOffset.FromUnixTimeMilliseconds(baseMs + (2 * minute)).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + string url = $"/api/public/lines/{_backend.Line(id)}/series?from={from}&to={to}&metrics=loss.total,rtt.p95"; + JsonNode before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1200); + before = await _backend.GetJsonAsync(_backend.Admin, url + "&step=1m"); + + await agent.DisposeAsync(); + await _backend.RestartAsync(); + HttpClient admin = _backend.Admin; // 重启之后用同一个令牌,证明令牌已经落盘 + + // 身份与令牌:Agent 记录在,管理员令牌仍然有效 + JsonNode agents = await _backend.GetJsonAsync(admin, "/api/admin/agents"); + JsonNode restored = agents["items"]!.AsArray().Single(a => a!["id"]!.GetValue() == id)!; + Assert.Equal("active", restored["status"]!.GetValue()); + Assert.False(restored["online"]!.GetValue()); + + // 持久层:重启前算好的分钟窗口原样还在 + JsonNode after = await _backend.GetJsonAsync(admin, url + "&step=1m"); + Assert.Equal(before["summary"]!["metrics"]!["loss.total"]!.GetValue(), after["summary"]!["metrics"]!["loss.total"]!.GetValue(), 9); + Assert.Equal(25.0, after["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + + // 热层:重启后为空,回填之后才会被查询选用;回填完成后 1s 步长能读到同样的数据 + JsonNode hot = null!; + await BackendFixture.EventuallyAsync(async () => + { + hot = await _backend.GetJsonAsync(admin, url + "&step=1s"); + return hot["source"]!.GetValue() == "hot" && hot["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1200; + }); + Assert.Equal(25.0, hot["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6); + + // 同一个身份可以重新连接,并拿回当前版本的期望状态 + await using AgentSimulator again = await _backend.ReconnectAsync(agent); + Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == 2)); + + // 已经落盘的块在重启后仍然可以按 ID 下载 + JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1"); + string chunkId = chunks["items"]![0]!["chunk_id"]!.GetValue(); + using HttpResponseMessage download = await admin.GetAsync("/api/admin/raw/chunks/" + chunkId); + Assert.True(RawChunkCodec.Decode(await download.Content.ReadAsByteArrayAsync()).IsSuccess); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/PlanTests.cs b/backend/tests/Monitor.Backend.Tests/PlanTests.cs new file mode 100644 index 0000000..352345a --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/PlanTests.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Tests; + +public sealed class PlanTests +{ + [Fact] + public void Plans_ComposeKnownCollectionPrimitives_AndTheirOverridesAreValid() + { + foreach (PlanSpec plan in Plans.All) + { + foreach (PhaseSpec phase in plan.Phases) + { + KindSpec kind = Kinds.Find(phase.Kind)!; + Assert.NotNull(kind); + Assert.True(ParamResolver.Resolve(kind.Params, phase.Overrides).IsValid, $"{plan.Id}/{phase.Kind}"); + } + } + + Assert.Null(Plans.Find("nope")); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs new file mode 100644 index 0000000..652ccdf --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/ProbeEndToEndTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Nodes; + +namespace Monitor.Backend.Tests; + +/// 真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。 +[Collection("backend")] +public sealed class ProbeEndToEndTests +{ + private readonly BackendFixture _backend; + + public ProbeEndToEndTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id); + + private async Task ChunkCountAsync(AgentHarness agent, string kind) => + (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == kind); + + [Fact] + public async Task Probe_ConnectsThroughTheBootstrapList_RunsTheLineTasks_AndTheBackendStoresMergedChunks() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(rateHz: 30); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id)), because: "the probe should connect to the bootstrap agent"); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id)!.RoleCount == 2), because: "udp_echo and tcp_echo"); + + // 每 10 秒切一块,回显类再等 2 秒宽限 + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_echo") >= 1 && await ChunkCountAsync(agent, "tcp_echo") >= 1, TimeSpan.FromSeconds(40)); + Assert.True(agent.App.Counters.Fragments >= 2); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + + // 时钟偏移已经估计出来:进程内两端共用同一个单调时钟,所以偏移应当接近 0(真实线路上是两台机器各自的时钟原点之差) + Assert.InRange(probe.App.Connection(agent.Id)!.ClockOffsetUs, -20_000, 20_000); + } + + [Fact] + public async Task Probe_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart() + { + await using AgentHarness first = await AgentHarness.StartAsync(_backend); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(first.Id))); + + await using AgentHarness second = await AgentHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new agent should be picked up through agents_changed: " + string.Join(";", probe.App.Directory.Agents.Select(a => $"{a.AgentId}@{a.Host}:{a.TcpPort}")) + " v" + probe.App.Directory.Version + " want " + second.Id); + Assert.Contains(probe.App.Directory.Agents, a => a.AgentId == second.Id); + Assert.True(File.Exists(Path.Combine(probe.Directory, "agents.json"))); + + // 停用一个 Agent:Probe 断开它 + using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject())); + Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync()); + await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task Hy2Ladder_WithTheRealProbe_Succeeds_AndEveryFragmentIsStored() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000 } })); + Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + JsonNode test = null!; + await BackendFixture.EventuallyAsync( + async () => + { + test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); + return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; + }, + TimeSpan.FromSeconds(90)); + Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); + + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "50 and 100 Mbit/s phases are split into several fragments"); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + } + + [Fact] + public async Task RealitySession_WithTheRealProbe_RunsTcpBulkAndStoresBytesAndKernelRows() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(); + await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); + + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + JsonNode test = null!; + await BackendFixture.EventuallyAsync( + async () => + { + test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); + return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; + }, + TimeSpan.FromSeconds(150)); + Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments"); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/ProbeHarness.cs b/backend/tests/Monitor.Backend.Tests/ProbeHarness.cs new file mode 100644 index 0000000..95b354e --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/ProbeHarness.cs @@ -0,0 +1,68 @@ +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; +using Monitor.Probe; + +namespace Monitor.Backend.Tests; + +/// 进程内的真实 Probe:用后端生成的身份文件(和面板下载的 probe.json 同一份)启动 。 +public sealed class ProbeHarness : IAsyncDisposable +{ + private readonly CancellationTokenSource _stop = new(); + private readonly string _directory; + private readonly Task _run; + + private ProbeHarness(string directory, ProbeApp app) + { + _directory = directory; + App = app; + _run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None); + } + + public ProbeApp App { get; } + + public string Directory => _directory; + + public string ProbeId { get; init; } = ""; + + /// 向后端申请身份(POST /api/admin/probe/identity),写成 probe.json 并启动。 + public static async Task StartAsync(BackendFixture backend, Action? configure = null) + { + JsonNode created = await backend.CreateProbeAsync(); + string json = created.ToJsonString(); + + string directory = Path.Combine(Path.GetTempPath(), "monitor-probe-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "probe.json"); + await File.WriteAllTextAsync(path, json, Encoding.UTF8); + + ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile); + configure?.Invoke(file); + var app = new ProbeApp(file, directory, TimeProvider.System, new Fleet.Hosting.ConsoleNodeLog(TimeProvider.System, Console.Out)); + return new ProbeHarness(directory, app) { ProbeId = created["probe_id"]!.GetValue() }; + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + try + { + await _run.WaitAsync(TimeSpan.FromSeconds(15)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + // 停止中 + } + + App.Dispose(); + _stop.Dispose(); + try + { + System.IO.Directory.Delete(_directory, recursive: true); + } + catch (IOException) + { + // 临时目录清理失败不影响测试结果 + } + } +} diff --git a/backend/tests/Monitor.Backend.Tests/QueryPlanningTests.cs b/backend/tests/Monitor.Backend.Tests/QueryPlanningTests.cs new file mode 100644 index 0000000..8c9d41e --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/QueryPlanningTests.cs @@ -0,0 +1,241 @@ +using Fleet.Core; +using Microsoft.Extensions.Time.Testing; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; +using Monitor.Protocol; + +namespace Monitor.Backend.Tests; + +/// 时间段、步长与数据源的规划:与 docs/query.md 里的表格逐行对应。 +public sealed class QueryPlanningTests +{ + private const long Second = 1000; + private const long Minute = 60 * Second; + private const long Hour = 60 * Minute; + private const long Day = 24 * Hour; + + private static readonly long s_now = new DateTimeOffset(2026, 10, 3, 12, 34, 56, TimeSpan.Zero).ToUnixTimeMilliseconds(); + + private readonly BackendOptions _options = new(); + + private RangeResolver Resolver => new(_options); + + private SourcePlanner Planner => new(_options); + + private QueryRange Resolve(string range, string? step = null) + { + Result result = Resolver.Resolve(new RangeRequest(range, null, null, step, null), s_now); + Assert.True(result.IsSuccess, result.IsSuccess ? "" : result.Error.Message); + return result.Value; + } + + [Theory] + [InlineData("5m", "1s", 300)] + [InlineData("15m", "2s", 450)] + [InlineData("30m", "5s", 360)] + [InlineData("1h", "5s", 720)] + [InlineData("6h", "30s", 720)] + [InlineData("24h", "2m", 720)] + [InlineData("7d", "15m", 672)] + [InlineData("30d", "1h", 720)] + [InlineData("90d", "3h", 720)] + public void AutoStep_MatchesTheDocumentedTable(string range, string step, int points) + { + QueryRange resolved = Resolve(range); + Assert.Equal(step, Steps.Format(resolved.StepMs)); + Assert.InRange(resolved.Points - points, 0, 1); // 对齐到步长的整数倍,可能多出一个不完整的桶 + } + + [Theory] + [InlineData("5m", "hot")] + [InlineData("15m", "hot")] + [InlineData("30m", "hot")] + [InlineData("1h", "hot")] + [InlineData("6h", "10s")] + [InlineData("24h", "1m")] + [InlineData("7d", "5m")] + [InlineData("30d", "1h")] + [InlineData("90d", "1h")] + public void SourceSelection_MatchesTheDocumentedTable(string range, string source) + { + QueryRange resolved = Resolve(range); + SourcePlan plan = Planner.Plan(resolved, s_now, hotReady: true); + Assert.Equal(source, plan.Name); + Assert.False(plan.Degraded); + Assert.Equal(resolved.StepMs, plan.StepMs); + } + + [Fact] + public void WhenTheHotLayerIsNotReady_FineStepsOverRecentDataComeFromRawChunks() + { + SourcePlan plan = Planner.Plan(Resolve("5m"), s_now, hotReady: false); + Assert.Equal("raw", plan.Name); + + // 区间超过 15 分钟就不现算,降级到 10 s 层 + SourcePlan degraded = Planner.Plan(Resolve("1h", step: "5s"), s_now, hotReady: false); + Assert.Equal("10s", degraded.Name); + Assert.True(degraded.Degraded); + Assert.Equal(10 * Second, degraded.StepMs); + } + + [Fact] + public void StepsThatNoLayerCanServe_AreDegradedAndSnappedToACommonStep() + { + // 15 s 不能整除 10 s 层的宽度,而且已经超出热层 + QueryRange old = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Hour)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (2 * Hour)), "15s", null), s_now).Value; + SourcePlan plan = Planner.Plan(old, s_now, hotReady: true); + Assert.Equal(("10s", true, "30s"), (plan.Name, plan.Degraded, Steps.Format(plan.StepMs))); + Assert.Equal(30 * Second, old.WithStep(plan.StepMs).StepMs); + Assert.Equal(0, old.WithStep(plan.StepMs).FromMs % plan.StepMs); + + // 超出 10 s 层的保留期(48 小时),1 分钟步长改用 1 分钟层(不降级:1m 层能整除) + QueryRange week = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day) + Hour), "10s", null), s_now).Value; + SourcePlan expired = Planner.Plan(week, s_now, hotReady: true); + Assert.True(expired.Degraded, "the 10 s layer only keeps 48 hours"); + Assert.Equal("1m", expired.Name); + Assert.Equal(Minute, expired.StepMs); + } + + [Fact] + public void RangeResolver_AlignsBucketsAndMarksPartialEdges() + { + long from = s_now - (s_now % Minute) + (13 * Second); + long to = from + (4 * Minute) + (7 * Second); + QueryRange range = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), DateTimeOffset.FromUnixTimeMilliseconds(to), "1m", null), s_now).Value; + + Assert.Equal(0, range.FromMs % Minute); + Assert.Equal(0, range.ToMs % Minute); + Assert.Equal(5, range.Points); + Assert.True(range.FirstPartial); + Assert.True(range.LastPartial); + Assert.False(range.IsRelative); + + QueryRange aligned = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(range.FromMs), DateTimeOffset.FromUnixTimeMilliseconds(range.ToMs), "1m", null), s_now).Value; + Assert.False(aligned.FirstPartial); + Assert.False(aligned.LastPartial); + } + + [Theory] + [InlineData("0m")] + [InlineData("-5m")] + [InlineData("5")] + [InlineData("5x")] + [InlineData("m")] + [InlineData("99999999d")] + [InlineData("1000d")] + public void RangeResolver_RejectsMalformedAndAbsurdRanges(string range) + { + Result result = Resolver.Resolve(new RangeRequest(range, null, null, null, null), s_now); + Assert.False(result.IsSuccess); + Assert.Contains(result.Error.Code, new[] { MonitorErrors.InvalidRange, MonitorErrors.RangeTooLarge }); + } + + [Fact] + public void RangeResolver_EnforcesThePointLimit_AndSuggestsAStep() + { + Result result = Resolver.Resolve(new RangeRequest("30d", null, null, "1m", null), s_now); + Assert.Equal(MonitorErrors.RangeTooLarge, result.Error.Code); + Assert.Contains("step=", result.Error.Message); + Assert.Equal(MonitorErrors.InvalidStep, Resolver.Resolve(new RangeRequest("1h", null, null, "7s", null), s_now).Error.Code); + Assert.Equal(MonitorErrors.InvalidRange, Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now), DateTimeOffset.FromUnixTimeMilliseconds(s_now), null, null), s_now).Error.Code); + Assert.Equal(MonitorErrors.ValidationFailed, Resolver.Resolve(new RangeRequest("1h", DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Error.Code); + } + + [Fact] + public void OnlyUtcIsSupported_OtherTimeZonesAreRejectedInsteadOfSilentlyIgnored() + { + Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, "UTC"), s_now).IsSuccess); + Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, null), s_now).IsSuccess); + Result shanghai = Resolver.Resolve(new RangeRequest("7d", null, null, "1d", "Asia/Shanghai"), s_now); + Assert.Equal(MonitorErrors.ValidationFailed, shanghai.Error.Code); + Assert.IsType(shanghai.Error); + } + + [Fact] + public void RelativeRanges_AreLive_AbsoluteOnesAreNot() + { + Assert.True(Resolve("15m").IsRelative); + QueryRange fromOnly = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Value; + Assert.True(fromOnly.IsRelative); // 没有 to:to = now,可以跟随实时数据 + } + + [Fact] + public void Steps_ParseFormatAndSnap() + { + foreach (long step in Steps.Common) + { + Assert.True(Steps.TryParse(Steps.Format(step), out long parsed)); + Assert.Equal(step, parsed); + } + + Assert.False(Steps.TryParse("3s", out _)); + Assert.Equal(30 * Second, Steps.Snap(15 * Second, 10 * Second)); + Assert.Equal(5 * Minute, Steps.Snap(Minute, 5 * Minute)); + Assert.Equal(-60_000, Steps.FloorTo(-1, Minute)); + Assert.Equal(0, Steps.CeilTo(0, Minute)); + Assert.Equal(Minute, Steps.CeilTo(1, Minute)); + } + + [Fact] + public void BucketedRows_MergeExactly_AndDoNotMutateTheirSources() + { + QueryRange range = Resolve("5m", "1m"); + var first = new MetricSet(); + first.GetOrAdd("c", AccKind.Counter).Add(3); + ((HistogramAcc)first.GetOrAdd("h", AccKind.Histogram)).Add(10); + var second = new MetricSet(); + second.GetOrAdd("c", AccKind.Counter).Add(4); + ((HistogramAcc)second.GetOrAdd("h", AccKind.Histogram)).Add(20); + + BucketedRows rows = BucketedRows.Create( + [new SourceRow(range.FromMs, first, false), new SourceRow(range.FromMs + (10 * Second), second, false), new SourceRow(range.FromMs - Minute, first, false), new SourceRow(range.ToMs, first, false)], range); + + Assert.Equal(range.Points, rows.T.Length); + Assert.Equal(2, rows.RowCounts[0]); + Assert.Equal(7UL, ((CounterAcc)rows.Sets[0]!.Items["c"]).Value); + Assert.Equal(2UL, ((HistogramAcc)rows.Sets[0]!.Items["h"]).Count); + Assert.Equal(3UL, ((CounterAcc)first.Items["c"]).Value); // 源行没有被改动 + Assert.Equal(2, rows.TotalRows); // 区间之外的行被忽略 + Assert.Null(rows.Sets[1]); + } + + [Fact] + public void MetricSelection_ResolvesPresetsAndScores_AndRejectsUnknownIds() + { + MetricSelection selection = MetricSelection.Resolve("rtt.p95,score.mc", "udp,scores", 100).Value; + Assert.Contains("loss.total", selection.ExportIds); + Assert.Contains("mc", selection.ScoreModelIds); + Assert.Equal(4, selection.ScoreModelIds.Count); + Assert.Equal(selection.ExportIds.Count, selection.ExportIds.Distinct().Count()); + Assert.True(selection.WantsScores); + + Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("nope", null, 100).Error.Code); + Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("score.nope", null, 100).Error.Code); + Assert.Equal(MonitorErrors.UnknownPreset, MetricSelection.Resolve(null, "nope", 100).Error.Code); + Assert.Equal(MonitorErrors.TooManyMetrics, MetricSelection.Resolve(null, "udp", 3).Error.Code); + } + + [Fact] + public void LoginRateLimiter_AllowsABurst_ThenBlocksUntilTheWindowSlides() + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); + var limiter = new LoginRateLimiter(new BackendOptions { LoginAttemptsPerMinute = 3 }, time); + + for (int i = 0; i < 3; i++) + { + Assert.True(limiter.TryAcquire("1.2.3.4", out _)); + time.Advance(TimeSpan.FromSeconds(10)); + } + + Assert.False(limiter.TryAcquire("1.2.3.4", out int retry)); // 已过 30 秒,最早的一次还要 30 秒才滑出窗口 + Assert.InRange(retry, 29, 31); + Assert.True(limiter.TryAcquire("5.6.7.8", out _), "another source is independent"); + + time.Advance(TimeSpan.FromSeconds(31)); + Assert.True(limiter.TryAcquire("1.2.3.4", out _)); + limiter.Prune(); + time.Advance(TimeSpan.FromMinutes(2)); + limiter.Prune(); + Assert.True(limiter.TryAcquire("1.2.3.4", out _)); + } +} diff --git a/backend/tests/Monitor.Backend.Tests/SseClient.cs b/backend/tests/Monitor.Backend.Tests/SseClient.cs new file mode 100644 index 0000000..f20f160 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/SseClient.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace Monitor.Backend.Tests; + +public sealed record SseEvent(string Name, JsonNode? Data); + +/// 读取 text/event-stream:把事件和心跳注释收集起来,测试按条件等待。 +public sealed class SseClient : IAsyncDisposable +{ + private readonly HttpResponseMessage _response; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _reader; + + private SseClient(HttpResponseMessage response, Stream stream) + { + _response = response; + _reader = Task.Run(() => ReadAsync(stream)); + } + + public ConcurrentQueue Events { get; } = new(); + + private int _comments; + + public int Comments => Volatile.Read(ref _comments); + + public string? ContentType => _response.Content.Headers.ContentType?.MediaType; + + public static async Task ConnectAsync(HttpClient http, string url) + { + HttpResponseMessage response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + return new SseClient(response, await response.Content.ReadAsStreamAsync()); + } + + public async Task WaitForAsync(Func predicate, TimeSpan? timeout = null) + { + long started = TimeProvider.System.GetTimestamp(); + TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15); + while (TimeProvider.System.GetElapsedTime(started) < limit) + { + SseEvent? found = Events.ToArray().FirstOrDefault(predicate); + if (found is not null) + { + return found; + } + + await Task.Delay(25); + } + + throw new TimeoutException("the expected server-sent event never arrived; got: " + string.Join(", ", Events.Select(e => e.Name))); + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _response.Dispose(); + try + { + await _reader; + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException) + { + // 连接被关闭 + } + + _stop.Dispose(); + } + + private async Task ReadAsync(Stream stream) + { + using var reader = new StreamReader(stream); + string? name = null; + var data = new System.Text.StringBuilder(); + while (!_stop.IsCancellationRequested) + { + string? line = await reader.ReadLineAsync(_stop.Token); + if (line is null) + { + return; + } + + if (line.StartsWith(':')) + { + Interlocked.Increment(ref _comments); + } + else if (line.StartsWith("event: ", StringComparison.Ordinal)) + { + name = line["event: ".Length..]; + } + else if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + data.Append(line["data: ".Length..]); + } + else if (line.Length == 0 && name is not null) + { + Events.Enqueue(new SseEvent(name, JsonNode.Parse(data.ToString()))); + name = null; + data.Clear(); + } + } + } +} diff --git a/backend/tests/Monitor.Backend.Tests/StorageTests.cs b/backend/tests/Monitor.Backend.Tests/StorageTests.cs new file mode 100644 index 0000000..e0dff17 --- /dev/null +++ b/backend/tests/Monitor.Backend.Tests/StorageTests.cs @@ -0,0 +1,398 @@ +using System.Text; +using Fleet.Core; +using Fleet.Server; +using Microsoft.Data.Sqlite; +using Monitor.Backend.Analysis; +using Monitor.Backend.Application; +using Monitor.Backend.Storage.Sqlite; +using Monitor.Protocol; +using Monitor.TestSupport; + +namespace Monitor.Backend.Tests; + +/// 存储层:段文件与索引、指标窗口、队列、节点与令牌。直接使用真实的 SQLite 和临时目录。 +public sealed class StorageTests : IDisposable +{ + private static readonly NodeId s_agent = new("agt_storage"); + + private readonly string _dir = Path.Combine(Path.GetTempPath(), "monitor-storage-" + Guid.NewGuid().ToString("N")); + private readonly SqliteDatabase _db; + + public StorageTests() + { + _db = new SqliteDatabase(_dir); + } + + public void Dispose() + { + _db.Dispose(); + try + { + Directory.Delete(_dir, recursive: true); + } + catch (IOException) + { + // 临时目录清理失败不影响测试结果 + } + } + + private static RawChunkAppend Chunk(NodeId agent, string kind, long start, string day, Guid? id = null, TestId? test = null, int? phase = null, string series = "line", byte[]? bytes = null) + { + var meta = new RawChunkMeta(new ChunkId(id ?? Guid.CreateVersion7()), agent, series, kind, null, test, phase, start, 10_000, false, "", 0, 0); + return new RawChunkAppend(meta, bytes ?? Encoding.UTF8.GetBytes("chunk-" + Guid.NewGuid()), day); + } + + // ───────────────────────── 段文件 ───────────────────────── + + [Fact] + public async Task Raw_AppendReadAndDedupe() + { + var raw = new SqliteRawStore(_db); + RawChunkAppend a = Chunk(s_agent, "udp_echo", 1_000, "2026-01-01"); + RawChunkAppend b = Chunk(s_agent, "udp_echo", 11_000, "2026-01-01"); + + Assert.True(await raw.AppendAsync(a, default)); + Assert.True(await raw.AppendAsync(b, default)); + Assert.False(await raw.AppendAsync(a, default), "same chunk_id twice is a duplicate"); + + IReadOnlyList metas = await raw.FindAsync(new RawQuery(s_agent), default); + Assert.Equal(2, metas.Count); + Assert.Equal("2026-01-01/agt_storage/udp_echo.seg", metas[0].SegmentPath); + Assert.Equal(0, metas[0].Offset); + Assert.Equal(metas[0].Length, metas[1].Offset); // 同一个段文件里依次追加 + Assert.Equal(a.Bytes.ToArray(), await raw.ReadAsync(metas[0], default)); + Assert.Equal(b.Bytes.ToArray(), await raw.ReadAsync(metas[1], default)); + Assert.Equal((await raw.GetMetaAsync(a.Meta.ChunkId, default))!.Offset, metas[0].Offset); + Assert.Equal(a.Bytes.Length + b.Bytes.Length, raw.TotalBytes); + } + + [Fact] + public async Task Raw_PathsAreSanitised_SoIdsCannotEscapeTheRawDirectory() + { + var raw = new SqliteRawStore(_db); + var evil = new NodeId("../../../outside"); + Assert.True(await raw.AppendAsync(Chunk(evil, "../etc", 1, "2026-01-01"), default)); + Assert.True(await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-01-01", test: new TestId("..\\..\\x"), phase: 0), default)); + + foreach (string file in Directory.EnumerateFiles(_dir, "*.seg", SearchOption.AllDirectories)) + { + Assert.StartsWith(Path.GetFullPath(Path.Combine(_dir, "raw")), Path.GetFullPath(file)); + } + + Assert.False(File.Exists(Path.Combine(_dir, "outside"))); + await Assert.ThrowsAsync(async () => await raw.AppendAsync(Chunk(s_agent, "udp_echo", 3, "../2026"), default)); + } + + [Fact] + public async Task Raw_FindFiltersAndPagesByStartTime() + { + var raw = new SqliteRawStore(_db); + var testId = new TestId("tst_one"); + for (int i = 0; i < 6; i++) + { + await raw.AppendAsync(Chunk(s_agent, "udp_echo", i * 10_000L, "2026-01-01"), default); + } + + await raw.AppendAsync(Chunk(s_agent, "tcp_echo", 5_000, "2026-01-01"), default); + await raw.AppendAsync(Chunk(new NodeId("agt_other"), "udp_echo", 5_000, "2026-01-01"), default); + await raw.AppendAsync(Chunk(s_agent, "udp_train", 0, "2026-01-01", test: testId, phase: 1), default); + + Assert.Equal(8, (await raw.FindAsync(new RawQuery(s_agent), default)).Count); + Assert.Equal(6, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo"), default)).Count); + Assert.Single(await raw.FindAsync(new RawQuery(TestId: testId), default)); + Assert.Equal(3, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", FromMs: 10_000, ToMs: 40_000), default)).Count); + Assert.Equal(2, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", Limit: 2), default)).Count); + Assert.Equal(4, (await raw.FindAsync(new RawQuery(s_agent, Kind: "udp_echo", AfterStartTs: 20_000), default)).Count); + + IReadOnlyList<(NodeId AgentId, string Series)> series = await raw.ListSeriesAsync(0, 100_000, default); + Assert.Equal(2, series.Count); // 测试的块不算持续序列 + } + + [Fact] + public async Task Raw_RecoverTruncatesAnUnindexedTail_AndLaterAppendsStayConsistent() + { + var raw = new SqliteRawStore(_db); + RawChunkAppend first = Chunk(s_agent, "udp_echo", 1, "2026-01-01"); + await raw.AppendAsync(first, default); + string segment = Directory.EnumerateFiles(_dir, "udp_echo.seg", SearchOption.AllDirectories).Single(); + long indexedEnd = new FileInfo(segment).Length; + + // 追加之后、写索引之前崩溃:段文件尾部多了没有索引记录的字节 + await File.AppendAllTextAsync(segment, "ORPHANED-TAIL-FROM-A-CRASH"); + Assert.True(new FileInfo(segment).Length > indexedEnd); + + await raw.RecoverAsync(default); + Assert.Equal(indexedEnd, new FileInfo(segment).Length); + + RawChunkAppend second = Chunk(s_agent, "udp_echo", 2, "2026-01-01"); + await raw.AppendAsync(second, default); + IReadOnlyList metas = await raw.FindAsync(new RawQuery(s_agent), default); + Assert.Equal(first.Bytes.ToArray(), await raw.ReadAsync(metas[0], default)); + Assert.Equal(second.Bytes.ToArray(), await raw.ReadAsync(metas[1], default)); + Assert.Equal(indexedEnd, metas[1].Offset); + } + + [Fact] + public async Task Raw_DeleteExpired_UsesSeparateRetentionForTests_AndRemovesEmptyDirectories() + { + var raw = new SqliteRawStore(_db); + var testId = new TestId("tst_old"); + await raw.AppendAsync(Chunk(s_agent, "udp_echo", 1, "2026-01-01"), default); + await raw.AppendAsync(Chunk(s_agent, "udp_echo", 2, "2026-03-01"), default); + await raw.AppendAsync(Chunk(s_agent, "udp_train", 3, "2026-01-01", test: testId, phase: 0), default); + await raw.AppendAsync(Chunk(s_agent, "udp_train", 4, "2026-02-01", test: new TestId("tst_mid"), phase: 0), default); + + // 持续任务保留到 2026-02-01 之后,测试保留到 2026-01-15 之后 + int removed = await raw.DeleteExpiredAsync(new DateOnly(2026, 2, 1), new DateOnly(2026, 1, 15), default); + Assert.Equal(2, removed); + + IReadOnlyList left = await raw.FindAsync(new RawQuery(), default); + Assert.Equal(2, left.Count); + Assert.Contains(left, m => m.StartTs == 2); + Assert.Contains(left, m => m.TestId?.Value == "tst_mid"); + Assert.False(Directory.Exists(Path.Combine(_dir, "raw", "2026-01-01")), "empty day directories are removed"); + } + + // ───────────────────────── 指标窗口 ───────────────────────── + + private static MetricRow Row(long window, Action fill, bool partial = false) + { + var set = new MetricSet(); + fill(set); + return new MetricRow(s_agent, "line", window, partial, AccumulatorCodec.Encode(set)); + } + + private static ulong Count(MetricRow row, string id) => ((CounterAcc)AccumulatorCodec.Decode(row.Metrics).Value.Items[id]).Value; + + [Fact] + public async Task Metrics_MergeAdds_UpsertReplaces_PatchOnlyReplacesNamedMetrics() + { + var store = new SqliteMetricStore(_db); + await store.MergeAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(3))], default); + await store.MergeAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(4)), Row(10_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1))], default); + IReadOnlyList rows = await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 20_000, default); + Assert.Equal([7UL, 1UL], rows.Select(r => Count(r, "a")).ToArray()); + + await store.UpsertAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("a", AccKind.Counter).Add(100))], default); + Assert.Equal(100UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0], "a")); + + // 回填一个新指标:只替换 b,a 保持不变 + await store.PatchAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("b", AccKind.Counter).Add(5))], new HashSet { "b" }, default); + MetricRow patched = (await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0]; + Assert.Equal(100UL, Count(patched, "a")); + Assert.Equal(5UL, Count(patched, "b")); + await store.PatchAsync(MetricLevel.S10, [Row(0, s => s.GetOrAdd("b", AccKind.Counter).Add(6))], new HashSet { "b" }, default); + Assert.Equal(6UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 0, 1, default))[0], "b")); + + // 对不存在的行打补丁会创建它(历史回填) + await store.PatchAsync(MetricLevel.S10, [Row(50_000, s => s.GetOrAdd("b", AccKind.Counter).Add(9))], new HashSet { "b" }, default); + Assert.Equal(9UL, Count((await store.ReadAsync(MetricLevel.S10, s_agent, "line", 50_000, 60_000, default))[0], "b")); + } + + [Fact] + public async Task Metrics_MergeKeepsThePartialFlag_AndReadsHonourHalfOpenRangesAndAgents() + { + var store = new SqliteMetricStore(_db); + await store.MergeAsync(MetricLevel.M1, [Row(60_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1), partial: true)], default); + await store.MergeAsync(MetricLevel.M1, [Row(60_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1), partial: false)], default); + Assert.True((await store.ReadAsync(MetricLevel.M1, s_agent, "line", 0, 120_000, default))[0].Partial); + + var other = new MetricRow(new NodeId("agt_other"), "line", 60_000, false, AccumulatorCodec.Encode(new MetricSet())); + await store.UpsertAsync(MetricLevel.M1, [other, Row(120_000, s => s.GetOrAdd("a", AccKind.Counter).Add(1))], default); + Assert.Single(await store.ReadAsync(MetricLevel.M1, s_agent, "line", 60_000, 120_000, default)); // [from, to) + Assert.Equal(3, (await store.ReadAgentsAsync(MetricLevel.M1, [s_agent, new NodeId("agt_other")], "line", 0, 999_999, default)).Count); + Assert.Empty(await store.ReadAgentsAsync(MetricLevel.M1, [], "line", 0, 999_999, default)); + + Assert.Equal(2, await store.DeleteOlderThanAsync(MetricLevel.M1, 100_000, default)); // 60_000 的两行(两个 Agent)被删,120_000 保留 + Assert.Single(await store.ReadAsync(MetricLevel.M1, s_agent, "line", 0, 999_999, default)); + } + + [Fact] + public async Task Queue_SequenceNumbersProtectLateChunksQueuedWhileAWindowWasBeingComputed() + { + var store = new SqliteMetricStore(_db); + var item = new QueueItem(s_agent, "line", 60_000, NotBefore: 1_000, Reason: "new"); + await store.EnqueueAsync([item], default); + IReadOnlyList first = await store.DequeueDueAsync(2_000, 10, default); + Assert.Single(first); + + // 处理期间又来了一个迟到的块:同一个窗口再次入队 + await store.EnqueueAsync([item with { NotBefore = 5_000, Reason = "late_chunk" }], default); + await store.CompleteAsync(first, default); // 完成的是旧序号 + Assert.Equal(1, await store.QueueLengthAsync(default)); + IReadOnlyList again = await store.DequeueDueAsync(6_000, 10, default); + Assert.Single(again); + Assert.True(again[0].Seq > first[0].Seq); + Assert.Equal(1_000, again[0].NotBefore); // 取较早的就绪时间 + await store.CompleteAsync(again, default); + Assert.Equal(0, await store.QueueLengthAsync(default)); + + Assert.Empty(await store.DequeueDueAsync(0, 10, default)); + } + + [Fact] + public async Task RollupDirty_IsDeduplicated_AndLowerLevelsComeFirst() + { + var store = new SqliteMetricStore(_db); + await store.MarkRollupDirtyAsync(MetricLevel.H1, s_agent, "line", 0, default); + await store.MarkRollupDirtyAsync(MetricLevel.M5, s_agent, "line", 300_000, default); + await store.MarkRollupDirtyAsync(MetricLevel.M5, s_agent, "line", 300_000, default); + + var taken = (await store.TakeRollupDirtyAsync(10, default)).ToList(); + Assert.Equal([MetricLevel.M5, MetricLevel.H1], taken.Select(t => t.Level).ToArray()); + Assert.Empty(await store.TakeRollupDirtyAsync(10, default)); + } + + [Fact] + public async Task MetricVersions_AreStoredPerMetric() + { + var store = new SqliteMetricStore(_db); + await store.SetVersionAsync(new MetricVersionRecord("rtt", "abc", null, null), default); + await store.SetVersionAsync(new MetricVersionRecord("rtt", "def", 1, 2), default); + MetricVersionRecord version = Assert.Single(await store.GetVersionsAsync(default)); + Assert.Equal(("def", 1L, 2L), (version.Fingerprint, version.RecomputeFrom, version.RecomputeCursor)); + } + + // ───────────────────────── 节点、令牌、设置、事件 ───────────────────────── + + private static NewNode Node(string id, string name, NodeStatus status = NodeStatus.Active, byte fill = 1) => + new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000); + + [Fact] + public async Task Agents_NamesAreUniqueAmongEstablishedNodes_PendingOnesMayRepeat() + { + var store = new SqliteAgentStore(_db); + Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_1", "hk"), default)).IsSuccess); + Result clash = await ((IFleetStore)store).CreateNodeAsync(Node("agt_2", "hk"), default); + Assert.Equal(FleetErrors.NameConflict, clash.Error.Code); + Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_3", "hk", NodeStatus.Pending), default)).IsSuccess); + Assert.True((await ((IFleetStore)store).CreateNodeAsync(Node("agt_4", "hk", NodeStatus.Pending), default)).IsSuccess); + + Result approve = await store.SetStatusAsync(new NodeId("agt_3"), NodeStatus.Active, default); + Assert.Equal(MonitorErrors.NameConflict, approve.Error.Code); + + // 接管:保留旧 ID 与历史,换成新公钥,删除待审批记录 + Result takeover = await store.TakeoverAsync(new NodeId("agt_3"), default); + Assert.Equal("agt_1", takeover.Value.Id.Value); + Assert.Equal(new byte[] { 1, 2, 3 }, takeover.Value.PublicKey); + Assert.Null(await store.GetAsync(new NodeId("agt_3"), default)); + Assert.Equal(NodeStatus.Pending, (await store.GetAsync(new NodeId("agt_4"), default))!.Status); + Assert.Equal(MonitorErrors.AgentNotFound, (await store.TakeoverAsync(new NodeId("agt_1"), default)).Error.Code); // 只有 pending 能接管别人 + } + + [Fact] + public async Task Agents_PatchUsesRowVersionForOptimisticConcurrency() + { + var store = new SqliteAgentStore(_db); + await ((IFleetStore)store).CreateNodeAsync(Node("agt_1", "hk"), default); + AgentRecord current = (await store.GetAsync(new NodeId("agt_1"), default))!; + + Result stale = await store.PatchAsync(current.Id, new AgentPatch { Labels = ["x"] }, current.RowVersion + 5, default); + Assert.Equal(MonitorErrors.PreconditionFailed, stale.Error.Code); + Result ok = await store.PatchAsync(current.Id, new AgentPatch { Labels = ["x"] }, current.RowVersion, default); + Assert.Equal(current.RowVersion + 1, ok.Value.RowVersion); + Assert.Equal(["x"], ok.Value.Labels); + Assert.Equal("hk", ok.Value.Name); // 没写的字段不动 + Assert.Equal(MonitorErrors.AgentNotFound, (await store.PatchAsync(new NodeId("agt_x"), new AgentPatch(), null, default)).Error.Code); + } + + [Fact] + public async Task JoinTokens_AreConsumedAtomically_EvenUnderConcurrency() + { + var tokens = new SqliteJoinTokenStore(_db); + var fleet = new SqliteAgentStore(_db); + byte[] hash = TokenHash.Sha256("jt_race"); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default); + Assert.Equal(0, created.Uses); + + bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); + Assert.Equal(3, results.Count(r => r)); + Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); + + Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, 5, expiresAtMs: 5_000, nowMs: 1_000, default); + Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); + Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); + Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); + Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_2", 3_000, default), "revoked"); + } + + [Fact] + public async Task Settings_IncrementIsAtomic_AndEventsPage() + { + var settings = new SqliteSettingsStore(_db); + long[] values = await Task.WhenAll(Enumerable.Range(0, 50).Select(_ => Task.Run(async () => await settings.IncrementAsync("counter", default)))); + Assert.Equal(Enumerable.Range(1, 50).Select(i => (long)i), values.Order()); + Assert.Equal("50", await settings.GetAsync("counter", default)); + + var events = new SqliteEventStore(_db); + for (int i = 0; i < 5; i++) + { + await events.AddAsync(s_agent, i % 2 == 0 ? "a" : "b", 1_000 + i, "d" + i, default); + } + + IReadOnlyList all = await events.ListAsync(s_agent, null, 0, 10_000, null, 10, default); + Assert.Equal(5, all.Count); + Assert.True(all[0].Id > all[1].Id); // 新的在前 + IReadOnlyList page2 = await events.ListAsync(s_agent, null, 0, 10_000, all[1].Id, 10, default); + Assert.Equal(3, page2.Count); + Assert.Equal(3, (await events.ListAsync(s_agent, "a", 0, 10_000, null, 10, default)).Count); + Assert.Equal(2, await events.DeleteOlderThanAsync(1_002, default)); + } + + // ───────────────────────── 写入者与模式 ───────────────────────── + + [Fact] + public async Task Writer_RollsBackAFailedTransaction_AndKeepsServingLaterWrites() + { + var settings = new SqliteSettingsStore(_db); + await Assert.ThrowsAsync(async () => await _db.WriteAsync(c => + { + c.Exec("INSERT INTO settings (key, value) VALUES ('half', 'written')"); + c.Exec("INSERT INTO settings (key, value) VALUES ('half', 'duplicate-primary-key')"); + }, default)); + + Assert.Null(await settings.GetAsync("half", default)); // 第一条插入也被回滚 + await settings.SetAsync("after", "still works", default); + Assert.Equal("still works", await settings.GetAsync("after", default)); + } + + [Fact] + public async Task Writes_AreSerialised_NoLostUpdatesUnderParallelLoad() + { + var settings = new SqliteSettingsStore(_db); + await Task.WhenAll(Enumerable.Range(0, 200).Select(i => Task.Run(async () => await settings.SetAsync("k" + i, i.ToString(System.Globalization.CultureInfo.InvariantCulture), default)))); + Assert.Equal(200, (await settings.GetAllAsync(default)).Count); + } + + [Fact] + public void Schema_RefusesADatabaseFromANewerBuild() + { + string dir = Path.Combine(Path.GetTempPath(), "monitor-schema-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + using (var connection = new SqliteConnection($"Data Source={Path.Combine(dir, "monitor.db")}")) + { + connection.Open(); + connection.Exec("PRAGMA user_version = 99"); + } + + SqliteConnection.ClearAllPools(); + Assert.Throws(() => new SqliteDatabase(dir)); + } + finally + { + SqliteConnection.ClearAllPools(); + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task Health_ReportsBothDatabaseAndRawDirectory() + { + StorageHealth health = await new SqliteStorageHealth(_db).CheckAsync(default); + Assert.True(health.Database); + Assert.True(health.RawDirectory); + } +} diff --git a/backend/tests/Monitor.TestSupport/AgentSimulator.cs b/backend/tests/Monitor.TestSupport/AgentSimulator.cs new file mode 100644 index 0000000..9510343 --- /dev/null +++ b/backend/tests/Monitor.TestSupport/AgentSimulator.cs @@ -0,0 +1,424 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Rpc; +using Monitor.Protocol; + +namespace Monitor.TestSupport; + +/// +/// 一个会说 Fleet 协议的假 Agent:用加入令牌注册 → 连接 → 签名认证 → hello → 接收期望状态 → 上传原始块并等待 ACK。 +/// 只用于测试和离线工具,不是真正的节点。 +/// +public sealed class AgentSimulator : IAsyncDisposable +{ + private readonly NodeIdentity _identity; + private readonly ClientWebSocket _socket = new(); + private readonly ConcurrentDictionary> _acks = new(); + private readonly ConcurrentDictionary> _responses = new(); + private readonly CancellationTokenSource _stop = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private long _nextId; + private int _disposed; + private Task? _loop; + + private AgentSimulator(NodeIdentity identity, NodeId nodeId, string status) + { + _identity = identity; + ExportedKey = identity.ExportPkcs8(); + NodeId = nodeId; + EnrollmentStatus = status; + } + + public NodeId NodeId { get; } + + /// 身份私钥(PKCS#8):释放之后仍可用它以同一个身份重新连接。 + public byte[] ExportedKey { get; } + + public string EnrollmentStatus { get; } + + public string AuthStatus { get; private set; } = ""; + + public ConcurrentQueue TaskSets { get; } = new(); + + public ConcurrentQueue ProbeAgentLists { get; } = new(); + + public ConcurrentQueue TestRuns { get; } = new(); + + public ConcurrentQueue TestCancels { get; } = new(); + + public IReadOnlyList RejectNextState { get; set; } = []; + + public bool Revoked { get; private set; } + + public string? CloseReason { get; private set; } + + /// 收到 monitor.test.run 时的应答:默认接受。 + public Func TestRunHandler { get; set; } = _ => (true, null); + + public byte[] PublicKey => _identity.PublicKeySpki; + + /// 用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。 + public byte[] SignAuth(NodeId claimedNode, string nonce) => _identity.Sign(SignaturePurpose.Auth, claimedNode.Value, nonce); + + public static async Task EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10") + { + NodeIdentity identity = NodeIdentity.Generate(); + string pubkey = Convert.ToBase64String(identity.PublicKeySpki); + byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)); + string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey)); + var body = new JsonObject + { + ["join_token"] = joinToken, + ["name"] = name, + ["pubkey"] = pubkey, + ["tls_spki"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + ["proof"] = proof, + ["node_version"] = "0.0.1-sim", + ["meta"] = new JsonObject + { + ["probe_tcp_port"] = 7443, + ["probe_udp_ports"] = new JsonArray(7443), + ["public_host"] = publicHost, + ["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()), + }, + }; + + using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync("/api/agent/v1/enroll", content); + string text = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + identity.Dispose(); + throw new InvalidOperationException($"enroll failed: {(int)response.StatusCode} {text}"); + } + + JsonObject result = JsonNode.Parse(text)!.AsObject(); + return new AgentSimulator(identity, new NodeId(result["node_id"]!.GetValue()), result["status"]!.GetValue()); + } + + /// 用已有的身份恢复一个模拟器(例如后端重启之后同一个节点重新连接)。 + public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId) => new(NodeIdentity.FromPkcs8(pkcs8), nodeId, "active"); + + /// 连接并完成认证与 hello。认证失败时抛出异常。 + public async Task ConnectAsync(Uri baseAddress, bool sendHello = true, string[]? kinds = null) + { + var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri; + await _socket.ConnectAsync(uri, _stop.Token); + + RpcMessage challenge = await ReceiveMessageAsync(_stop.Token) ?? throw new InvalidOperationException("no challenge"); + ChallengeParams nonce = challenge.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; + string signature = Convert.ToBase64String(_identity.Sign(SignaturePurpose.Auth, NodeId.Value, nonce.Nonce)); + await SendAsync(RpcMessage.Request(NextId(), FleetMethods.Auth, new AuthParams { NodeId = NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams)); + RpcMessage? authReply = await ReceiveMessageAsync(_stop.Token); + if (authReply?.Error is { } error) + { + throw new InvalidOperationException($"auth failed: {error.Data?.Code} {error.Message}"); + } + + AuthStatus = authReply!.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status; + _loop = Task.Run(ReceiveLoopAsync); + if (sendHello) + { + await HelloAsync(kinds); + } + } + + public async Task HelloAsync(string[]? kinds = null) + { + var meta = JsonSerializer.SerializeToElement( + new AgentHelloMeta + { + TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + ProbeTcpPort = 7443, + ProbeUdpPorts = [7443], + LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 }, + }, + MonitorJsonContext.Default.AgentHelloMeta); + var hello = new HelloParams + { + NodeVersion = "0.0.1-sim", + Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"])], + NowUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(), + Meta = meta, + }; + await RequestAsync(FleetMethods.Hello, JsonSerializer.SerializeToElement(hello, FleetRpcContext.Default.HelloParams)); + } + + /// 上传一个原始块并等待服务端 ACK(持久化之后才会确认)。 + /// 设置之后,这个假 Agent 发出的每个块都归属于这个 Probe(夹具的默认 Probe)。 + public ProbeId? ProbeForChunks { get; set; } + + public async Task SendChunkAsync(RawChunk chunk, TimeSpan? timeout = null) + { + if (ProbeForChunks is { } probe) + { + chunk.Header.ProbeId = probe; + } + + byte[] bytes = RawChunkCodec.Encode(chunk); + Guid id = chunk.Header.ChunkId.Value; + var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _acks[id] = ack; + await SendBinaryAsync(OutboxFrame.Build(MonitorChannels.RawChunk, id, bytes)); + return await ack.Task.WaitAsync(timeout ?? TimeSpan.FromSeconds(10)); + } + + /// 发送一个任意的文本帧(用来测试服务端对非法输入的处理)。 + public Task SendTextAsync(string text) => SendFrameAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text); + + /// 发送一个任意的二进制帧(用来测试服务端对非法输入的处理)。 + public Task SendBinaryAsync(byte[] frame) => SendFrameAsync(frame, WebSocketMessageType.Binary); + + public Task SendEventAsync(string kind, string? detail = null) => + SendAsync(RpcMessage.Notification( + MonitorMethods.Event, + new AgentEventParams { Kind = kind, AtUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail }, + MonitorJsonContext.Default.AgentEventParams)); + + public Task SendHostAsync(HostSnapshot snapshot) => + SendAsync(RpcMessage.Notification(MonitorMethods.Host, snapshot, MonitorJsonContext.Default.HostSnapshot)); + + public Task SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) => + SendAsync(RpcMessage.Notification( + MonitorMethods.TestStatus, + new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode }, + MonitorJsonContext.Default.TestStatusParams)); + + public async Task RequestProbeAgentsAsync() + { + RpcMessage reply = await RequestAsync(MonitorMethods.ProbeAgents, JsonSerializer.SerializeToElement(new ProbeAgentsParams(), MonitorJsonContext.Default.ProbeAgentsParams)); + return reply.Result!.Value.Deserialize(MonitorJsonContext.Default.ProbeAgentsResult)!; + } + + public async Task RequestRawAsync(string method, JsonElement? parameters = null) => await RequestAsync(method, parameters); + + public async Task WaitForTaskSetAsync(Func predicate, TimeSpan? timeout = null) + { + long started = TimeProvider.System.GetTimestamp(); + TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10); + while (TimeProvider.System.GetElapsedTime(started) < limit) + { + foreach (TaskSet set in TaskSets.ToArray().Reverse()) + { + if (predicate(set)) + { + return set; + } + } + + await Task.Delay(25); + } + + throw new TimeoutException("the expected task set never arrived"); + } + + public async Task WaitUntilAsync(Func condition, TimeSpan? timeout = null) + { + long started = TimeProvider.System.GetTimestamp(); + TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10); + while (TimeProvider.System.GetElapsedTime(started) < limit) + { + if (condition()) + { + return; + } + + await Task.Delay(25); + } + + throw new TimeoutException("condition was not met in time"); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await _stop.CancelAsync(); + if (_socket.State == WebSocketState.Open) + { + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + } + catch (WebSocketException) + { + // 已经断开 + } + } + + if (_loop is not null) + { + try + { + await _loop; + } + catch (OperationCanceledException) + { + // 正常结束 + } + } + + _socket.Dispose(); + _identity.Dispose(); + _stop.Dispose(); + _sendLock.Dispose(); + } + + private long NextId() => Interlocked.Increment(ref _nextId); + + private async Task RequestAsync(string method, JsonElement? parameters) + { + long id = NextId(); + var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _responses[id] = waiter; + await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters }); + return await waiter.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + + private Task SendAsync(RpcMessage message) => + SendFrameAsync(JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage), WebSocketMessageType.Text); + + private async Task SendFrameAsync(byte[] bytes, WebSocketMessageType type) + { + await _sendLock.WaitAsync(_stop.Token); + try + { + await _socket.SendAsync(bytes, type, endOfMessage: true, _stop.Token); + } + finally + { + _sendLock.Release(); + } + } + + private async Task ReceiveMessageAsync(CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + using var message = new MemoryStream(); + while (true) + { + WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + CloseReason = result.CloseStatusDescription; + if (_socket.State == WebSocketState.CloseReceived) + { + // 按协议确认对端发起的关闭 + try + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None); + } + catch (WebSocketException) + { + // 对端已经断开 + } + } + + return null; + } + + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage); + } + } + } + + private async Task ReceiveLoopAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + RpcMessage? message = await ReceiveMessageAsync(_stop.Token); + if (message is null) + { + return; + } + + await HandleAsync(message); + } + } + catch (OperationCanceledException) + { + // 正常结束 + } + catch (WebSocketException) + { + // 连接断开 + } + } + + private async Task HandleAsync(RpcMessage message) + { + if (message.IsResponse) + { + if (_responses.TryRemove(message.Id!.Value, out TaskCompletionSource? waiter)) + { + waiter.TrySetResult(message); + } + + return; + } + + switch (message.Method) + { + case FleetMethods.StateSync: + StateSyncParams sync = message.Params!.Value.Deserialize(FleetRpcContext.Default.StateSyncParams)!; + if (sync.Type == TaskSet.StateType) + { + TaskSets.Enqueue(sync.Doc.Deserialize(MonitorJsonContext.Default.TaskSet)!); + } + + var result = new StateSyncResult { AppliedVersion = sync.Version, Rejected = [.. RejectNextState] }; + await SendAsync(RpcMessage.Success(message.Id!.Value, result, FleetRpcContext.Default.StateSyncResult)); + break; + case FleetMethods.OutboxAck: + OutboxAckParams ack = message.Params!.Value.Deserialize(FleetRpcContext.Default.OutboxAckParams)!; + foreach (string id in ack.Ids.Concat(ack.Duplicates)) + { + if (_acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter)) + { + waiter.TrySetResult(ack.Duplicates.Contains(id) ? false : true); + } + } + + break; + case FleetMethods.Revoked: + Revoked = true; + break; + case MonitorMethods.TestRun: + TestRunParams run = message.Params!.Value.Deserialize(MonitorJsonContext.Default.TestRunParams)!; + TestRuns.Enqueue(run); + (bool accepted, string? errorCode) = TestRunHandler(run); + if (accepted) + { + await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MonitorJsonContext.Default.TestRunResult)); + } + else + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(new ErrorCode(errorCode ?? "test_busy"), "refused"))); + } + + break; + case MonitorMethods.TestCancel: + TestCancelParams cancel = message.Params!.Value.Deserialize(MonitorJsonContext.Default.TestCancelParams)!; + TestCancels.Enqueue(cancel.TestId); + await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MonitorJsonContext.Default.TestRunResult)); + break; + case MonitorMethods.ProbeAgentsChanged: + ProbeAgentLists.Enqueue(message.Params!.Value.Deserialize(MonitorJsonContext.Default.ProbeAgentsResult)!); + break; + } + } +} diff --git a/backend/tests/Monitor.TestSupport/AgentWire.cs b/backend/tests/Monitor.TestSupport/AgentWire.cs new file mode 100644 index 0000000..685f307 --- /dev/null +++ b/backend/tests/Monitor.TestSupport/AgentWire.cs @@ -0,0 +1,64 @@ +using System.Net.WebSockets; +using System.Text.Json; +using Fleet.Core; +using Fleet.Rpc; + +namespace Monitor.TestSupport; + +public sealed record AuthAttempt(bool Ok, string? Status, string? ErrorCode, string? CloseReason); + +/// 只做 Fleet 的挑战/认证握手:用来测试被拒绝的连接(冒名、被禁用的节点等),不保持连接。 +public static class AgentWire +{ + public static async Task AuthenticateAsync(Uri baseAddress, NodeId claimedNode, Func signNonce) + { + using var socket = new ClientWebSocket(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri; + await socket.ConnectAsync(uri, timeout.Token); + + RpcMessage? challenge = await ReceiveAsync(socket, timeout.Token); + ChallengeParams nonce = challenge!.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; + var auth = new AuthParams { NodeId = claimedNode.Value, Signature = Convert.ToBase64String(signNonce(nonce.Nonce)) }; + byte[] request = JsonSerializer.SerializeToUtf8Bytes(RpcMessage.Request(1, FleetMethods.Auth, auth, FleetRpcContext.Default.AuthParams), FleetRpcContext.Default.RpcMessage); + await socket.SendAsync(request, WebSocketMessageType.Text, endOfMessage: true, timeout.Token); + + string? closeReason = null; + while (true) + { + RpcMessage? reply = await ReceiveAsync(socket, timeout.Token); + if (reply is null) + { + closeReason = socket.CloseStatusDescription; + return new AuthAttempt(false, null, null, closeReason); + } + + if (reply.IsResponse) + { + return reply.Error is { } error + ? new AuthAttempt(false, null, error.Data?.Code, closeReason) + : new AuthAttempt(true, reply.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status, null, null); + } + } + } + + private static async Task ReceiveAsync(ClientWebSocket socket, CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + using var message = new MemoryStream(); + while (true) + { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, ct); + if (result.MessageType == WebSocketMessageType.Close) + { + return null; + } + + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage); + } + } + } +} diff --git a/backend/tests/Monitor.TestSupport/ChunkFactory.cs b/backend/tests/Monitor.TestSupport/ChunkFactory.cs new file mode 100644 index 0000000..2a01dab --- /dev/null +++ b/backend/tests/Monitor.TestSupport/ChunkFactory.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Fleet.Core; +using Monitor.Protocol; + +namespace Monitor.TestSupport; + +/// 每一个回显包的设定:上行或下行丢失,以及两个方向的单向时延(微秒)。 +public readonly record struct EchoPacket(bool LostUp, bool LostDown, long OwdUpUs, long OwdDownUs, long ProcessUs = 50); + +/// 合成原始块:用于测试、模拟器和离线工具。块头里的时钟锚点保证 Probe 与 Agent 时钟相差 。 +public static class ChunkFactory +{ + /// 合成块默认属于哪个 Probe(测试夹具在后端创建一个真实的 Probe 之后把它的 id 写在这里)。 + public static string DefaultProbe { get; set; } = "prb_test"; + + public const long AgentMonoAnchorUs = 1_000_000_000; + public const long ProbeOffsetUs = 5_000_000; + + public static RawChunkHeader Header(NodeId agent, string kind, long startUtcMs, int durationMs = 10_000, IReadOnlyDictionary? parameters = null) + { + KindSpec spec = Kinds.Find(kind)!; + ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters); + return new RawChunkHeader + { + ChunkId = new ChunkId(Guid.CreateVersion7()), + Kind = kind, + AgentId = agent, + ProbeId = new ProbeId(DefaultProbe), + Params = new Dictionary(resolved.Values!.Values), + Port = 7443, + StartUtcMs = startUtcMs, + DurationMs = durationMs, + Clock = new ChunkClock { AgentMonoUs = AgentMonoAnchorUs, UtcMs = startUtcMs, ProbeOffsetUs = ProbeOffsetUs }, + }; + } + + /// Probe 时钟下,距离块起点 毫秒的时刻(微秒)。 + public static long ProbeUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs - ProbeOffsetUs; + + public static long AgentUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs; + + public static RawChunk Echo( + NodeId agent, + string kind, + long startUtcMs, + int count, + int intervalMs, + Func packet, + int durationMs = 10_000, + IReadOnlyDictionary? parameters = null, + IEnumerable? extraStreams = null) + { + var msg = new StreamBuilder(Kinds.Find(kind)!.FindStream("msg")!); + for (int i = 0; i < count; i++) + { + EchoPacket p = packet(i); + long t1 = ProbeUs((long)i * intervalMs); + long t2 = t1 + ProbeOffsetUs + p.OwdUpUs; + long t3 = t2 + p.ProcessUs; + long t4 = t3 - ProbeOffsetUs + p.OwdDownUs; + msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4); + } + + var streams = new List { msg.Build() }; + if (extraStreams is not null) + { + streams.AddRange(extraStreams); + } + + return new RawChunk(Header(agent, kind, startUtcMs, durationMs, parameters), streams); + } + + /// 10 Hz 的 udp_echo,没有丢包,RTT = 2 × owdMs。 + public static RawChunk UdpEchoClean(NodeId agent, long startUtcMs, int count = 100, double owdMs = 20) => + Echo(agent, "udp_echo", startUtcMs, count, 100, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000))); + + public static RawStream TcpInfo(IEnumerable<(long TsUs, byte Side, uint SrttUs, uint RetransTotal, uint SegsOut)> samples) + { + var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!); + foreach ((long ts, byte side, uint srtt, uint retrans, uint segs) in samples) + { + builder.Add(ts, side, srtt, srtt / 4, 10, retrans, segs, segs * 100L); + } + + return builder.Build(); + } + + public static RawChunk Connect(NodeId agent, long startUtcMs, IEnumerable<(long TcpUs, long? TlsUs, ConnectOutcomeCode Outcome)> attempts) + { + var builder = new StreamBuilder(Kinds.Connect.FindStream("conn")!); + int i = 0; + foreach ((long tcp, long? tls, ConnectOutcomeCode outcome) in attempts) + { + builder.Add(i, ProbeUs(i * 5000L), outcome == ConnectOutcomeCode.Timeout ? null : tcp, tls, (long)outcome); + i++; + } + + return new RawChunk(Header(agent, "connect", startUtcMs), [builder.Build()]); + } + + /// udp_train:Agent 以固定间隔发包,Probe 记录收到的时刻; 为 N 时每 N 个包丢一个。 + public static RawChunk Train(NodeId agent, long startUtcMs, double mbps, int phase, int seconds = 10, int lossEvery = 0, long owdUs = 20_000) + { + const int size = 1250; + int packets = (int)(mbps * 1_000_000 / 8 / size * seconds); + double intervalUs = seconds * 1_000_000.0 / packets; + var pkt = new StreamBuilder(Kinds.UdpTrain.FindStream("pkt")!); + for (int i = 0; i < packets; i++) + { + long sendAgent = AgentMonoAnchorUs + (long)(i * intervalUs); + bool lost = lossEvery > 0 && i % lossEvery == 0; + long recvProbe = sendAgent + owdUs - ProbeOffsetUs; + pkt.Add(i, size, sendAgent, lost ? null : recvProbe); + } + + var parameters = new Dictionary + { + ["mbps"] = JsonSerializer.SerializeToElement(mbps, FactoryJson.Default.Double), + ["duration_s"] = JsonSerializer.SerializeToElement((double)seconds, FactoryJson.Default.Double), + }; + RawChunkHeader header = Header(agent, "udp_train", startUtcMs, seconds * 1000, parameters); + header.Phase = phase; + header.TestId = new TestId("tst_test"); + return new RawChunk(header, [pkt.Build(), new StreamBuilder(Kinds.UdpTrain.FindStream("step")!).Build()]); + } +} + +public enum ConnectOutcomeCode +{ + Ok = 0, + Timeout = 1, + Reset = 2, + TlsAlert = 3, +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(double))] +internal sealed partial class FactoryJson : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/tests/Monitor.TestSupport/Monitor.TestSupport.csproj b/backend/tests/Monitor.TestSupport/Monitor.TestSupport.csproj new file mode 100644 index 0000000..ae60e1e --- /dev/null +++ b/backend/tests/Monitor.TestSupport/Monitor.TestSupport.csproj @@ -0,0 +1,10 @@ + + + false + + + + + + + diff --git a/backend/tests/Monitor.TestSupport/ProbeSimulator.cs b/backend/tests/Monitor.TestSupport/ProbeSimulator.cs new file mode 100644 index 0000000..8e3c5df --- /dev/null +++ b/backend/tests/Monitor.TestSupport/ProbeSimulator.cs @@ -0,0 +1,466 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text.Json; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Link; +using Fleet.Rpc; +using Monitor.Protocol; + +namespace Monitor.TestSupport; + +/// +/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 line_task_set, +/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。 +/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。 +/// +public sealed class ProbeSimulator : IAsyncDisposable +{ + private readonly NodeIdentity _identity; + private readonly RpcRouter _router; + private readonly ConcurrentDictionary _acks = new(); + private readonly ConcurrentDictionary _echoes = new(); + private readonly ConcurrentDictionary _trains = new(); + private readonly ConcurrentQueue _taskSets = new(); + private readonly long _origin = TimeProvider.System.GetTimestamp(); + private readonly CancellationTokenSource _stop = new(); + private NodeId _agentId; + private RpcSession? _rpc; + private UdpClient? _udp; + private IPEndPoint? _agentUdp; + private PeerSessionInfo? _info; + private Task? _receiver; + private Task? _run; + + public ProbeSimulator(string probeId, NodeIdentity identity) + { + ProbeId = probeId; + _identity = identity; + _router = BuildRouter(); + } + + public string ProbeId { get; } + + /// 同一个身份的另一个假 Probe(模拟 Probe 重启:新的进程、新的会话,序号从 0 重新开始)。 + public ProbeSimulator Restarted() => new(ProbeId, _identity); + + /// true 时拒绝按需测试的阶段(用来测试 Agent 对 Probe 拒绝的处理)。 + public bool RefusePhases { get; set; } + + /// 收到 phase_end 之后不交出列块,只报告 0 个(用来测试 Agent 的等待逻辑)。 + public bool DropFragments { get; set; } + + public long ClockOffsetUs { get; private set; } + + public IReadOnlyCollection TaskSets => [.. _taskSets]; + + public ConcurrentQueue Phases { get; } = new(); + + public ConcurrentQueue Aborts { get; } = new(); + + public ConcurrentQueue AgentLists { get; } = new(); + + public bool IsConnected => _rpc is not null && !_stop.IsCancellationRequested; + + public long NowUs() => (TimeProvider.System.GetTimestamp() - _origin) * 1_000_000 / TimeProvider.System.TimestampFrequency; + + public async Task ConnectAsync(string host, int controlPort, byte[] agentSpkiSha256, int udpPort, NodeId agentId, TimeProvider? time = null, CancellationToken ct = default) + { + _agentId = agentId; + (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentSpkiSha256, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct); + _info = info; + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + _agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort); + _rpc = new RpcSession(transport, _router, this, time ?? TimeProvider.System); + _rpc.OnBinary = (_, _) => ValueTask.CompletedTask; + _run = _rpc.RunAsync(_stop.Token); + _receiver = Task.Run(ReceiveLoopAsync, _stop.Token); + await SyncClockAsync(); + } + + public async Task WaitForTasksAsync(Func predicate, TimeSpan? timeout = null) + { + long started = Stopwatch.GetTimestamp(); + TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15); + while (Stopwatch.GetElapsedTime(started) < limit) + { + LineTaskSet? found = _taskSets.ToArray().LastOrDefault(predicate); + if (found is not null) + { + return found; + } + + await Task.Delay(20); + } + + throw new TimeoutException("the agent did not send the expected line task set"); + } + + /// NTP 式的时钟同步:偏移 = Agent 时钟 − (往返中点的) Probe 时钟。取最小往返的一次。 + public async Task SyncClockAsync(int rounds = 5) + { + long bestRoundTrip = long.MaxValue; + for (int i = 0; i < rounds; i++) + { + long t0 = NowUs(); + Result reply = await _rpc!.RequestAsync(MonitorMethods.Clock, new ClockParams { ProbeMonoUs = t0 }, MonitorJsonContext.Default.ClockParams, MonitorJsonContext.Default.ClockResult, CancellationToken.None); + long t1 = NowUs(); + long roundTrip = t1 - t0; + if (reply.IsSuccess && roundTrip < bestRoundTrip) + { + bestRoundTrip = roundTrip; + ClockOffsetUs = reply.Value.AgentMonoUs - ((t0 + t1) / 2); + } + } + } + + /// 对一个 udp_echo 线路任务发 个回显包,等回复,然后交出一个列块。返回交给 Agent 的列块的序号范围。 + public async Task<(int Sent, int Received)> RunUdpEchoAsync(LineTask task, int count, TimeSpan interval, CancellationToken ct = default) + { + int size = (int)task.Params["size"].GetInt64(); + var collector = new EchoCollector(); + _echoes[task.StreamId] = collector; + long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + byte[] packet = new byte[size]; + for (uint seq = 0; seq < count; seq++) + { + long t1 = NowUs(); + collector.Sent[seq] = t1; + UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key); + await _udp!.SendAsync(packet, packet.Length, _agentUdp!); + await Task.Delay(interval, ct); + } + + await Task.Delay(300, ct); // 等最后的回复 + _echoes.TryRemove(task.StreamId, out _); + + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key)) + { + msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null); + } + + RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params); + await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct); + return (count, collector.Received.Count); + } + + /// 对一个 tcp_echo 线路任务建立一条 TCP(可选 TLS)连接,发 条回显消息,然后交出一个列块。 + public async Task<(int Sent, int Received)> RunTcpEchoAsync(LineTask task, int tcpPort, int count, TimeSpan interval, bool tls, CancellationToken ct = default) + { + using var client = new System.Net.Sockets.TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, tcpPort, ct); + Stream stream = client.GetStream(); + if (tls) + { +#pragma warning disable CA5359 // 测试:Agent 的证书是自签名的,这里只验证数据面 + var ssl = new System.Net.Security.SslStream(stream, false, (_, _, _, _) => true); +#pragma warning restore CA5359 + await ssl.AuthenticateAsClientAsync(new System.Net.Security.SslClientAuthenticationOptions { TargetHost = "agent" }, ct); + stream = ssl; + } + + byte[] auth = new byte[TcpWire.AuthBytes]; + TcpWire.WriteAuth(auth, _info!.Credentials.SessionIdValue, task.StreamId, RandomNumberGenerator.GetBytes(TcpWire.NonceBytes), _info.Credentials.Key); + await stream.WriteAsync(auth, ct); + + long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + int received = 0; + for (uint seq = 0; seq < count; seq++) + { + byte[] body = new byte[48]; + long t1 = NowUs(); + TcpWire.WriteEchoBody(body, seq, t1); + await TcpWire.WriteMessageAsync(stream, body, ct); + await stream.FlushAsync(ct); + byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct); + long? t4 = reply is not null && TcpWire.TryReadEchoBody(reply, out uint echoed, out _) && echoed == seq ? NowUs() : null; + received += t4 is null ? 0 : 1; + msg.Add(seq, body.Length, t1, t4); + await Task.Delay(interval, ct); + } + + RawChunkHeader header = Header("tcp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params); + await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct); + return (count, received); + } + + /// 发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。 + public Task SendRawUdpAsync(byte[] datagram) => _udp!.SendAsync(datagram, datagram.Length, _agentUdp!); + + /// 用伪造的密钥发一个回显包。 + public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null) + { + byte[] packet = new byte[64]; + UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]); + return _udp!.SendAsync(packet, packet.Length, _agentUdp!); + } + + /// 重放:用同一个序号、同样的密钥再发一次。 + public Task SendEchoAsync(LineTask task, uint seq) + { + byte[] packet = new byte[64]; + UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key); + return _udp!.SendAsync(packet, packet.Length, _agentUdp!); + } + + public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0; + + /// 交出一个已经构造好的列块并等待 Agent 确认。 + public async Task ShipAsync(RawChunk fragment, bool waitForAck = true, CancellationToken ct = default) + { + Guid id = fragment.Header.ChunkId.Value; + var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _acks[id] = ack; + await _rpc!.SendBinaryAsync(OutboxFrame.Build(MonitorChannels.ColumnBlock, id, RawChunkCodec.Encode(fragment)), ct); + if (waitForAck) + { + await ack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _udp?.Dispose(); + if (_rpc is not null) + { + await _rpc.DisposeAsync(); + } + + foreach (Task? task in new[] { _run, _receiver }) + { + if (task is not null) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or ObjectDisposedException or SocketException or IOException) + { + // 关闭中 + } + } + } + + _stop.Dispose(); + } + + private RawChunkHeader Header(string kind, TaskId? task, TestId? test, int? phase, long startUtcMs, int durationMs, IReadOnlyDictionary? extra) + { + var header = new RawChunkHeader + { + ChunkId = new ChunkId(Guid.CreateVersion7()), + Kind = kind, + AgentId = _agentId, + TaskId = task, + TestId = test, + Phase = phase, + ProbeId = new ProbeId(ProbeId), + StartUtcMs = startUtcMs, + DurationMs = durationMs, + Clock = new ChunkClock { ProbeOffsetUs = ClockOffsetUs }, + }; + if (extra is not null) + { + foreach ((string key, JsonElement value) in extra) + { + header.Params[key] = value; + } + } + + return header; + } + + private async Task ReceiveLoopAsync() + { + while (!_stop.IsCancellationRequested) + { + UdpReceiveResult result; + try + { + result = await _udp!.ReceiveAsync(_stop.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException) + { + if (_stop.IsCancellationRequested || ex is ObjectDisposedException) + { + return; + } + + continue; + } + + long now = NowUs(); + byte[] data = result.Buffer; + if (!UdpPacket.TryParseHeader(data, out UdpHeader header) || !UdpPacket.VerifyMac(data, _info!.Credentials.Key)) + { + continue; + } + + if (header.Type == UdpPacketType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo)) + { + echo.Received.TryAdd(header.Seq, now); + } + else if (header.Type == UdpPacketType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train)) + { + train.Received.TryAdd(header.Seq, now); + train.Size = data.Length; + } + } + } + + private RpcRouter BuildRouter() => + new RpcRouter() + .On( + FleetMethods.StateSync, + FleetRpcContext.Default.StateSyncParams, + FleetRpcContext.Default.StateSyncResult, + (probe, sync, _) => + { + if (sync.Type == LineTaskSet.StateType) + { + probe._taskSets.Enqueue(sync.Doc.Deserialize(MonitorJsonContext.Default.LineTaskSet)!); + } + + return ValueTask.FromResult(Result.Ok(new StateSyncResult { AppliedVersion = sync.Version })); + }) + .On( + MonitorMethods.TestPhase, + MonitorJsonContext.Default.TestPhaseStartParams, + MonitorJsonContext.Default.TestPhaseStartResult, + async (probe, start, ct) => Result.Ok(new TestPhaseStartResult { Accepted = await probe.StartPhaseAsync(start, ct) })) + .On( + MonitorMethods.TestPhaseEnd, + MonitorJsonContext.Default.TestPhaseEndParams, + MonitorJsonContext.Default.TestPhaseEndResult, + async (probe, end, ct) => Result.Ok(new TestPhaseEndResult { Fragments = await probe.EndPhaseAsync(end, ct) })) + .OnNotify( + MonitorMethods.TestAbort, + MonitorJsonContext.Default.TestAbortParams, + (probe, abort, _) => + { + probe.Aborts.Enqueue(abort.TestId); + return ValueTask.CompletedTask; + }) + .OnNotify( + MonitorMethods.ProbeAgentsChanged, + MonitorJsonContext.Default.ProbeAgentsResult, + (probe, list, _) => + { + probe.AgentLists.Enqueue(list); + return ValueTask.CompletedTask; + }) + .OnNotify( + FleetMethods.OutboxAck, + FleetRpcContext.Default.OutboxAckParams, + (probe, ack, _) => + { + foreach (string id in ack.Ids.Concat(ack.Duplicates)) + { + if (probe._acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter)) + { + waiter.TrySetResult(); + } + } + + return ValueTask.CompletedTask; + }); + + private readonly ConcurrentDictionary _activePhases = new(); + + private async Task StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct) + { + Phases.Enqueue(start); + if (RefusePhases) + { + return false; + } + + _activePhases[start.StreamId] = start; + if (start.Kind == "udp_train") + { + _trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() }; + // 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted + byte[] hello = new byte[64]; + for (uint seq = 0; seq < 3; seq++) + { + UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key); + await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct); + } + } + + return true; + } + + private async Task EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct) + { + _activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start); + if (start is null || DropFragments) + { + _trains.TryRemove(end.StreamId, out _); + return 0; + } + + if (start.Kind == "udp_train" && _trains.TryRemove(end.StreamId, out TrainCollector? train)) + { + await Task.Delay(200, ct); // 最后一批包还在路上 + // 单块的记录数有上限(ProtocolLimits.MaxRecordsPerChunk):按序号范围切成多个列块,Agent 一侧按同样的范围取行 + const int PerFragment = 40_000; + uint total = (uint)end.SentCount; + int fragments = 0; + for (uint from = 0; from < total || fragments == 0; from += PerFragment) + { + uint to = (uint)Math.Min((long)from + PerFragment, total); + var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv", nullable: true)])); + foreach ((uint seq, long recv) in train.Received.Where(p => p.Key >= from && p.Key < to).OrderBy(p => p.Key)) + { + pkt.Add(seq, recv); + } + + var streams = new List { pkt.Build() }; + if (fragments == 0) + { + var step = new StreamBuilder(Kinds.UdpTrain.FindStream("step")!); + step.Add(0, (long)start.Params["mbps"].GetDouble(), NowUs() - (start.DurationSeconds * 1_000_000L), NowUs()); + streams.Add(step.Build()); + } + + RawChunkHeader header = Header("udp_train", null, start.TestId, start.Phase, train.StartUtcMs, start.DurationSeconds * 1000, start.Params); + header.Params[FragmentKeys.SeqFrom] = JsonSerializer.SerializeToElement(from); + header.Params[FragmentKeys.SeqTo] = JsonSerializer.SerializeToElement(to); + // 不能在这里等确认:确认是 Agent 的通知,由同一个入站队列顺序处理,而当前正处在 phase_end 的处理函数里 + await ShipAsync(new RawChunk(header, streams), waitForAck: false, ct: ct); + fragments++; + if (total == 0) + { + break; + } + } + + return fragments; + } + + return 0; + } + + private sealed class EchoCollector + { + public ConcurrentDictionary Sent { get; } = new(); + + public ConcurrentDictionary Received { get; } = new(); + } + + private sealed class TrainCollector + { + public ConcurrentDictionary Received { get; } = new(); + + public int Size { get; set; } + + public long StartUtcMs { get; init; } + } +} diff --git a/docs/analysis.md b/docs/analysis.md new file mode 100644 index 0000000..aa2545c --- /dev/null +++ b/docs/analysis.md @@ -0,0 +1,147 @@ +# 分析层(Backend.Analysis) + +## 1. 结构:算子 → 指标 → 模型 + +```text + 原始块(自描述列) + │ 解码为列视图(按列名读取) + ▼ + ┌────────────┐ ┌──────────────────────┐ ┌───────────────────────┐ + │ 算子 Op │──>│ 指标 MetricDef │──>│ 模型 Model(评分) │ + │ 可复用计算 │ │ 一行声明 = 一个指标 │ │ 指标的评分函数 │ + │ rtt/loss/… │ │ 输入 + 算子 + 累加器 │ │ base/mc/hy2/reality │ + └────────────┘ └──────────┬───────────┘ └──────────┬────────────┘ + │ 累加器状态(可合并) │ 查询时计算 + ▼ ▼ + 指标窗口(1m) → 5m → 1h API:metrics{} + scores{} +``` + +三层都采用**注册制**,由代码中的注册表驱动;API 的 `/catalog/metrics`、`/catalog/models` 也直接由注册表生成。 + +## 2. 累加器(固定的少数几种) + +所有指标的中间状态只能是下面几种累加器之一。**合并规则只写一次**,存储、降采样、按时间范围聚合都通用处理: + +| 累加器 | 状态 | 合并 | 可导出的值 | +| --- | --- | --- | --- | +| `Counter` | u64 | 相加 | 值;除以时长得到速率(例如每分钟) | +| `Ratio` | 分子、分母 | 分别相加 | 百分比 | +| `Sum` / `Min` / `Max` | f64 | 相加 / 取极值 | 值;平均值 = Sum / Counter | +| `Histogram` | 固定对数桶(100 µs~30 s,每 2 倍 8 个桶,相对误差 ≤ 4.5%)| 逐桶相加 | p50 / p90 / p95 / p99、均值、最值 | +| `RunLength` | 连续段长度的直方图 | 逐桶相加 | 平均或最大连续丢包长度 | +| `Series` | 固定分辨率的时间序列(例如每秒一个点) | 拼接 | 曲线(仅用于测试结果) | + +状态的二进制编码由 `AccumulatorCodec` 统一实现,存储层只把它当作不透明的字节处理。 + +## 3. 算子(可复用的计算,新增时才需要写代码) + +| 算子 | 输入列 | 作用 | +| --- | --- | --- | +| `rtt` | 回显流的 `t1..t4` | `(t4−t1)−(t3−t2)` | +| `loss(dir)` | 回显流或包列 | 按方向判断丢失 | +| `owd_var(dir)` | `t1,t2` 或 `t3,t4` | 去除偏移和线性漂移后的单向时延变化(抖动) | +| `loss_runs` | 丢失标记 | 连续丢失段的长度 | +| `reorder` / `dup` | `seq` | 乱序与重复 | +| `gap(expected, threshold)` | 到达时间 | 卡顿(到达间隔超过期望值 + 阈值) | +| `kernel_delta(field)` | `tcp_info` 流 | 两次采样之间的差值(重传、确认字节数) | +| `rate(dir)` | 字节数或包数 + 时间 | 每秒吞吐 | +| `phase_ms(a, b)` | `connect` 事件 | 阶段耗时(TCP 建连、TLS 握手) | +| `outcome` | 事件 | 按错误类别计数(超时、RST、TLS alert) | +| `change_point` | 逐秒序列 | 变点检测(QoS 开始限速的时刻) | + +算子都是纯函数,每个都有单元测试。 + +## 4. 指标定义(一行声明) + +```csharp +// Backend.Analysis/Metrics/LinkMetrics.cs +public static class LinkMetrics +{ + public static readonly MetricDef Rtt = + Metric.Histogram("rtt", from: "*.echo", op: Ops.Rtt, unit: Unit.Ms, + exports: [Stat.P50, Stat.P95, Stat.P99, Stat.Mean]); + + public static readonly MetricDef LossUp = + Metric.Ratio("loss.up", from: "udp_echo.msg", op: Ops.Loss(Dir.Up), unit: Unit.Pct); + + public static readonly MetricDef JitterDown = + Metric.Histogram("jitter.down", from: "udp_echo.msg", op: Ops.OwdVar(Dir.Down), unit: Unit.Ms, + exports: [Stat.P95]); + + public static readonly MetricDef TcpStall = + Metric.Counter("tcp.stall", from: "tcp_echo.msg", op: Ops.Gap(expectedFrom: "rate_hz", thresholdMs: 100), + unit: Unit.PerMin); + + public static readonly MetricDef TcpRetrans = + Metric.Ratio("tcp.retrans", from: "tcp_echo.tcp_info", op: Ops.KernelDelta("retrans_total", over: "segs_out"), + unit: Unit.Pct); +} +``` + +- `from` 写成 `kind.stream`,`*` 表示所有具有该 stream 的 Kind;某个窗口里没有对应的输入时,该指标在这个窗口就不存在。 +- **TCP 长尾**:均值和 p95 看不到偶发的重传尖峰(一次丢包后的 RTT 聚在 RTO 附近),所以 `tcp.rtt` 还导出 `p999`、`max` 和长尾比 `tail`(p99 / p50,从同一个直方图导出,窗口合并后仍然精确);`tcp.slow` 是慢回显的占比——阈值 `max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))`,基线是**同一个 10 秒块**里 RTT 的中位数(所以每个观测只依赖自己所在的块,窗口合并仍然精确;块里少于 20 个样本不产生观测)。把它和 `tcp.retrans`(内核计数,Agent 一侧反映下行)、`tcp.stall` 对照,可以区分"重传造成的尖峰"和"队头阻塞"。 +- 导出值的名字为 `指标id.统计量`,例如 `rtt.p95`、`loss.up`、`tcp.stall.per_min`。 + +**新增一个统计 = 新增一行 `MetricDef`**,如果需要新的算子,再加一个算子类。存储、降采样、API 输出、目录说明都不需要改。新指标上线后,会自动对保留期内(30 天)的原始数据回填(见第 7 节)。 + +## 5. 模型(服务体验评分) + +模型是一组指标的评分函数,**在查询时计算**,不存储。改动评分公式后,立即对全部历史生效。 + +```csharp +public sealed class McModel : IServiceModel +{ + public string Id => "mc"; + public int Version => 1; + public IReadOnlyList Needs => ["rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"]; + public ModelScore Score(MetricView m) => /* 分段打分 + 加权,返回 0..100 与各维度得分和说明 */; +} +``` + +| 模型 | 主要指标 | 说明 | +| --- | --- | --- | +| `base` | `rtt.*`、`loss.up/down`、`jitter.up/down`、`loss.run_max` | 线路基础质量 | +| `mc` | `tcp_echo` 的 `rtt.p95/p99`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans` | TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。没有 `tcp_echo` 数据时,用 `udp_echo` 的丢包序列代入 TCP 恢复模型估算,并标记为 `estimated` | +| `hy2` | 测试中 `udp_train` 各档的 `loss`、`jitter`,以及变点检测得到的 `qos_onset`;持续的 `udp_echo` 作为趋势参考 | Brutal 固定码率:计算可持续码率(丢包 ≤ 5% 或 ≤ 10%)、有效吞吐、限速开始的时刻 | +| `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:Probe 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(Probe 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 | + +## 6. 两种处理路径 + +```text +chunk 入库 ─┬─> 流式路径:按 1s 桶计算累加器 → 热层环形缓冲(最近 1 小时) + │ → 每满 10 s 写入 metric_10s → 推送 SSE(series / overview) + └─> 持久路径:分钟窗口就绪(该分钟的块都已到达,或已过窗口结束时间 + 1 分钟) + → 计算完整窗口 → 写入 metric_1m → 标记降采样脏桶(5m、1h) +按需测试:所有阶段的块到达后 → 计算测试结果(包含 Series 曲线)→ 写入 test_results +``` + +两条路径调用同一份 `MetricDef` 和算子代码,所以实时值与历史值使用的是同一套口径。迟到的块(例如 Agent 重连后补传)会同时更新热层(如果仍在 1 小时内)、`metric_10s`,并重新进入持久路径。 + +**指标分组(preset)** 与指标一样采用注册制:`Presets.Udp = [LinkMetrics.LossUp, …]`,由 `/api/public/catalog/presets` 下发。前端按分组展示,新增指标只需要把它加进分组。查询规则见 [query.md](query.md)。 + +## 7. 版本与重算 + +- 每个 `MetricDef` 都有一个指纹(由声明内容和算子版本计算得出)。启动时与 `metric_versions` 表比对,发现新增或变化的指标时,就把保留期内的相关窗口加入重算队列,由后台以低优先级、限速的方式执行,进度可以通过 API 查询。 +- 超出原始数据保留期的历史保留旧口径,查询结果中会标注指标的版本。 +- 模型只在查询时计算,不参与重算。 + +## 8. 置信说明 + +每个模型的输出都附带:各指标的样本量、`estimated` 标记,以及局限说明,例如: + +- 模拟的只是流量特征,不是真实协议; +- 测量端口与真实服务端口不同(`port_differs_from_service`,默认端口 7443/7444 时总会出现)。 + +## 9. 项目结构 + +```text +backend/src/Monitor.Backend.Analysis/ 依赖:Monitor.Protocol、Fleet.Core(纯计算,没有 I/O) +├─ Accumulators/ Counter Ratio Sum Min Max Histogram RunLength Series + AccumulatorCodec +├─ Ops/ Rtt Loss OwdVar LossRuns Reorder Gap KernelDelta Rate PhaseMs Outcome ChangePoint +├─ Metrics/ LinkMetrics TcpMetrics UdpTrainMetrics ConnectMetrics …(只包含声明) +├─ Models/ BaseModel McModel Hy2Model RealityModel +├─ Engine/ ChunkView(列视图) WindowEvaluator TestEvaluator MetricRegistry ModelRegistry +└─ Clock/ ClockDriftFit(估计两端的时钟偏移和漂移) +``` + +分析库没有 I/O,可以直接用于离线 CLI `tools/monitor-analyze`,对下载的原始块做实验。 diff --git a/docs/api-guidelines.md b/docs/api-guidelines.md new file mode 100644 index 0000000..266736d --- /dev/null +++ b/docs/api-guidelines.md @@ -0,0 +1,141 @@ +# API 设计约束 + +本文是所有 Backend REST 端点都必须遵守的**规则**;具体端点见 [api.md](api.md)。规则参考了 [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) 和 RFC 9457,并根据本项目做了取舍。内部协议(Probe↔Agent、Agent↔Backend)的兼容规则见 [protocol.md](protocol.md) 第 4 节。 + +## 1. 契约生成链路 + +```text +Minimal API 端点(TypedResults 返回类型 + 显式元数据) + │ dotnet build(Microsoft.Extensions.ApiDescription.Server) + ▼ +backend/openapi/v1.json(OpenAPI 3.1,提交到仓库) + │ CI:git diff --exit-code,未提交的变更会导致失败 + ▼ +web:openapi-typescript 生成类型,调用方的类型错误会直接暴露 +``` + +- 端点**必须**声明精确的返回类型,例如 `Results, NotFound>`,不能返回 `IResult` 或匿名对象,否则 OpenAPI 文档会不准确。 +- 每个端点都必须设置 `WithName("agents_list")`(即 operationId,snake_case、`资源_动作`)、`WithSummary`、`WithTags`。 +- 请求和响应 DTO 放在 `Monitor.Backend.Api/Contracts/`,并注册到 Backend 的 JSON 源生成上下文。**不要直接暴露 `Monitor.Protocol` 中的线协议类型**,因为 REST 与线协议的演进节奏不同。 +- **结构强类型,指标弱类型**:资源结构(Agent、任务、测试等)必须是强类型 DTO;而**指标值和评分**统一使用字典 `metrics: { "": number }`、`scores: { "": number }`,键的含义、单位、版本由 `/api/public/catalog/*` 给出。这样新增指标或模型不需要改 OpenAPI,也不需要改前端类型。 + +## 2. 版本与兼容性 + +- 当前版本为 v1,路径前缀为 `/api/public`、`/api/admin`、`/api/agent/v1`。出现破坏性变更时,新增 `/api/v2/public`、`/api/v2/admin` 等路径。v1 内只允许**非破坏性变更**: + +| 非破坏性变更(v1 内允许) | 破坏性变更(需要新建 v2) | +| --- | --- | +| 新增端点 | 删除或重命名端点、字段 | +| 响应中新增字段 | 修改字段类型、单位或语义 | +| 新增可选的请求字段或查询参数 | 可选字段改为必填 | +| 枚举新增值 | 删除枚举值 | +| 放宽校验 | 收紧校验、修改默认值 | +| 新增错误码 | 修改已有错误码对应的 HTTP 状态 | + +- 客户端必须忽略未知字段,也必须能处理未知的枚举值。 +- 需要 v2 时,v1 和 v2 至少并存一个发布周期;OpenAPI 文档按版本分文件(`v1.json`、`v2.json`)。 + +## 3. URL 与方法 + +- **按调用方分层**:`/api/public`(只读展示)、`/api/admin`(管理)、`/api/agent`(节点)。同一个资源在不同层中可以有不同的 DTO,例如 public 层不暴露 `pubkey`、`local_limits`。 +- 路径全部小写,资源名用复数名词,多个单词用 `-` 连接:`/api/admin/join-tokens`。 +- 嵌套最多一层;子资源一旦有全局唯一 ID,就提供顶层路径(`/probes/{probe_id}`)。 +- 查询参数和 JSON 字段使用 snake_case。 + +| 操作 | 方法 | 成功状态 | 说明 | +| --- | --- | --- | --- | +| 列表 | GET 集合 | 200 | 分页 | +| 读取 | GET 单个 | 200 | | +| 创建 | POST 集合 | 201 + `Location` | 响应体返回完整资源 | +| 部分更新 | PATCH | 200 | `application/merge-patch+json`(RFC 7396) | +| 删除 | DELETE | 204 | 资源不存在时同样返回 204(幂等) | +| 非 CRUD 动作 | POST `/{resource}/{id}/{verb}` | 200 或 204 | 例如 `revoke`、`rotate-token` | + +- GET 请求不能产生副作用。PUT 不使用:配置类资源都通过 PATCH 修改。 + +## 4. 数据表示 + +| 类别 | 规则 | 示例 | +| --- | --- | --- | +| 时间点 | RFC 3339 UTC,以 `Z` 结尾,精确到毫秒。**唯一例外**:列式时序响应中的时间轴数组 `t` 使用 Unix 毫秒(见 [query.md](query.md)) | `2026-09-27T10:00:00.000Z` | +| 时长 | 整数 + 单位后缀 | `interval_ms: 1000` | +| 延迟 | 带小数的毫秒值(指标字典中的单位见目录) | `rtt_p95_ms: 58.93` | +| 百分比 | 0–100 的数字 + `_pct` | `loss_pct: 0.5` | +| 计数 | 整数,不使用浮点数 | `attempts: 600` | +| ID | 带类型前缀的 ULID 字符串 | `prb_01J9Z...` | +| 枚举 | snake_case 字符串 | `"udp_echo"` | +| 缺失值 | 字段始终存在;未知值写 `null`,不省略字段 | `"last_seen_at": null` | +| 布尔值 | 使用 `is_`/`has_` 前缀,或者用意义明确的形容词 | `online`、`enabled` | + +## 5. 错误 + +所有 4xx/5xx 响应都使用 `application/problem+json`(RFC 9457),并附加扩展字段: + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "code": "validation_failed", + "detail": "interval_ms must be >= 200", + "trace_id": "00-4bf92f...-01", + "errors": { "interval_ms": ["must be >= 200"] } +} +``` + +- `code` 取自共享的 `ErrorCode` 枚举,是客户端做判断的**唯一**依据;`title`/`detail` 只供人阅读,内容可能变化。 +- `errors` 只在 400 `validation_failed` 中出现,键名是 JSON 字段路径。 +- 5xx 响应不能泄露异常信息或调用栈,只提供 `trace_id`,用于在日志中定位问题。 + +| 状态码 | 使用场景 | +| --- | --- | +| 400 | 请求格式错误或校验失败 | +| 401 | 未认证 | +| 404 | 资源不存在 | +| 403 | 已认证但作用域不足 | +| 409 | 名称冲突、状态冲突(例如 `probe_offline`) | +| 410 | 资源曾经存在但已过保留期(例如 `raw_expired`) | +| 412 | `If-Match` 不匹配 | +| 413 | 请求体超过上限 | +| 422 | 语义上无法满足(例如 `range_too_large`) | +| 429 | 超过限流,需要带 `Retry-After` | +| 503 | 存储不可用,或正在关闭 | + +## 6. 集合与查询 + +- 分页由服务端驱动:`?limit=`(默认 50,最大 500)和 `?cursor=`(不透明字符串);响应格式为 `{ "items": [...], "next_cursor": string | null }`。排序必须稳定(按 ID 或时间 + ID)。 +- 时间范围参数统一使用 `from`/`to`,区间为左闭右开 `[from, to)`。缺省时 `to = now`,`from = to − 1h`。 +- 所有查询都有上限(点数、时间跨度、结果条数);超出时返回 422 并在 `detail` 中给出可行的替代方案,不能悄悄截断。 + +## 7. 并发控制 + +- 配置类资源(Agent、Probe、任务)在响应中返回 `ETag`,PATCH/DELETE 支持 `If-Match`,不匹配时返回 412。没有带 `If-Match` 的请求按“最后写入者获胜”处理。 + +## 8. 安全 + +- 只提供 HTTPS。认证方式有两种: + - 浏览器使用 Cookie 会话:`HttpOnly; Secure; SameSite=Strict`;所有非 GET 请求都必须带 `X-CSRF-Token` 请求头; + - 脚本使用 `Authorization: Bearer `,作用域为 `read` 或 `admin`。 +- 凭据不能放在 URL 或查询参数中;SSE 依靠 Cookie 或同源令牌认证。 +- 登录接口需要限流(例如每个 IP 每分钟 10 次),失败时统一返回 401,不区分“用户不存在”和“密码错误”。 +- public 层根据站点设置和 `public_visible` 过滤数据:被隐藏的资源对匿名访客一律返回 404,而不是 403。 +- 令牌明文只在创建或轮换的响应中出现一次;这类响应带 `Cache-Control: no-store`。 +- 请求体上限为 64 KiB;查询接口有超时(默认 10 秒)。 + +## 9. 实时推送(SSE) + +- 实时数据使用 `text/event-stream`。`event` 名称就是数据类型(`line`、`node`、`point`、`test`);`data` 是单行 JSON,遵守第 4 节的数据表示规则。 +- 每 15 秒发送一行注释作为心跳。重连后服务端先推送当前快照,再推送增量,不补发历史。 +- 订阅的主题数和连接数都有上限,超出时返回 429 `too_many_streams`。 +- 推送的内容必须能由对应的 REST 查询重建:SSE 只负责“更快地获得变化”,不承载独有的数据。 + +## 10. 新增端点检查清单 + +1. 符合第 3 节的路径与方法规则,并有 operationId、summary 和 tag; +2. 返回类型精确(`TypedResults`),错误使用 problem+json,错误码来自 `ErrorCode`; +3. DTO 符合第 4 节的数据表示规则,并注册到 JSON 源生成上下文; +4. 列表支持分页,查询有上限; +5. 选择正确的层(public / admin / agent),public 层只暴露可以公开的字段; +6. 重新生成并提交 `openapi/v1.json`,在 PR 中说明是否属于破坏性变更; +7. 测试覆盖:成功路径、校验失败、404、鉴权失败、隐藏资源; +8. 同步更新 [api.md](api.md)。 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..9215bb4 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,167 @@ +# Backend API 规范(v1) + +本文列出 Backend 的全部对外接口。通用规则见 [api-guidelines.md](api-guidelines.md);OpenAPI 3.1 文档在运行时由 `/openapi/v1.json` 提供,构建时同时生成快照 `backend/openapi/v1.json`。两者冲突时,以 OpenAPI 为准。 + +## 1. 分层 + +```text +/api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌; + public_visible = false 的 Agent 对匿名访客隐藏 +/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌 +/api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证) +/healthz /metrics /openapi/v1.json /install.sh /install.ps1 +``` + +资源总览: + +```text +/api/public +├─ overview 首屏快照:所有线路的在线状态、最新指标、评分 +├─ live SSE 实时推送 +├─ lines/{line_id} 线路详情(线路 = Agent 与 Probe 的一对,id 形如 agt_…~prb_…) +├─ lines/{line_id}/series 历史曲线(指标与评分) +├─ compare 多条线路对比 +├─ tests/{test_id} 测试结果 +└─ catalog/{kinds|plans|metrics|models} 目录(由代码注册表生成) + +/api/admin +├─ auth/{login|logout|me} +├─ agents agents/{id} agents/{id}/{approve|disable|enable|takeover} +├─ join-tokens +├─ probes probes/{id} probes/{id}/{rotate|revoke} Probe 身份(后端生成、吊销、删除) +├─ tasks tasks/{id} 线路模板、针对单个 Agent 的覆盖、目标任务 +├─ lines/{line_id}/tests 在某条线路(某个 Probe)上发起按需测试;tests/{id}/cancel +├─ raw/chunks raw/chunks/{id} 原始数据检索与下载 +├─ releases 应用版本、灰度、批准(Fleet.Server.Releases) +├─ events analysis/recompute api-tokens settings users +``` + +## 2. Public API + +> 展示数据的查询规则(时间段、步长、分层、指标分组、列式响应、实时跟随、缓存)以 [query.md](query.md) 为准。本节只列出端点。 + +### 2.1 首屏排行 + +`GET /api/public/overview?range=5m&preset=udp,tcp,scores&sort=score.mc&order=desc` + +- 返回每条线路在该时间段内的精确汇总,以及在线状态、会话信息(源地址变化次数)、最近一次测试的摘要; +- `range` 默认 `5m`,也可以写 `from/to`; +- 汇总字段的格式见 [query.md](query.md) 第 6 节。 + +### 2.2 实时推送(SSE) + +`GET /api/public/live?topics=nodes,overview:15m:udp,series:agt_…:2s:udp,tcp,test:tst_…`(`text/event-stream`) + +| 事件 | 订阅主题 | 频率 | 内容 | +| --- | --- | --- | --- | +| `node` | `nodes` | 上下线时 | `{kind, id, online, at}` | +| `overview` | `overview:{range}:{preset}` | 约每 10 s | 每条线路的最新汇总 | +| `series` | `series:{line_id}:{step}:{preset}`(step ≤ 1m) | 约每 10 s | 新增的桶(列式)和最新的汇总 | +| `host` | `hosts` | Agent 每次上报(约 2 s) | `{agent_id, online, host}`:Agent 主机资源占用的最新快照(连接时先推一份当前的) | +| `test` | `test:{test_id}` | 状态变化时 | `{test_id, status, phase, phases}` | + +- 每 15 秒发送一行注释作为心跳;断线后浏览器会自动重连,服务端先推送当前快照,再推送增量,不补发历史。 +- 单个连接最多订阅 32 个主题;每个客户端最多 4 个 SSE 连接。 + +### 2.3 时序、对比、分布 + +| 端点 | 说明 | +| --- | --- | +| `GET /api/public/lines/{line_id}/series?range=15m&preset=udp,tcp` | 单条线路的时序(列式),附带整个时间段的精确汇总、缺口和事件 | +| `GET /api/public/compare?lines=a~p1,b~p1,c~p2&metric=loss.down&range=1h` | 多条线路的时间轴对齐对比 | +| `GET /api/public/lines/{line_id}/distribution?metric=rtt&range=15m` | 该时间段内的直方图和 CDF | +| `GET /api/public/lines/{line_id}/heatmap?metric=rtt&range=6h` | 时间 × 延迟桶的热力图 | + +参数、响应示例和限额见 [query.md](query.md)。 + +线路详情 `GET /api/public/lines/{line_id}` 带 `data_since`:这条线路最早的数据是什么时候(没有数据是 null),前端据此只提供「有数据」的时间范围。**展示端的响应里没有 Probe 的版本和来源地址**(那些只在管理端)。 + +### 2.3.1 Agent 的主机资源占用 + +| 端点 | 内容 | +| --- | --- | +| `GET /api/public/hosts` | 每个对外可见的 Agent 的最新快照:`{agent_id, name, online, stale, last_seen_at, host}`;超过 15 s 没有更新 `stale` 为真(离线,或者 Agent 关了上报) | +| `GET /api/public/agents/{agent_id}/host?range=1h` | 历史曲线(列式,按步长取平均;`range` 从 1 小时到 30 天):`cpu`、`mem`、`disk`(百分比)、`rx_bps`、`tx_bps` | + +`host` 只有汇总的数字:CPU 占用、内存与根分区的已用和总量、网卡累计收发字节与瞬时网速、系统运行时长。**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——Agent 不采集这些,公开接口当然也不会返回;有测试断言响应里不出现这类字段。实时更新走 SSE 的 `hosts` 主题,历史每个 Agent 每分钟存一个样本(`retention.host_days`,默认 7 天)。 + +### 2.4 测试结果与目录 + +- `GET /api/public/tests/{test_id}`:包括计划、参数、状态、阶段,`metrics` 与 `scores`,`series`(例如码率–丢包曲线),以及置信说明 `caveats`。 +- `GET /api/public/catalog/kinds`:每个 Kind 的积木组合与 `ParamSpec`(默认值和上下限)。 +- `GET /api/public/catalog/plans`、`/metrics`(id、单位、标签、说明、`better`、阈值、来源 Kind、版本)、`/presets`(指标分组)、`/models`(评分维度与所需指标)。 + +### 2.5 跨域访问 + +默认前端和 Backend 同源(Cookie 会话)。前端在别的来源时(比如本机跑前端、连线上的 Backend),Backend 设 `Monitor__CorsOrigins`(逗号分隔的来源,或 `*`;默认空 = 不允许)。只对 `/api/*` 生效,响应带 `Access-Control-Allow-Origin`,预检请求(`OPTIONS`)返回 204,允许的头是 `Authorization, Content-Type, X-CSRF-Token`。**不带凭据**,Cookie 不会跨域,所以跨域的调用只能用 `Authorization: Bearer `:展示端用只读令牌(站点私有时需要),管理端用 admin 范围的令牌(`/api/admin/auth/me` 对令牌同样可用)。SSE 在跨域时前端用 fetch 读流,因为 `EventSource` 带不了请求头。 + +## 3. Admin API + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/admin/auth/login` | `{username, password}` → 设置 `HttpOnly; Secure; SameSite=Strict` 的会话 Cookie,并返回 `csrf_token` | +| POST | `/api/admin/auth/logout` | | +| GET | `/api/admin/auth/me` | | +| GET | `/api/admin/agents` | 全部 Agent(包括 pending/disabled)、版本、本地硬上限、心跳计数、任务同步状态 | +| PATCH | `/api/admin/agents/{id}` | `name`、`labels`、`public_host`、`public_visible` | +| POST | `/api/admin/agents/{id}/approve` `/disable` `/enable` `/takeover` | 见 [enrollment.md](enrollment.md) | +| DELETE | `/api/admin/agents/{id}` | 删除;历史数据按保留策略自然过期 | +| GET/POST | `/api/admin/join-tokens` | 创建时返回令牌明文和安装命令(仅此一次) | +| DELETE | `/api/admin/join-tokens/{id}` | 吊销 | +| GET/POST | `/api/admin/probes` | 列出所有 Probe(含它现在连着哪些 Agent:版本、远端 IP、起始时间);创建 Probe 身份,返回 `probe.json`(含私钥,仅此一次) | +| GET/PATCH/DELETE | `/api/admin/probes/{id}` | 详情、改名、删除(历史数据按保留策略自然过期) | +| POST | `/api/admin/probes/{id}/rotate` | 换新密钥(id 不变):旧密钥立即失效,所有 Agent 断开它的会话;返回新的 `probe.json`(仅此一次) | +| POST | `/api/admin/probes/{id}/revoke` | 吊销:公钥从所有 Agent 的名单里移除,Agent 立即断开它;记录和历史保留 | +| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让这个 Agent 断开这个 Probe 的会话(Probe 会重连,除非已吊销);没有会话时返回 409 `probe_offline` | +| GET/POST | `/api/admin/tasks` | 列表和创建:line 模板(不带 `agent_id`)、单个 Agent 的覆盖(带 `agent_id` + `base_task_id`)、target 任务 | +| GET/PATCH/DELETE | `/api/admin/tasks/{id}` | 参数按 Kind 的 `ParamSpec` 校验,并检查带宽预算 | +| POST | `/api/admin/lines/{line_id}/tests` | `{plan, params?}` → **202** + `Location: /api/public/tests/{id}`;Probe 离线时返回 409 `probe_offline`,节点繁忙时返回 409 `test_busy` | +| POST | `/api/admin/tests/{id}/cancel` | | +| GET | `/api/admin/raw/chunks?agent_id=&kind=&from=&to=&test_id=` | 原始块元数据 | +| GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.monitor.rawchunk`);过期返回 410 `raw_expired` | +| GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | +| GET | `/api/admin/analysis/recompute` | 指标重算进度 | +| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read` 或 `admin`;明文只显示一次 | +| GET | `/api/admin/releases` | 各应用、各通道的当前版本,以及节点的版本分布(由 `Fleet.Server.Releases` 提供) | +| POST | `/api/admin/releases/{app}/{channel}/rollout` | `{version, batches:[{labels?, count?}], soak_minutes}`:分批灰度;只能选择已经签名的清单 | +| POST | `/api/admin/releases/{app}/approve` | 对 `policy = notify` 的节点批准更新 `{version, agents?}` | +| GET/PATCH | `/api/admin/settings` | 站点公开或私有、保留期等 | +| GET/POST/PATCH | `/api/admin/users` | 首次启动时通过命令行或环境变量创建初始管理员 | + +任务的请求体示例: + +```json +{ "scope": "line", "kind": "tcp_echo", "schedule": "continuous", + "params": { "rate_hz": 50, "tcp_info_ms": 20, "port": 7444 } } +``` + +## 4. Agent API + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供(使用加入令牌认证),见 [fleet/link.md](fleet/link.md) 第 3 节 | +| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供,升级为 WebSocket;Monitor 的方法见 [protocol.md](protocol.md) | +| GET | `/fleet/releases/{app}/{channel}/latest.json`、`/fleet/releases/{app}/{version}/…` | 由 `MapFleetReleases` 提供的公开发布源(清单和制品),供 `fleet-updater` 轮询;也可以改用 GitHub Releases | + +## 5. 运维端点 + +| 路径 | 认证 | 说明 | +| --- | --- | --- | +| `GET /healthz` | 无 | SQLite 与原始数据目录都可写 | +| `GET /metrics` | admin 令牌 | Prometheus 格式:在线数、块入库速率、分析队列长度、重算进度、磁盘占用、SSE 连接数 | +| `GET /openapi/v1.json` | 无 | | +| `GET /install.sh` `/install.ps1` | 无 | 安装脚本(只下载二进制并写入配置) | + +## 6. 错误码(节选,完整列表见共享的 `ErrorCode`) + +| HTTP | code | +| --- | --- | +| 400 | `validation_failed`、`budget_exceeded` | +| 401 | `unauthorized`、`csrf_failed` | +| 403 | `forbidden`(令牌作用域不足)、`site_private` | +| 404 | `agent_not_found`、`task_not_found`、`test_not_found`、`chunk_not_found`、`unknown_kind`、`unknown_plan` | +| 409 | `name_conflict`、`probe_offline`、`test_busy`、`agent_not_active` | +| 410 | `raw_expired`、`join_token_expired` | +| 422 | `range_too_large`、`invalid_step`、`unknown_metric`、`unknown_preset`、`too_many_metrics` | +| 429 | `too_many_requests`、`too_many_streams` | +| 503 | `storage_unavailable` | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8ef9763 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,139 @@ +# 架构设计 + +## 1. 目标与定位 + +一个**只关注网络层**的线路质量面板:单体面板(Backend)+ 轻量节点(Probe、Agent)+ 长连接 RPC + 任务下发。核心能力: + +- 在**一个家宽 Probe** 与**多台海外 VPS Agent**(10 几台)之间,采集**最原始的逐包、逐事件数据**; +- 由 Backend 的分析层推算:**如果在某条线路上部署 REALITY 类、Hysteria2 类或 Minecraft 服务,体验会怎样**,并对多条线路排序和对比; +- **主机层信息只上报只读的汇总数字**(2026-10 起,由用户确认放开):Agent 读 `/proc` 得到 CPU 占用、内存、根分区、网卡累计流量与瞬时网速、运行时长,不采集 CPU 型号、主机名、网卡名和任何 IP,不采集进程;Probe 不上报。展示页只能拿到这些数字。Agent 和 Probe 仍然是**纯采集器**,不具备 SSH、远程执行、终端、文件读写、远程更新等能力。 + +## 2. 拓扑 + +```text + ┌────────────────── Backend(面板)───────────────────┐ + 前端(待开发)◀─────────┤ Api(public|admin) │ 注册中心 │ 任务编排 │ 结果管线 │ + REST + SSE 实时 │ Analysis(指标注册表) │ LiveHub │ 存储(段文件+SQLite) │ + └────▲──────▲──────▲───────────────▲─────────────────┘ + wss JSON-RPC │ │ │ │ 每个 Agent 一条长连接(由 Agent 发起) + ┌─────┴┐ ┌───┴──┐ ┌─┴────┐ ┌───┴──┐ + │ HK-1 │ │ JP-1 │ │ SG-1 │ ... │ US-3 │ 10+ 台 VPS Agent(有公网 IP) + └──▲───┘ └──▲───┘ └──▲───┘ └──▲───┘ + │ │ │ │ 每个 Agent 一个会话: + └────────┴───┬────┴─────────────┘ TLS 控制通道 + UDP/TCP 数据通道 + ┌─────┴──────┐ + │ Probe │ 家宽,NAT/CGNAT 后,唯一的测量起点 + └────────────┘ 不认识 Backend,只连 Agent +``` + +- **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Backend 生成,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:` 单独存放,互不混合。对外的线路 id 是 `~`。 +- **目标任务(target)** = 某个 Agent → 指定的 IP:端口(ICMP、TCP/TLS 建连),只能访问 Agent 本地允许的网段。 + +## 3. 数据链路 + +```text + 采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示 +┌───────┐ 原始列(10s) ┌────────┐ RawChunk(10s) ┌──────────────────────────────────────────┐ +│ Probe │───────────>│ Agent │──outbox(16)──>│ 段文件(30天) → Analysis → 指标窗口(1m) │──> REST +│ 积木 │<══════════>│ 积木 │ wss │ ↓ 流式处理未满窗口 ↓ 降采样 5m/1h │──> SSE +└───────┘ 数据通道 └────────┘ │ LiveHub(内存快照) │ + └──────────────────────────────────────────┘ +``` + +| 角色 | 职责 | 明确不做 | +| --- | --- | --- | +| Probe | 连接所有启用的 Agent;执行线路任务中 Probe 一侧的角色;记录本端原始列并交给对应的 Agent | 认识 Backend;统计;落盘测量数据;任何主机操作 | +| Agent | 注册;接受 Probe;执行 Agent 一侧的角色和目标任务;合并两侧的列并上传 | 统计;落盘测量数据;exec/终端/文件/自更新;采集主机信息 | +| Backend | 注册与身份;任务编排;持久化原始数据;分析;实时汇聚;API | 连接 Probe | + +## 4. 关键设计(详见各专题文档) + +| 主题 | 要点 | 文档 | +| --- | --- | --- | +| 节点框架 | 注册、身份、连接、RPC、可靠上报、状态同步、宿主、更新都由与业务无关的 **Fleet** 提供;Monitor 只写业务,其他产品也能复用 | [fleet/overview.md](fleet/overview.md) | +| 注册与身份 | 使用 `fleet-enroll` 和加入令牌自动注册,之后用签名认证;Probe 通过引导 Agent 同步 Agent 列表 | [enrollment.md](enrollment.md) | +| 更新 | 独立的 `fleet-updater` 进程:只接受离线密钥签名的清单,更新策略由本地决定,失败自动回滚 | [fleet/update.md](fleet/update.md) | +| 任务模型 | `Task = {kind, target, params, schedule}`;持续采集、按需测试、目标任务统一建模;下发“期望任务集”,由节点自行调和 | [measurement.md](measurement.md) | +| 采集扩展 | Kind 由积木组合声明;采集密度靠参数调整;原始块自描述 | [measurement.md](measurement.md) | +| 统计扩展 | 指标注册制:少数几种累加器 + 统计算子;一行 `MetricDef` 就是一个新指标 | [analysis.md](analysis.md) | +| 协议 | 控制链路使用 Fleet(JSON-RPC 2.0 + 二进制通道);Monitor 只定义 `monitor.*` 方法、`TaskSet` 状态文档和 UDP/TCP 数据面 | [protocol.md](protocol.md) | +| 存储 | 原始块追加写入段文件,SQLite 记录偏移量;指标窗口通用存储并降采样 | [storage.md](storage.md) | +| 展示查询 | 任意时间段(相对或绝对);自动步长(每条曲线最多约 720 个点);1s 内存热层(1 小时)加上 10s/1m/5m/1h 各层;累加器现场合并,汇总精确;列式响应;SSE 实时跟随 | [query.md](query.md) | +| API | `/api/public`(只读,可设为公开)、`/api/admin`(登录)、`/api/agent`(注册与 RPC);实时数据用 SSE | [api.md](api.md) | + +### 4.1 回显的四个时间戳 + +回显类 Kind 记录 `t1..t4`(Probe 发出、Agent 收到、Agent 回复、Probe 收到)。借此可以扣除 Agent 的处理耗时得到 RTT,可以分别判断上行和下行的丢包,并在去除时钟偏移和漂移后,得到**每个方向各自的抖动**,不需要两端时钟同步。 + +### 4.2 NAT / CGNAT 适配 + +所有连接和 UDP 流都由 Probe 发起;Agent 把回复发往请求的**实际源地址**。会话以 `session_id` 和 MAC 认证,不绑定五元组,NAT 映射变化后自动跟随新地址。持续的 UDP 回显本身就起到保活作用。 + +## 5. 安全边界:纯采集 + +```text +Agent / Probe 能做的 执行编译在二进制中的 Kind;监听和访问本地配置允许的端口与网段;受本地硬上限约束 +不能做的 exec / shell / 终端 / 文件读写(配置目录除外)/ 远程更新 / 加载外部代码 / 采集主机标识(型号、主机名、IP、进程) +即使 Backend 被攻破 攻击者最多能在本地硬上限内发测量流量,拿不到主机权限,也无法把 Agent 当成扫描器或放大器; + 也推不出恶意更新(更新清单必须由离线密钥签名) +``` + +- **本地硬上限**写在节点本机的配置文件中,Backend 下发的任务如果超出,节点直接拒绝:允许访问的目标网段(默认为空,即不开放目标任务)、总码率和包速率上限、允许监听的端口。 +- **构建期强制**:常驻节点进程使用 `BannedSymbols.Node.txt`,禁用 `System.Diagnostics.Process`、`Assembly.Load*`、`NativeLibrary.Load`、`HttpClient`,以及 `ConfigStore` 以外的文件写入 API,详见 [project-layout.md](project-layout.md) 第 7 节。 +- **三进程权限分离**:注册(`fleet-enroll`,一次性运行)、采集(`monitor-agent` / `monitor-probe`)、更新(`fleet-updater`)是三个独立进程,使用不同的系统用户。采集进程无法改写自己的二进制;更新进程读不到节点身份私钥。 +- **最小权限运行**:非 root 用户;只在需要时授予 `CAP_NET_RAW`(ICMP);默认端口都高于 1024,不需要 `CAP_NET_BIND_SERVICE`,只有在 Agent 本地显式开启 443 时才授予。 +- **身份**:节点使用本地生成的 ECDSA P-256 密钥对签名认证,Backend 只存公钥,数据库泄露也无法冒充节点。 +- **防放大**:未认证的包静默丢弃;回复长度与请求相等;每个流按声明速率限速;使用重放窗口。 +- **自身状态**:节点上报进程内的计数(会话数、包速率、队列、丢弃数);Agent 另外每隔几秒用 `monitor.host` 上报一份主机资源占用的汇总数字(可以在 `agent.json` 里用 `reporting.host_stats = false` 整个关掉),最新值实时推给展示页,Backend 每分钟存一个样本(默认保留 7 天,`retention.host_days`)。 + +## 6. 可扩展性目标 + +| 变更 | 需要改动的地方 | +| --- | --- | +| 调整采集密度(例如 TCP 20 Hz → 100 Hz,`tcp_info` 100 ms → 10 ms) | 在 API 中修改任务参数,立即下发生效 | +| 新增采集方法(现有积木可以组合出来) | 在 Kind 目录中加 1 条声明,然后更新 Probe/Agent 版本 | +| 新增采集方法(需要新积木) | 1 个积木类 + 1 条 Kind 声明 | +| 新增统计指标 | 1 行 `MetricDef`(如需新算子,再加 1 个算子类);自动回填 30 天历史 | +| 调整服务评分 | 修改模型的评分函数;评分在查询时计算,立即对全部历史生效 | + +Kind 目录编译在二进制中,节点不接受远程下发代码(安全边界)。节点在 `fleet.hello` 的 capabilities 中上报自己支持的 Kind,Backend 只下发它支持的类型。 + +## 7. 可靠性与资源 + +- Probe:每个 Agent 未交付的原始列 ≤ 8 MB;Agent:上传队列 ≤ 64 MB,满时丢弃最旧的块,并通过 `monitor.event(gap)` 上报缺口。 +- 至少一次投递:Backend 持久化之后才回复 ACK,按 `chunk_id` 去重。 +- 节点重启会丢失未上传的数据,并体现为数据缺口。 +- 窗口时间以 Agent 时钟为准;时钟偏差超过 5 秒时记录告警事件。 +- 规模:10+ 个 Agent × 2~4 种持续 Kind × 每 10 秒一块,总计约每秒 2~6 块。 + +## 8. 设计决策记录 + +| # | 决策 | 理由 | +| --- | --- | --- | +| D1 | 只关注网络层;节点是纯采集器,没有远程执行能力 | 由用户确认;防止权限泄露 | +| D2 | 拓扑为多个 Probe 连接多个 Agent(10+);线路 = (Probe, Agent),数据按 Probe 隔离 | 由用户确认 | +| D3 | Agent 用加入令牌自动注册,之后用密钥对签名认证 | 批量部署省事;Backend 不保存可用于冒充节点的秘密 | +| D4 | Probe 不认识 Backend,通过引导 Agent 同步 Agent 列表 | 新增 Agent 后自动覆盖,不需要改 Probe 配置 | +| D5 | 只采集逐包、逐事件的原始数据,解释全部放在 Backend | 模型和指标可以事后重算;采集端最轻 | +| D6 | 统一的任务模型 + 期望任务集调和 | 新增任务类型时协议不变 | +| D7 | Kind 由积木组合声明;原始块自描述 | 调整密度或新增采集方法只改一处 | +| D8 | 指标注册制,使用固定的少数几种累加器 | 新增统计只改一行;存储、降采样、API 全部通用 | +| D9 | JSON-RPC 2.0 信封 + 二进制帧 | 新增能力只需要加方法 | +| D10 | 原始数据每 10 秒切一块,追加写入段文件,保留 30 天;指标长期保留 | 同一条链路同时服务实时和历史;避免海量小文件 | +| D11 | API 分为 public / admin / agent 三层;实时数据用 SSE | 前端只读场景足够;反向代理和 CDN 更友好 | +| D12 | 单仓库,三个独立项目加待开发的 Web;契约以 C# 代码为真源 | 改一处,编译期即可发现不一致 | +| D13 | Probe/Agent 运行时只依赖 BCL;.NET 10 NativeAOT | 轻量 | +| D14 | 节点机制抽成与业务无关的 Fleet 框架,放在同一仓库的 `backend/fleet/` 目录,暂不独立(由用户确认;不设拆分时间表,需要时再评估;不使用 PublicApiAnalyzers) | 可复用;`samples/EchoNode` 作为第二个使用者,防止过度设计 | +| D15 | 注册独立为 `fleet-enroll` 工具;常驻进程不包含注册代码 | 常驻进程去掉 HttpClient,边界更纯 | +| D16 | 更新独立为 `fleet-updater`:离线签名、多发布源、本地策略、健康确认与回滚(默认方案,待确认) | 面板被攻破时也无法推送恶意二进制;可以同时管理多个产品 | +| D17 | Probe 支持 OpenWrt:正式支持 `linux-musl-x64`、`linux-musl-arm64`;armv7 视工具链验证结果决定;MIPS 不支持,改为在局域网设备上运行 Probe | 由用户确认;NativeAOT 不支持 MIPS | +| D18 | 默认测量端口不使用 443:控制 7443/tcp,UDP 数据 7443/udp,TCP 数据 7444/tcp;443 只在 Agent 本地显式开启时才允许使用 | 由用户确认;避免与 VPS 上的真实服务冲突;结果会记录实际端口 | +| D19 | 展示查询最细 1s:最近 1 小时来自内存热层,更早的数据来自 10s/1m/5m/1h 各层,现场合并;时间段内的汇总由累加器精确合并得出 | 由用户确认;5 分钟、15 分钟这类视图足够精细且快 | +| D20 | 时序响应采用列式格式(`t[]` + `values{}`,时间轴使用 Unix 毫秒) | 由用户确认;体积小,可以直接交给图表库 | + +## 9. 仍待确认 + +以下两项是按推荐方案写入的默认值(D16),等待确认:Fleet.Link 的通用范围(当前包含注册、认证、RPC、发件箱、状态同步、点对点会话);更新采用多发布源加离线签名。其余待确认事项: + +1. **告警**:首版不包含,是否需要预留 Webhook? +2. **与真实服务共用 443**(例如 SNI 分流):首版不做,默认使用非 443 端口;以后如有需要再评估。 diff --git a/docs/coding-style.md b/docs/coding-style.md new file mode 100644 index 0000000..d3a95c2 --- /dev/null +++ b/docs/coding-style.md @@ -0,0 +1,111 @@ +# C# 代码风格 + +基线是 [dotnet/runtime 编码风格](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md),再加上本项目的补充规则。所有能自动检查的规则都写在 `.editorconfig` 或 `BannedSymbols.txt` 里,**违反即编译失败**(`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`);无法自动检查的规则由代码评审把关。 + +## 1. 格式与命名(`.editorconfig` 强制) + +- 使用 Allman 大括号(左大括号独占一行),四空格缩进,不使用 Tab;`if`/`for` 等语句必须加大括号。 +- 使用文件作用域 namespace;namespace 与目录一致(`Monitor.Agent.Sessions` 对应 `Sessions/`)。 +- `using` 放在文件顶部、namespace 之外,`System.*` 排在最前面,其余按字母排序。 +- 始终显式写出可见性,并且放在修饰符的第一位;`static readonly` 的顺序固定。 + +| 元素 | 规则 | 示例 | +| --- | --- | --- | +| 类型、方法、属性、常量、局部函数 | PascalCase | `SessionTable`、`MaxFrameBytes` | +| 私有和内部实例字段 | `_camelCase` | `_sessions` | +| 静态字段 | `s_camelCase` | `s_idleTimeout` | +| 线程静态字段 | `t_camelCase` | `t_buffer` | +| 局部变量、参数、主构造函数参数 | camelCase | `windowSeq` | +| 接口 | `I` 前缀 | `IMeasurement` | +| 异步方法 | `Async` 后缀 | `SendReportAsync` | + +- `var` 只在右侧类型显而易见时使用(`new`、强制转换);类型名使用语言关键字(`int`,而不是 `Int32`)。 +- 用 `nameof(...)` 代替字符串字面量;不写多余的 `this.`。 +- 字段放在类型的最前面;一个文件只放一个主要类型,文件名与类型名相同。 +- 内部类和私有类默认使用 `sealed`(或 `static`),除非确实需要被继承。 + +## 2. 命名语义 + +- **名称里带单位**:`timeoutMs`、`rttUs`、`payloadBytes`。API 内部统一用 `TimeSpan`,只在边界(线协议、配置、JSON)使用带单位后缀的整数。 +- 不使用 `Manager`、`Helper`、`Util`、`Service`(ASP.NET 约定位置除外)之类的模糊名称,名称要说明职责:`UploadQueue`、`SessionTable`、`ResultWriter`。 +- 布尔值使用 `is`/`has`/`can` 前缀;返回“成功与否加输出值”的方法使用 `TryXxx(out ...)`。 + +## 3. 类型与数据 + +- 契约和领域值使用 `sealed record` 或 `readonly record struct`,并且不可变。 +- 热路径上的计数器和缓冲区使用可变 `struct` 或 `sealed class`,**所有权要写清楚**(谁创建、谁归还池)。 +- ID 使用强类型(`readonly record struct ProbeId(string Value)`),不直接传裸 `string`。 +- 枚举序列化成 snake_case 字符串(`JsonStringEnumConverter` + `JsonStringEnumMemberName`);新增枚举值不属于破坏性变更,但所有消费方都必须能处理未知值。 +- 可空性:公共成员必须标注;使用 `!` 抑制警告时,同一行要写注释说明理由。 + +## 4. 错误处理 + +- **预期内的失败不抛异常**:超时、拒绝连接、认证失败、校验失败都返回结构化结果,其中包含 `ErrorCode`。 +- 异常只用于程序缺陷或无法继续运行的状态;只在边界(会话循环、请求管线、进程入口)捕获,并记录日志。 +- 禁止空 `catch`;捕获 `OperationCanceledException` 时必须确认它确实来自自己的取消令牌。 + +## 5. 异步与并发 + +- 所有 I/O 都是异步的,并且接受、传递 `CancellationToken`(作为最后一个参数)。 +- 禁止使用 `.Result`、`.Wait()`、`Thread.Sleep`、`async void`(事件处理函数除外),`BannedSymbols.txt` 强制执行。 +- 库代码中的 `await` 不需要 `ConfigureAwait(false)`,因为三个程序都没有同步上下文,统一省略即可。 +- 生产者和消费者之间使用有界 `Channel`,明确指定 `BoundedChannelFullMode`。 +- 共享可变状态要么只由单个循环拥有(推荐),要么使用 `lock` 保护;不要混用多种方式。 + +## 6. 时间 + +- 时间来源一律通过 `TimeProvider` 获得:墙钟使用 `GetUtcNow()`,耗时使用 `GetTimestamp()`/`GetElapsedTime()`。 +- `DateTime.Now`、`DateTime.UtcNow`、`DateTimeOffset.Now`、`Stopwatch.StartNew` 列入禁用名单。测试使用 `FakeTimeProvider` 驱动窗口和超时(这个包只出现在测试项目中)。 + +## 7. NativeAOT 与轻量化 + +- 序列化只使用 `System.Text.Json` 源生成上下文(或手写的二进制编解码);禁用无上下文的 `JsonSerializer.Serialize(T)` 重载。 +- 不做程序集扫描、`Activator.CreateInstance`、`Type.GetType(string)`、动态代理或 `Reflection.Emit`。 +- **Probe/Agent**:不使用 DI 容器,在 `Program.cs` 中手工组装对象,依赖关系一眼可见;不使用 LINQ;不使用 `HttpClient`(Agent 的 WebSocket 使用 `ClientWebSocket`)。 +- **Backend**:使用 `CreateSlimBuilder` + Minimal API;只能通过 `Microsoft.Data.Sqlite` 执行手写的参数化 SQL,禁止字符串拼接 SQL。 +- 热路径:使用 `Span`、`stackalloc`(≤ 256 B)、`ArrayPool.Shared`;每个包的处理过程中不分配内存、不写日志。 +- 新增任何第三方包之前,需要先更新 [project-layout.md](project-layout.md) 第 3 节的依赖表,并在 PR 中说明理由。 + +## 8. 日志 + +- 统一使用 logfmt 字段名:`ts`、`level`、`msg`、`agent`、`probe`、`session`、`task`、`result`、`err`。 +- Backend 使用 `Microsoft.Extensions.Logging` 源生成的 `[LoggerMessage]`;Probe/Agent 使用 `NodeLog`,两者输出的字段名相同。 +- 令牌、`session_key` 和 MAC 不能出现在日志里;IP 地址可以记录。 +- 每个会话或连接的生命周期事件记录为 Info;逐包事件不记录,只累加计数。 + +## 9. 纯采集边界(Probe/Agent 与 Fleet 节点侧) + +- 由 `BannedSymbols.Node.txt` 在编译期强制:禁止启动进程、动态加载程序集或本机库、使用 `HttpClient`、在配置目录以外写文件。唯一的豁免点是 `Fleet.Hosting.ConfigStore`,豁免必须写 `#pragma` 并附上理由注释。注册和更新分别在 `fleet-enroll` 和 `fleet-updater` 中完成,常驻进程不需要这些能力。 +- 节点不能新增任何“由远端触发的非采集动作”对应的 RPC 方法。凡是新增 RPC 方法的 PR,都要在描述中说明它不会扩大权限边界。 +- Backend 下发的任何参数都要先经过 `ParamSpec` 校验,再经过本地硬上限(`BudgetGuard`)检查,**两道校验都不能省略**。 +- 日志中不能出现私钥、签名、`session_key`、令牌或 MAC。 + +## 9.1 Fleet 的通用性 + +- `backend/fleet/**` 不能引用任何 `Monitor.*`,也不能出现产品相关的概念、命名或默认值(例如端口 7443、`monitor` 字样),由 `scripts/check-deps` 和代码评审把关。 +- Fleet 的每个公开 API 都要在 `samples/EchoNode` 或 `EchoServer` 中有用法。新增的公开 API 如果只有 Monitor 使用,应先放在 Monitor 中,等出现第二个使用者后再下沉到 Fleet。 +- Fleet 永远不提供 exec、shell、文件读写或远程下发代码的方法,这条规则不接受例外。 +- 公开 API 的变更记录在 `PublicAPI.Unshipped.txt` 中;PR 描述需要说明是否属于破坏性变更。 + +## 10. 声明式扩展的写法 + +- **Kind**:只放在 `Protocol/Catalog/Kinds`,一个 Kind 一个静态字段;参数一律使用 `P.Int`、`P.Enum` 等 `ParamSpec` 写出默认值和上下限,禁止写没有上下限的参数。 +- **积木**:实现对应的积木接口,在 `BlockRegistry` 中注册一行;积木内部不能读取全局配置,所有输入都来自参数。 +- **指标**:只放在 `Analysis/Metrics`,一行一个 `MetricDef`;指标 id 使用 `领域.名称`(如 `tcp.stall`),导出值为 `id.统计量`(如 `rtt.p95`)。已发布的 id 不能改名,只能新增。 +- **算子**:纯函数,输入列视图,输出累加器更新;每个算子都要有边界用例测试(空输入、单个样本、乱序、时钟回绕)。 +- **模型**:只读取指标,不读取原始列;评分函数需要有单调性测试(指标变差时,分数不能升高)。 + +## 11. 测试 + +- 使用 xUnit v3。测试项目与被测项目一一对应,放在同名目录下的 `tests/` 中。 +- 测试名采用 `方法_场景_期望` 格式,例如 `Merge_TwoHistograms_PreservesP95`。 +- 协议编解码必须有“黄金字节”测试:固定的输入对应固定的字节序列。布局发生变化时测试会失败,提醒升级协议版本。 +- 采集端必须覆盖:逐包记录的完整性(发送数 = 记录数)、丢失标记、重复、乱序、非法长度、重放、会话过期、版本不兼容。 +- 分析模型必须有“黄金原始块 → 期望输出”的测试;模型升版时,同时更新期望输出并说明原因。 +- e2e 测试在 Windows 和 Linux 上对 **AOT 发布产物**运行,而不是对 JIT 构建运行。 + +## 12. 提交与评审 + +- 提交信息使用 `<范围>: <摘要>` 格式,范围取值为 `probe`、`agent`、`backend`、`shared`、`web`、`docs`、`scripts`。 +- 修改 `backend/shared/Monitor.Protocol` 的 PR 必须说明兼容性影响(新增字段、行为变化、需要升级版本)。 +- 修改 `backend/openapi/v1.json` 的 PR 必须按照 [api-guidelines.md](api-guidelines.md) 第 2 节判断是否属于破坏性变更。 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..69ec9d1 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,135 @@ +# 部署 + +节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(`libssl.so.3`)。启动时会自检,缺少时第一步就明确报错。 + +## 1. 构建与打包 + +```text +scripts/publish.sh → backend/artifacts/publish/// +scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) +``` + +| rid | 目标 | 构建方式 | +| --- | --- | --- | +| `linux-musl-x64` | Alpine、OpenWrt x86 | Alpine 的 NativeAOT SDK 容器(任何有 Docker 的主机,包括 Windows) | +| `linux-x64` | Debian、Ubuntu 等 glibc 发行版 | Ubuntu noble 的 NativeAOT SDK 容器 | +| `win-x64` | Windows | 在 Windows 上直接 `dotnet publish -p:PublishAot=true`(需要 VS 的 C++ 工具链,`vswhere.exe` 所在目录要在 PATH 里) | +| `linux-musl-arm64`、`linux-arm64` | 路由器、树莓派 | 需要在 arm64 主机(或 CI 的 arm64 runner)上运行同一个脚本;脚本在 x64 主机上会拒绝并说明 | + +NativeAOT 不能交叉编译到别的操作系统;MIPS 不支持(见 `project-layout.md`)。 + +实测体积(`StackTraceSupport=false`、`IlcFoldIdenticalMethodBodies=true`、`OptimizationPreference=Size`): + +| 程序 | linux-musl-x64 | linux-x64 | win-x64 | +| --- | --- | --- | --- | +| monitor-probe | 7.7 MB | 7.7 MB | 5.6 MB | +| monitor-agent | 8.4 MB | 8.4 MB | 7.3 MB | +| fleet-enroll | 6.4 MB | 6.4 MB | — | + +Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。tar.gz 压缩后 Probe 约 3.6 MB。 + +## 2. 安装 + +解压 tar.gz,在解压目录里运行 `install.sh`(POSIX sh,root 运行;Debian、Ubuntu、Alpine、OpenWrt 都测试过): + +```sh +# Agent(服务器):注册并启动 +./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1 + +# Probe(家宽网络里的机器或 OpenWrt 路由器):先在面板生成身份(POST /api/admin/probes,只能下载一次) +./install.sh probe --probe-json ./probe.json + +./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册) +``` + +| 位置 | 内容 | 所有者 | +| --- | --- | --- | +| `/usr/lib/monitor/` | 二进制 | root,0755:采集进程不能改写自己的可执行文件 | +| `/etc/monitor/` | `agent.json` 或 `probe.json`、身份(`node.json`、`node.key`、`tls.pfx`)、`agents.json` | `monitor` 用户,0750 | + +安装脚本会:检查 OpenSSL、创建 `monitor` 用户(`useradd`、busybox `adduser`,OpenWrt 没有这些时直接写 `/etc/passwd`)、安装二进制和服务文件;缺少配置时只安装、不启动,并打印下一步。 + +### systemd(`packaging/systemd/*.service`) + +以 `monitor` 用户运行,沙箱:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、`PrivateTmp/Devices`、`ProtectKernel*`、`RestrictAddressFamilies=AF_INET AF_INET6`、`RestrictNamespaces`、`MemoryDenyWriteExecute`(NativeAOT 不 JIT)、`SystemCallArchitectures=native`,`MemoryMax`(Agent 128 MB、Probe 64 MB)。 + +- **Agent** 只保留 `CAP_NET_RAW`(ICMP 目标任务用),`/etc/monitor` 只读;Probe 没有任何 capability,只对 `agents.json` 所在的目录可写。 +- 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 和对应的 `CapabilityBoundingSet`。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。 + +### OpenWrt(`packaging/openwrt/`) + +只支持 Probe(Agent 面向服务器)。`install.sh probe` 安装 `/etc/init.d/monitor-probe`(procd,崩溃自动重启,日志进 logd)并写入 `/lib/upgrade/keep.d/monitor`,sysupgrade 时保留 `/etc/monitor/`。 + +OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 24.10 之后用 `apk add libopenssl3`)。闪存很小时注意:二进制 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 `probe.json` 的 `limits.max_test_mbps` 调低。 + +## 3. 验证 + +`scripts/smoke-linux.sh` 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),`MODE=install` 用打包产物和 `install.sh` 安装、以 `monitor` 用户运行。`scripts/smoke-systemd.sh` 在 systemd 容器里验证服务单元。已经验证过: + +- Alpine 3.22(musl)、Debian 12(glibc):直接运行和 `install.sh` 安装都通; +- OpenWrt(官方 rootfs,busybox,无 `install`/`useradd`/`su`):安装、建用户、卸载、二进制启动和 OpenSSL 自检; +- systemd(Debian 12):两个服务 active,Agent 只有 `CAP_NET_RAW`、Probe 没有 capability,`NoNewPrivs`、seccomp 生效,Agent 无法写 `/etc/monitor`,没有重启和错误日志。 + +没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。 + +实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。 + +### 跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend) + +`scripts/smoke-wsl.py` 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 `tc netem` 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms): + +| 注入 | 期望 | 实测 | +| --- | --- | --- | +| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | `loss.down` 8.2%、`loss.up` 0;`jitter.down.p95` 8.7 ms、`jitter.up.p95` 0.18 ms;RTT +31 ms;TCP:`tcp.rtt.p99` 1.2 s、`tcp.slow` 28.8%、`tcp.retrans` 5.9%(Agent 一侧内核计数) | +| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | `loss.up` 9.4%、`loss.down` 0;`jitter.up.p95` 5.2 ms、`jitter.down.p95` 0 | +| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);`tcp.rtt.max` 9.5 s、`tcp.stall_max` 9.6 s | +| `hy2_ladder`(10/25/50/100 Mbit/s) | 无丢包 | `train.loss` 0.006%、`train.jitter.p95` 0.64 ms、可持续 100 Mbit/s | +| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 | + +验证中发现并修掉的两个问题: + +- **Probe 重启后,Agent 沿用了上一个会话的记录**:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里 `loss.up` 0、`loss.down` 7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。 +- **TCP 回显的几秒中断被记成丢包**:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间 `tcp.rtt.max` 只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。 + +没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。 + +### 跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS) + +2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。 + +```text +浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181) + 家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent + VPS: Agent ──WSS 到 https://<公网域名>──► Backend +``` + +做法和要点: + +- **Backend 通过隧道暴露**:隧道把一个公网域名转到家宽机器的 `29181`;Backend 监听 `http://0.0.0.0:29181`(只写一个监听地址,同时写 `0.0.0.0` 和 `[::]` 会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用 `fleet-enroll --server https://<公网域名>` 注册,之后用 WSS 连回 Backend。 +- **经隧道注册之后,检查 Agent 的 `public_host`**:Backend 看到的来源地址是隧道的出口,不是 VPS 的地址,要改成 VPS 的真实地址,Probe 才连得上。 +- **先注册 Agent,再创建 Probe**:`probe.json` 的 `bootstrap` 里才有这个 Agent;Probe 启动后会通过 Agent 同步列表。 +- **小 VPS 把 `limits.max_total_mbps` 调低**(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除 `/opt/…`、停掉进程即可,没有留下任何东西。 +- 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。 +- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force`,密码放环境变量,不写进文件)。 + +**常驻(开机自启)**: + +- 家宽机器(没有 root 时):`~/.config/systemd/user/monitor-backend.service` 和 `monitor-probe.service`,`EnvironmentFile` 指向放配置的 `backend.env`,`Restart=always`,`systemctl --user enable --now …`,再用 `sudo loginctl enable-linger <用户>` 让它们不登录也能起来。Probe 的命令行是 `monitor-probe --config `(默认路径是 `/etc/monitor/probe.json`)。 +- VPS:`/etc/systemd/system/monitor-agent.service`,`Restart=always`,加 `NoNewPrivileges`、`ProtectSystem=strict`、`ReadWritePaths=/opt/monitor`、`ProtectHome`、`PrivateTmp`。这是不走安装脚本的手动方式;正式安装用上面的 `packaging/systemd`。 +- 日志进 journal:`journalctl -u monitor-agent`、`journalctl --user -u monitor-probe`。重启 Backend 用 `systemctl` 而不是手动再起一个进程,否则会端口冲突。 + +**从别处看这个面板**:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 `Monitor__CorsOrigins` 放行前端的来源(见 [web/README.md](../web/README.md))。 + +实测(Probe 在广东移动家宽,Agent 在日本 IIJ): + +| 项目 | 结果 | +| --- | --- | +| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms | +| 丢包 | 上行 0%,下行 8.1%(`hy2_ladder` 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) | +| 抖动 p95(上 / 下) | 0.9 / 5.2 ms | +| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms | +| `reality_session` | 建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) | +| 评分 | REALITY 82(含按需测试)、Hy2 42 | +| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 | + +这次试跑发现并修掉的问题:`hy2_ladder` 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。 diff --git a/docs/enrollment.md b/docs/enrollment.md new file mode 100644 index 0000000..eeb4b72 --- /dev/null +++ b/docs/enrollment.md @@ -0,0 +1,104 @@ +# Monitor 的注册与身份 + +通用的注册、身份、认证机制由 Fleet 提供,见 [fleet/link.md](fleet/link.md) 第 2~3 节。本文只描述 **Monitor 在 Fleet 之上的约定**:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。 + +## 1. 身份一览 + +| 主体 | 凭据 | 生成方 | Backend 保存 | +| --- | --- | --- | --- | +| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 | +| Probe | Fleet 节点身份 | 面板生成 `probe.json`(可以有多个 Probe,每个独立吊销) | 公钥 | +| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 | +| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 | +| 发布签名 | 离线的 ECDSA 发布密钥 | CI 或离线环境 | 只在节点的 `fleet-updater` 中配置公钥,**面板不保存** | + +## 2. Agent 接入流程 + +```text +管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve} ──> 面板(Fleet.Server.Enrollment) + <── 安装命令: + curl -fsSL https://panel/install.sh | sh -s -- --join jt_xxx --name jp-1 + +install.sh(只做安装,不包含任何远程控制功能) + ├─ 下载 monitor-agent、fleet-enroll、fleet-updater,并验证发布签名 + ├─ 创建系统用户:monitor(运行 Agent)、fleet-update(运行更新器) + ├─ fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor + │ └─ 注册元数据(Monitor 定义):{ probe_tcp_port, probe_udp_ports, public_host?, kinds[] } + ├─ 写入 agent.json(本地硬上限)和 /etc/fleet/updater.json + └─ 启用 monitor-agent.service 和 fleet-updater.service + +面板收到 OnEnrolled(Monitor 挂钩) + ├─ public_host 未提供时,取注册请求的来源 IP + ├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet) + └─ 通知 Probe(通过 monitor.probe.agents_changed)→ Probe 自动连接新 Agent + 从安装到开始出数据,全程不需要手工配置 +``` + +名称冲突、审批、接管、禁用等行为都采用 Fleet 的默认规则。 + +## 3. Probe 身份与 Agent 列表同步 + +Probe 不认识 Backend,它需要的 Agent 列表经由任意一个已连接的 Agent 获取。 + +```text +面板:POST /api/admin/probes {name} + └─ 生成 Fleet 节点身份,返回 probe.json(只能下载一次;轮换密钥 /rotate 会使旧密钥失效,吊销 /revoke 会让所有 Agent 断开它) + { probe_id, private_key, bootstrap: [ {agent_id, host, tcp_port, spki}, ... ], + limits: { max_test_mbps, max_total_pps } } ← 本地硬上限,面板无法修改;在路由器上建议调低 + +Probe 启动 + ├─ 依次尝试 bootstrap 中的 Agent,与其中一个建立 Fleet 点对点会话(peer 用途签名) + ├─ monitor.probe.agents ──> Agent ──> Backend + │ <── { version, agents: [ {agent_id, host, tcp_port, udp_ports, spki}... ] } + ├─ 调和:新增的 Agent → 建立会话;被移除或禁用的 → 断开 + └─ 之后列表的任何变化,都通过已连接的 Agent 推送 monitor.probe.agents_changed; + Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 bootstrap(这是配置,不是测量数据) +``` + +- 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。 +- 吊销 Probe:在面板上重新生成身份,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开。 + +## 4. Agent 本地配置(`/etc/monitor/agent.json`,面板无法修改) + +```json +{ + "fleet": { "server": "https://panel.example.com", "identity_dir": "/etc/monitor" }, + "listen": { "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] }, + "limits": { + "max_total_mbps": 200, + "max_total_pps": 20000, + "allowed_target_cidrs": [], + "max_concurrent_tests": 1 + }, + "fleet_policy": { "allow_methods": ["monitor.test.run", "monitor.test.cancel", "monitor.probe.agents_changed"] } +} +``` + +- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。 +- `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。 +- `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `udp_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](measurement.md) 第 8 节。 +- `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。 + +## 5. 相关 API + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供 | +| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) | +| GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 | +| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 | +| GET/POST/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 身份的创建、列表、改名、删除 | +| POST | `/api/admin/probes/{id}/rotate`、`/revoke` | 轮换密钥、吊销 | +| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe | +| GET | `/install.sh`、`/install.ps1` | 安装脚本 | + + +## Agent 的本地配置:主机状态上报 + +`agent.json` 的 `reporting` 段(面板无法修改): + +```json +{ "reporting": { "host_stats": true, "host_interval_s": 2 } } +``` + +`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `monitor.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。 diff --git a/docs/fleet/link.md b/docs/fleet/link.md new file mode 100644 index 0000000..6238f40 --- /dev/null +++ b/docs/fleet/link.md @@ -0,0 +1,137 @@ +# Fleet:身份、注册与连接 + +## 1. 概览 + +```text + fleet-enroll(一次性运行) + │ ① 用加入令牌注册,生成身份 + ▼ +┌────────── 节点 ──────────┐ ┌──────────── 服务端 ────────────┐ +│ Fleet.Hosting │ ② wss 连接 │ Fleet.Server.Link │ +│ └─ Fleet.Link ─────────────── 签名认证 ────>│ 连接注册表 · 在线事件 │ +│ ├─ Rpc 路由 │<── state.sync ──│ 期望状态提供者(产品实现) │ +│ ├─ 期望状态调和 │── outbox.push ─>│ 收件箱(产品实现,持久化后 ACK) │ +│ └─ 可靠发件箱 │<── outbox.ack ──│ │ +│ │ │ Fleet.Server.Enrollment │ +│ Fleet.Link(点对点)──────── TLS + 签名 ────> 另一个节点(例如 Probe → Agent) │ +└──────────────────────────┘ └────────────────────────────────┘ +``` + +## 2. 身份(Fleet.Identity) + +| 对象 | 算法 | 保存位置 | +| --- | --- | --- | +| 节点身份密钥 | ECDSA P-256(BCL 自带;Ed25519 不在 BCL 中) | `/node.key`(0600);服务端只保存公钥 | +| 点对点 TLS 证书 | 自签名,ECDSA P-256 | `/tls.pfx`;对端按 SPKI SHA-256 指纹校验 | +| 签名串 | `"fleet|v1||" + 各字段` | 带用途前缀,防止同一个签名在不同场景下被复用 | + +用途前缀的取值:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途(例如 `myapp.xxx`),但不能占用 `fleet` 保留的用途。 + +## 3. 注册(Fleet.Enrollment) + +```text +管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve, product_meta?} ──> 服务端 + <── 令牌明文(只返回一次)+ 安装命令 + +节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp + ├─ 生成身份密钥和 TLS 证书 + ├─ POST + │ { join_token, name, pubkey, tls_spki, proof = 签名("enroll", join_token 哈希 + pubkey), + │ node_version, meta(产品自定义的 JSON,经产品的 JsonTypeInfo 校验)} + │ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发 OnEnrolled 事件(产品挂钩) + └─ 写入 /node.json { node_id, server, … },然后退出 +``` + +- **名称冲突**:同名节点已存在时,新注册进入 `pending`,由管理员选择“接管”(旧记录换成新公钥,历史数据保留)或“作为新节点”。 +- **节点状态**:`pending`(能连接,但不下发状态、不接收上报)、`active`、`disabled`(拒绝连接)。 +- 注册逻辑只存在于 `fleet-enroll` 中,**常驻的业务进程不包含注册代码,也不使用 HttpClient**。 +- 产品可以复用 `fleet-enroll` 本身,也可以把 `Fleet.Enrollment.Client` 嵌入自己的安装工具中。 + +## 4. RPC(Fleet.Rpc) + +- 信封采用 JSON-RPC 2.0;方法名为 `领域.动作`。`fleet.*` 保留给框架,产品使用自己的前缀,例如 `monitor.*`。 +- 二进制帧:`u8 channel | 正文`。channel `0` 到 `15` 保留给框架,产品从 `16` 开始注册自己的二进制通道。 +- 错误:`error.data.code` 为 snake_case 字符串,框架的错误码以 `fleet_` 开头,产品可以扩展。 +- **与传输方式无关**:同一套路由既可以跑在 wss 上,也可以跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。 + +框架保留的方法: + +| 方法 | 方向 | 说明 | +| --- | --- | --- | +| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) | +| `fleet.hello` | 节点→服务端 | 节点版本、能力列表(框架 + 产品)、本地策略摘要、当前时间 → 返回时钟偏差 | +| `fleet.heartbeat` | 节点→服务端 | 每 10 s 一次;产品可以附加进程级计数 | +| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` | +| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` | +| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 | + +## 5. 连接生命周期(Fleet.Link) + +```text +connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行 + ▲ │ + └──── 指数退避重连(1 s → 60 s,带随机抖动;401 或 revoked 时停止重试)◀─ 断开 +``` + +- 能力协商:双方交换 capabilities;调用对方不支持的方法时,在本地直接返回 `fleet_unsupported`,不会发出请求。 +- 节点本地策略:`allow_methods`(白名单,默认只包含本地已注册的方法)、`max_frame_bytes`、`max_inflight`。 + +## 6. 可靠发件箱(Outbox) + +```text +产品 → outbox.EnqueueAsync(msg) → 有界队列(按字节和条数限制;满时按策略:DropOldest 或 Reject) + → 发送(二进制通道或 JSON)→ 服务端收件箱处理并持久化 → fleet.outbox.ack → 从队列移除 +``` + +- 投递语义为**至少一次**:每条消息都带有 `msg_id`(UUIDv7),服务端按此去重。 +- 在途数据按字节限制(默认 4 MiB),保证服务端慢时不会把内存撑爆。 +- 丢弃消息时会累计缺口计数,通过 `OnGap` 回调通知产品,由产品决定如何上报。 +- 默认只保存在内存中,不落盘。产品如果需要持久化,可以自行实现 `IOutboxStore`(Monitor 不使用)。 + +## 7. 期望状态同步(State Sync) + +```text +服务端:IStateProvider.GetAsync(node) → {version, doc} + 发生变化时调用 NotifyChanged(node) → 推送 fleet.state.sync +节点: IStateReconciler.ApplyAsync(doc) → {applied_version, rejected[]} + 重连后,服务端总会重新推送当前版本 +``` + +- 下发的是**完整快照**而不是命令,所以天然幂等,断线重连后也能自动收敛。 +- `rejected[]` 为结构化原因,例如 `unsupported`、`budget_exceeded`、`policy_denied`,服务端可以把它展示在面板上。 + +## 8. 节点间点对点会话 + +```text +节点 A(发起方,例如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,例如 Agent) + <── fleet.challenge ── + ── fleet.auth(peer 用途,节点 A 的私钥签名)──> + 节点 B 用服务端下发的对端公钥列表验签(通常经 state.sync 下发) + 之后使用同一套 Fleet.Rpc:方法路由、二进制通道、发件箱都可以复用 +``` + +- 对端的公钥和允许连接的节点列表,由产品通过期望状态下发给监听方。Fleet 只负责认证和会话,拓扑由产品决定。 +- 数据面的 UDP、原始 TCP 流等通道由产品自己实现。Fleet 只提供会话密钥派生(`session_key`)和首帧 MAC 的辅助函数。 + +## 9. 宿主(Fleet.Hosting) + +- `ModuleHost`:按依赖顺序启动模块,按相反顺序停止;隔离故障,重启时退避。 +- `NodeConfig`:源生成 JSON 读取,支持环境变量覆盖,启动时校验。 +- `ConfigStore`:业务进程唯一允许写文件的地方,只能写配置目录。 +- `ServiceShell`:处理 SIGTERM 和 Ctrl+C,支持 systemd `sd_notify`、OpenWrt procd(前台运行,由 procd 负责重启)和 Windows 服务。 +- `HealthBeacon`:进程完成启动并与服务端握手后,写出 `{app, version, pid, ok_at}` 到本地信标文件或命名管道,供 `fleet-updater` 确认新版本是否健康。 + +## 10. 服务端存储抽象 + +```csharp +public interface IFleetStore +{ + ValueTask FindJoinTokenAsync(ReadOnlyMemory tokenHash, CancellationToken ct); + ValueTask> CreateNodeAsync(NewNode node, CancellationToken ct); + ValueTask GetNodeAsync(NodeId id, CancellationToken ct); + ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct); + // 令牌、审批、接管、禁用等 +} +``` + +产品可以使用 `Fleet.Server.Sqlite`,也可以把这些表放进自己的数据库中。Monitor 的做法是把它们放进自己的 `monitor.db`,并由同一个单写入者负责写入。 diff --git a/docs/fleet/overview.md b/docs/fleet/overview.md new file mode 100644 index 0000000..04c774e --- /dev/null +++ b/docs/fleet/overview.md @@ -0,0 +1,104 @@ +# Fleet:通用节点框架 + +> 名称暂定。Fleet 与业务无关,Monitor 是它的第一个使用者。 + +## 1. 定位 + +凡是“很多节点连到一个面板”的产品,都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置、怎么更新自己。Fleet 把这些做成**通用库和工具**,产品只需要写业务。 + +```text +┌──────────────────────────── 产品层(各产品自己写) ─────────────────────────────┐ +│ Monitor: Protocol(Kind/任务) · Measurement(积木) · Agent · Probe · Backend │ +│ 其他产品: YourApp.Node · YourApp.Server │ +└──────────────────────────────────┬─────────────────────────────────────────────┘ + │ 只依赖 Fleet;Fleet 不认识任何产品 +┌───────────────────────────────── Fleet ───────────────────────────────────────┐ +│ 节点侧(只依赖 BCL,支持 AOT) 服务端侧(ASP.NET Core) │ +│ Fleet.Core Result/退避/有界队列 Fleet.Server.Enrollment 注册与审批 │ +│ Fleet.Identity 密钥/签名/证书/存储 Fleet.Server.Link wss 网关/在线表 │ +│ Fleet.Rpc JSON-RPC/路由/二进制帧 Fleet.Server.Releases 通道与灰度(可选)│ +│ Fleet.Link 会话/认证/重连/能力/ Fleet.Server.Sqlite 存储实现(可选)│ +│ 发件箱/状态同步/点对点 IFleetStore 存储抽象 │ +│ Fleet.Hosting 模块宿主/配置/日志/服务外壳/健康信标 │ +│ Fleet.Update 清单/验签/安装/切换/回滚 │ +│ 工具:fleet-enroll(注册) fleet-updater(更新) fleet-release(CI 签名) │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +## 2. 设计原则 + +1. **与业务无关**:Fleet 中不出现任何产品概念。产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。`scripts/check-deps` 强制规定 `backend/fleet/**` 不能引用 `Monitor.*`。 +2. **安全默认值**: + - Fleet **永远不提供** exec、shell、文件读写、远程下发代码这类方法; + - 节点只响应本地注册过的方法,本地策略 `allow_methods` 还可以进一步收窄; + - 更新必须通过离线密钥签名验证,面板即使被攻破也推不出恶意二进制。 +3. **权限分离**:注册(`fleet-enroll`,一次性运行)、常驻业务进程、更新(`fleet-updater`)是三个独立的进程,使用不同的系统用户,各自只拥有最小权限。 +4. **NativeAOT 优先**:节点侧只依赖 BCL;泛型扩展点通过产品传入的 `JsonTypeInfo` 实现序列化,不使用反射。 +5. **只抽取真正用到的**:每项能力都必须同时被 Monitor 和 `samples/EchoNode` 使用,防止过度设计。 + +## 3. 组件 + +| 组件 | 侧 | 职责 | 详细 | +| --- | --- | --- | --- | +| `Fleet.Core` | 两侧 | `Result`、错误码基类、退避、有界队列、ULID/UUIDv7 | — | +| `Fleet.Identity` | 两侧 | ECDSA P-256 身份密钥、自签名证书、SPKI 指纹、带用途前缀的签名串、`KeyStore`(文件权限 0600) | [link.md](link.md) §2 | +| `Fleet.Rpc` | 两侧 | JSON-RPC 2.0 信封、方法路由、请求与通知、二进制帧、错误映射;与传输方式无关 | [link.md](link.md) §4 | +| `Fleet.Link` | 节点 | 连接服务端(wss)或其他节点(TLS);认证、心跳、重连、能力协商、可靠发件箱、期望状态同步 | [link.md](link.md) | +| `Fleet.Enrollment.Client` | 节点 | 加入令牌注册流程(供 `fleet-enroll` 使用) | [link.md](link.md) §3 | +| `Fleet.Hosting` | 节点 | 模块宿主、配置(源生成 JSON)、logfmt 日志、服务外壳(systemd/procd/Windows)、健康信标 | [link.md](link.md) §8 | +| `Fleet.Update` | 节点 | 清单格式、验签、下载校验、多版本目录与原子切换、服务适配器、健康确认、回滚 | [update.md](update.md) | +| `Fleet.Server.Enrollment` | 服务端 | 加入令牌、注册端点、审批与接管 | [link.md](link.md) §3 | +| `Fleet.Server.Link` | 服务端 | wss 端点、签名认证、连接注册表、在线事件、状态下发、收件箱 ACK | [link.md](link.md) | +| `Fleet.Server.Releases` | 服务端(可选) | 托管清单、管理通道、灰度发布、汇总各节点版本 | [update.md](update.md) §6 | +| `Fleet.Server.Sqlite` | 服务端(可选) | `IFleetStore` 的 SQLite 实现(节点、令牌、公钥、状态版本) | — | +| `fleet-enroll` | 工具 | 节点注册:生成密钥、调用注册端点、写入配置 | [link.md](link.md) §3 | +| `fleet-updater` | 工具 | 节点更新守护进程,可以同时管理多个产品 | [update.md](update.md) | +| `fleet-release` | 工具 | 在 CI 或离线环境中生成并签名发布清单 | [update.md](update.md) §3 | + +## 4. 目录结构 + +```text +fleet/ 以后可以原样拆成独立仓库并发布 NuGet 包 +├─ Directory.Build.props 导入仓库根目录的 props +├─ src/ +│ ├─ Fleet.Core/ Fleet.Identity/ Fleet.Rpc/ Fleet.Link/ Fleet.Hosting/ Fleet.Update/ +│ ├─ Fleet.Enrollment.Client/ +│ └─ Fleet.Server.Enrollment/ Fleet.Server.Link/ Fleet.Server.Releases/ Fleet.Server.Sqlite/ +├─ tools/ fleet-enroll/ fleet-updater/ fleet-release/ +├─ samples/ EchoNode/ EchoServer/ 最小示例:不依赖 Monitor 的第二个使用者 +├─ tests/ +└─ docs → 仓库 docs/fleet/ +``` + +## 5. 演进策略 + +- **现阶段**(已确认,暂不独立):放在 monitor 仓库的 `backend/fleet/` 目录中,通过项目引用使用,一处修改两边同时编译。 +- **拆分条件**:公开 API 连续两个里程碑没有破坏性变更,并且 `samples` 能覆盖全部能力。满足后拆成独立仓库,按 semver 发布 NuGet 包,Monitor 改为包引用。 +- **公开 API 审查**:`backend/fleet/src/*` 的 public 类型使用 `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` 跟踪(使用 `Microsoft.CodeAnalysis.PublicApiAnalyzers`,只在构建期生效),任何变更都会在 PR 中显示出来。 + +## 6. 产品接入一览 + +**节点侧**: + +```csharp +var identity = NodeIdentity.Load(config.IdentityDir); // 由 fleet-enroll 生成 +var link = FleetLink.ToServer(config.Server, identity, MyRpcContext.Default) + .WithCapabilities(["myapp.v1"]) + .WithState(MyRpcContext.Default.MyDesired, reconciler) // 期望状态同步(可选) + .WithOutbox(MyReportCodec.Instance, maxBytes: 16 << 20) // 可靠上报(可选) + .On("myapp.do_thing", HandleAsync); // 业务方法 +var host = new ModuleHost(log).Add(link).Add(myModules); +await ServiceShell.RunAsync(host, HealthBeacon.From(config)); // 同时提供更新所需的健康确认 +``` + +**服务端**: + +```csharp +builder.Services.AddFleetServer(o => o.UseStore()) + .AddState(desiredProvider) // 为每个节点计算期望状态 + .AddInbox(reportHandler) // 持久化之后再 ACK + .OnNodeEvent(presenceHandler); // 上线、下线、版本变化 +app.MapFleetEnrollment("/fleet/enroll"); +app.MapFleetLink("/fleet/rpc"); +app.MapFleetReleases("/fleet/releases"); // 可选 +``` diff --git a/docs/fleet/update.md b/docs/fleet/update.md new file mode 100644 index 0000000..c1c873b --- /dev/null +++ b/docs/fleet/update.md @@ -0,0 +1,110 @@ +# Fleet:通用更新(Fleet.Update / fleet-updater) + +## 1. 原则 + +- **与产品无关**:`fleet-updater` 通过本地配置声明它管理哪些应用,一台机器上可以同时管理多个产品。 +- **信任来自签名,而不是来源**:只接受由**离线发布密钥**签名的清单。发布源可以是面板、GitHub Releases、对象存储或任意 HTTP 服务。**面板即使被攻破,也推不出恶意二进制。** +- **本地说了算**:更新策略(`auto`、`notify`、`pinned`)写在节点本地的配置中,面板只能“建议”目标版本。 +- **权限分离**:业务进程不能改写自己的二进制;更新进程读不到业务身份私钥,也不参与业务。 + +```text +┌─ 节点 ─────────────────────────────────────────────────────────────────────┐ +│ myapp(业务进程) 用户 myapp:只读 /opt/myapp,只能写配置目录 │ +│ └─ HealthBeacon ──写──> /run/fleet/myapp.beacon │ +│ fleet-updater 用户 fleet-update:只能写 /opt/myapp/versions, │ +│ ├─ 读取信标 通过 polkit/sudoers 规则只允许重启 myapp.service │ +│ └─ 不读取 node.key │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +## 2. 本地配置(`/etc/fleet/updater.json`) + +```json +{ + "trust": [ + { "id": "rel-2026a", "alg": "ecdsa-p256", "pubkey": "BASE64..." }, + { "id": "rel-2027a", "alg": "ecdsa-p256", "pubkey": "BASE64..." } + ], + "check_interval_s": 3600, + "apps": [ + { + "id": "monitor-agent", + "sources": ["https://panel.example.com/fleet/releases", "https://github.com/org/monitor/releases/download"], + "channel": "stable", + "policy": "notify", + "rid": "linux-x64", + "install_dir": "/opt/monitor-agent", + "keep_versions": 2, + "service": { "kind": "systemd", "name": "monitor-agent" }, + "health": { "kind": "beacon", "path": "/run/fleet/monitor-agent.beacon", "timeout_s": 60 } + } + ] +} +``` + +- `trust` 可以配置多把公钥,用于轮换;清单中的 `key_id` 用来选择验证哪一把。 +- `policy` 取值:`auto`(验签通过即安装)、`notify`(只下载并上报“有可用更新”,等待面板上的人工批准)、`pinned:`(锁定版本)。 +- 面板上的批准只对 `notify` 生效,并且**批准的版本同样必须通过签名验证**。 + +## 3. 发布清单(由 `fleet-release` 在 CI 或离线环境中生成) + +```json +{ + "schema": 1, + "app": "monitor-agent", + "channel": "stable", + "version": "0.4.2", + "released_at": "2026-10-01T00:00:00Z", + "min_from": "0.3.0", + "artifacts": [ + { "rid": "linux-x64", "url": "monitor-agent-0.4.2-linux-x64.tar.gz", "sha256": "…", "size": 4120334 }, + { "rid": "linux-arm64", "url": "monitor-agent-0.4.2-linux-arm64.tar.gz", "sha256": "…", "size": 3987112 }, + { "rid": "linux-musl-arm64", "url": "monitor-agent-0.4.2-linux-musl-arm64.tar.gz", "sha256": "…", "size": 3990021 }, + { "rid": "win-x64", "url": "monitor-agent-0.4.2-win-x64.zip", "sha256": "…", "size": 4455010 } + ], + "notes_url": "https://…/CHANGELOG#0.4.2", + "key_id": "rel-2026a", + "signature": "BASE64(ECDSA(规范化 JSON,不含 signature 字段))" +} +``` + +- 签名范围是**规范化后的 JSON**:键排序、无空白、不含 `signature` 字段。规范化规则由 `Fleet.Update` 实现,签名端和验签端使用同一份代码。 +- 发布源的目录约定:`///latest.json`(指向当前版本)和 `///manifest.json`。 +- 防回滚:updater 在本地记录每个应用已安装过的最高版本;除非本地配置显式允许降级,否则拒绝安装更低的版本,防止旧的签名清单被重放。 + +## 4. 更新流程 + +```text +轮询(间隔加随机抖动) + └─ 拉取 latest.json → 验签 ✗ → 记录并忽略 + ├─ 版本 ≤ 当前版本,或当前版本 < min_from → 跳过(后者需要人工介入) + ├─ policy = notify → 下载并校验 → 上报“可以更新” → 等待批准 + └─ policy = auto 或已批准: + 下载 → 校验 sha256 和大小 → 解包到 versions// → 检查可执行文件 + → 原子切换 current → versions/(Linux 用符号链接,Windows 见 §5) + → 重启服务 → 等待健康信标(version == v,并且 ok_at 晚于重启时刻) + ✓ → 记录成功,按 keep_versions 清理旧版本 + ✗ 超时 → 切回旧版本 → 重启 → 记录失败,该版本标记为坏版本,不再自动尝试 +``` + +## 5. 平台适配 + +| 平台 | 切换方式 | 服务管理 | +| --- | --- | --- | +| Linux | `current` 符号链接 + `rename(2)` 原子替换 | systemd:`systemctl restart `,由 polkit 规则限定只能操作被管理的单元 | +| Windows | 服务的 ImagePath 指向 `current\app.exe`;先停服务,再切换目录联结(junction),然后启动 | SCM:updater 的服务账户只被授予这几个服务的启动和停止权限 | +| OpenWrt | 与 Linux 相同的符号链接切换;默认 `keep_versions = 1`,下载前检查闪存剩余空间 | procd:`/etc/init.d/ restart`(updater 以 root 运行,这是 OpenWrt 的常见做法;仍然不读取节点身份目录) | +| 其他(以后) | launchd、OpenRC | 通过适配器接口 `IServiceAdapter` 扩展 | + +## 6. 面板侧(`Fleet.Server.Releases`,可选) + +- 托管清单和制品(也可以只做代理或索引,制品放在 GitHub 或对象存储)。 +- **通道与灰度**:每个通道可以指定目标版本,并按标签分批发布,例如先更新 1 台节点,观察 N 分钟没有失败后再更新其余节点。 +- **状态汇总**:节点在 `fleet.hello` 中上报 `{app, version}`,updater 上报 `{available, installing, failed, rolled_back}`。面板据此展示版本分布和更新进度。 +- updater 与面板通信时,**不使用业务进程的身份**。首版只轮询公开的通道清单,完全不需要身份,状态也由业务进程在 `fleet.hello` 中代为上报。给 updater 单独一对身份密钥是以后的扩展。 + +## 7. 发布密钥管理 + +- 私钥只存在于 CI 的密钥服务或离线环境中,**永远不上传到面板**。 +- 轮换方式:新旧两把公钥同时写进 `trust` → 用新密钥发布 → 所有节点都更新到信任新密钥的版本后 → 移除旧公钥。 +- 密钥泄露时:发布一个用新密钥签名、并从 `trust` 中移除旧公钥的版本;已经被恶意更新的节点需要人工重装。 diff --git a/docs/measurement.md b/docs/measurement.md new file mode 100644 index 0000000..541d903 --- /dev/null +++ b/docs/measurement.md @@ -0,0 +1,184 @@ +# 任务与采集引擎 + +## 1. 原则 + +- **只采集原始数据**:逐包、逐消息、逐事件,TCP 另加周期性的内核采样;不计算任何统计,解释全部放在 [analysis.md](analysis.md)。 +- **一处声明,处处生效**:采集方法(Kind)由积木组合**声明**而成。Probe 和 Agent 共用同一套积木(`backend/shared/Monitor.Measurement`)。 +- **密度即参数**:频率、包大小、采样间隔全部是任务参数,由 API 下发,不需要改代码。 + +## 2. 任务模型 + +```text +Task = { id, scope, kind, target, params, schedule, enabled } + +scope line : Probe ↔ Agent 线路任务 target 为空(agent_id 为空时表示对所有 Agent 生效的模板) + target : Agent → 目标 IP 的单边任务 target = ip[:port](受 Agent 本地 allowed_target_cidrs 限制) +kind Kind 目录中的 id,例如 udp_echo / tcp_echo / udp_train … +params 按 Kind 的 ParamSpec 校验(默认值、上下限);未指定的参数取默认值 +schedule continuous 持续运行,按时间切块 + every(N) 每 N 分钟运行一次(N ≥ 60,适用于重负载 Kind) + once 按需测试,由 API 触发,也就是一次 Test +``` + +- **线路任务模板**:`agent_id` 为空的 `line` 任务会自动应用到所有 `active` 的 Agent。新 Agent 注册后自动获得这些任务;也可以针对单个 Agent 覆盖参数或停用。 +- **测试方案(Plan)**:多个阶段的 `once` 任务组合,例如 `hy2_ladder` 是 `udp_train` 在 10、25、50、100 Mbps 下各运行 10 秒。Plan 定义在 `Protocol/Catalog/Plans`。 +- **期望任务集**:Backend 为每个 Agent 计算“有效任务集”(模板 + 覆盖 + 目标任务),带版本号,作为 Fleet 的期望状态(`fleet.state.sync`,文档类型 `TaskSet`)下发。Agent 的 `TaskReconciler` 与当前运行的任务对比后,启动、停止或修改参数;其中线路任务再经过控制通道同步给 Probe。**新增任务类型时,协议不需要任何改动。** + +## 3. Kind 目录:积木组合 + +### 3.1 积木(`Monitor.Measurement`,只有新增积木才需要写代码) + +| 类别 | 积木 | 参数示例 | +| --- | --- | --- | +| Transport | `udp`、`tcp`、`tls`、`icmp` | `port`、`sni`、`nodelay`、`sndbuf` | +| Pacer(发送节奏) | `fixed`、`poisson`、`burst`、`ladder` | `rate_hz`、`mbps`、`burst_len`、`steps[]` | +| Payload(负载形态) | `fixed`、`uniform`、`choice` | `size`、`min`/`max`、`sizes[]` | +| Behavior(对端行为) | `echo`、`sink`、`source`、`connect`、`hold` | `direction`(`fwd`/`rev`/`bidir`)、`streams` | +| Sampler(采样器,可挂到任意连接上) | `tcp_info`、`socket_counters` | `every_ms` | +| Recorder(记录器) | 根据 Kind 声明的列**自动生成**的列式记录器 | — | + +**两种角色互相独立**:连接角色(Probe 永远是拨号方,适配 NAT)与数据角色(发送端、接收端、回显端,由 `direction` 决定)无关。每种积木只实现一次,Probe 和 Agent 共用。 + +### 3.2 Kind 声明(`Monitor.Protocol/Catalog/Kinds`) + +```csharp +public static readonly KindSpec TcpEcho = new( + Id: "tcp_echo", + Scopes: Scope.Line, + Transport: Blocks.Tcp(nodelay: true), + Pacer: Blocks.Fixed(rateHz: P.Int("rate_hz", @default: 20, min: 1, max: 200)), + Payload: Blocks.Uniform(P.Int("min", 32, 8, 1400), P.Int("max", 64, 8, 1400)), + Behavior: Blocks.Echo(), + Samplers: [Blocks.TcpInfo(everyMs: P.Int("tcp_info_ms", @default: 100, min: 10, max: 1000))], + Records: + [ + Rec.Stream("msg", Col.U32("seq"), Col.U16("size"), + Col.TsProbe("t1"), Col.TsAgent("t2"), Col.TsAgent("t3"), Col.TsProbe("t4")), + Rec.Stream("tcp_info", Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), + Col.U32("rttvar_us"), Col.U32("cwnd"), Col.U32("retrans_total"), Col.U64("bytes_acked")), + ], + Budget: p => Bandwidth.Echo(p.RateHz, p.Max)); // 用于校验上限和估算带宽 +``` + +这一条声明会被四个地方使用: + +- **Probe/Agent**:组装积木执行任务; +- **Recorder**:根据列定义记录数据; +- **Backend**:校验参数和预算,并在 `/catalog/kinds` 中展示; +- **Analysis**:按列名读取数据。 + +### 3.3 内置 Kind + +| Kind | 组合 | 默认参数 | 主要服务于 | +| --- | --- | --- | --- | +| `udp_echo` | udp + fixed + fixed + echo | 10 Hz × 64 B | 基础指标、mc、hy2 | +| `udp_train` | udp + fixed/ladder + fixed + sink | 1250 B,`rev` | hy2 | +| `tcp_echo` | tcp/tls + fixed + uniform + echo + tcp_info | 20 Hz × 32–64 B,`tcp_info` 每 100 ms | mc、reality | +| `tcp_bulk` | tcp/tls + source/sink + tcp_info | `streams` 条连接并行传输(`direction` = fwd Probe 发 / rev Agent 发),Probe 每 `sample_ms` 记录每条连接的累计字节数(`bytes` 流);两端各自只采样第一条连接的内核 tcp_info;发送端受本地 `MaxBulkMbps` 约束;只用于按需测试,默认 4 秒(够量到峰值,省流量) | reality | +| `connect` | tcp/tls + fixed + connect | 每分钟 6 次 | reality,以及目标任务 | +| `hold` | tls + hold | 每 30 s 一次心跳 | reality | +| `icmp_echo` | icmp + fixed + fixed + echo | 1 Hz × 64 B | 目标任务 | + +默认的线路任务模板:`udp_echo`、`tcp_echo`(持续运行)。其余 Kind 可以按需开启,或者以 Plan 形式运行。 + +### 3.4 回显的四个时间戳 + +```text +Probe 单调时钟 Agent 单调时钟 + t1 发送 ─────────────> t2 收到 + t4 收到 <───────────── t3 回复 +``` + +RTT = `(t4 − t1) − (t3 − t2)`。缺少 `t2` 表示上行丢失;有 `t2`/`t3` 但缺少 `t4` 表示下行丢失。`t2 − t1` 和 `t4 − t3` 的**变化量**在去除偏移和漂移后,就是每个方向各自的时延抖动。 + +## 4. 原始块(自描述) + +```text +RawChunk +├─ header(JSON,自描述) +│ chunk_id(UUIDv7) container_version kind task_id test_id? phase? +│ agent_id probe_id? target? params(完整快照) port direction +│ start_utc_ms duration_ms +│ clock:agent 单调时钟 ↔ UTC 锚点,probe 时钟偏移估计 +│ streams: [ { name: "msg", columns: [ {name:"seq", type:"u32", enc:"delta"}, … ], count } … ] +├─ 各列数据(delta / zigzag / varint,按列定义编码) +├─ Brotli 压缩(BCL 自带) +└─ crc32c +``` + +- **列定义写在块头中**:新增列或新增 Kind 时,**不需要升级 `container_version`**。只有容器格式本身变化时才升级。Backend 可以通用地解码任何块,不认识的列会被保留,但会被分析层忽略。 +- **切块规则**:持续任务每 **10 秒**切一块,或者记录数达到上限(默认 50 000)时提前切块。这样采集密度提高后,单块大小仍然有界,实时显示和持久化也共用同一条链路。 +- 按需测试按阶段切块,阶段内同样受记录数上限约束。 +- 编解码器位于 `Monitor.Protocol/Raw`,Probe、Agent、Backend、离线工具使用同一份代码。 + +## 5. 列的归属与合并 + +> **两侧各自记录完整行的流**(`StreamSpec.Union`,例如 `tcp_info`):Probe 和 Agent 各自采样自己的套接字(Linux `TCP_INFO`,通过 `Socket.GetRawSocketOption`,不需要 P/Invoke),每一行带 `side` 标明属于哪一侧;合并时直接拼接,不连接,Agent 一侧的行每行只交出一次。Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。非 Linux 平台没有这类采样,对应的指标在窗口里就是没有数据。 + +采集层只产生**原始列**(`seq`、`size`、`t1…t4`、`t_send`/`t_recv`、`outcome`……),不做任何统计,也不认识上层的方案或协议;丢包率、RTT、抖动由 Backend 的分析层从这些列推算。 + +每个记录流(`StreamSpec`)声明: + +- `Driver`:行的主导方——行由它发出的包或事件决定; +- `Key`:连接键(通常是 `seq`); +- 每一列属于哪一侧由它的时钟决定:`TsProbe` 属于 Probe,`TsAgent` 属于 Agent,其余列属于主导方。 + +两端各自把**本侧的列**记在一张按键索引的环形行表里(`RowStore`),Probe 每 10 秒把已完成的行切成列块经控制通道交给**对应的 Agent**,Agent 按声明合并成完整的 RawChunk 后上传: + +- `Driver = Probe`(回显):行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); +- `Driver = Agent`(单向数据流):行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失); +- 没有 `Key`,或只有主导方一侧有列:原样透传。 + +新增一种记录流只需要写声明,不需要写合并代码。Probe 的列在规定时间内没有送达时,目前 Agent 带着已有的数据继续(不上传只有 Agent 一侧的 `partial` 块,见已知缺口)。 + +## 6. 代码结构(`backend/shared/Monitor.Measurement`) + +同一种采集方法的两端是**一对角色**写在同一个文件里:拨号方(Probe 永远是拨号方,适配 NAT)和监听方(Agent)。两端共用包的构造和校验、时钟、行表和合并;Probe 和 Agent 只负责组装自己那一侧。 + +```text +Monitor.Measurement/ 依赖:Monitor.Protocol(仅 BCL,零 NuGet) +├─ Core/ +│ ├─ Contracts.cs IBlock、BlockSpec、BlockEnv、DataSession、IAdmission、IFragmentSink…… +│ ├─ RowStore.cs 本侧记录的行:只含本侧拥有的列,按键索引的环形缓冲,热路径不分配 +│ ├─ MonoClock.cs 单调时钟(微秒)与 UTC 锚点 +│ └─ RunningBlock.cs 一个运行中的积木(取消令牌 + 后台任务) +├─ Net/ +│ ├─ UdpMux.cs UDP 数据面:按 stream 分发;校验顺序固定;准入策略(重放、限速)通过 IAdmission 注入 +│ └─ Tcp.cs TcpLink(拨号:TLS 按指纹固定 + 认证首帧)、TcpAcceptor(监听:TLS 自动识别 + 认证首帧) +├─ Blocks/ 一个文件 = 一种采集方法的两个角色 +│ ├─ Echo.cs UdpEchoDialer/Listener、TcpEchoDialer/Listener +│ ├─ Session.cs ConnectDialer、HoldDialer、IcmpEchoSolo(没有对端)、IdleListener +│ ├─ Train.cs TrainDialer(接收,记 t_recv)、TrainListener(按码率发包,记发送行) +│ └─ BlockCatalog.cs Kind → (拨号方, 监听方);capabilities、本地预算的支持列表都从这里推出来 +├─ Merging/ FragmentMerger(声明驱动的按键合并)、ChunkFinalizer(盖章:块头、时钟锚点、起止时间) +└─ Execution/ BudgetGuard(参数校验 + 本地硬上限,超出即拒绝) +``` + +**边界**(由架构测试强制):采集层的任何代码里不能出现 REALITY、Hysteria2、Minecraft 这些业务概念,也没有测试方案——方案(例如 `hy2_ladder`、`reality_session`)只是若干采集原语阶段的编排,只存在于 Backend。监听方的准入策略(重放窗口、令牌桶、源地址学习)是安全边界,通过 `IAdmission` 由 Agent 实现,积木本身不含策略。 + +## 7. 资源与安全上限 + +| 项目 | 默认值 | 由谁约束 | +| --- | --- | --- | +| 同时运行的重负载测试 | 每个 Agent 1 个;Probe 全局 1 个 | Backend 编排 + 节点本地 | +| 单次测试时长 / 码率 | ≤ 120 s;≤ 本地 `max_total_mbps` | 节点本地硬上限 | +| 单块记录数 | ≤ 50 000 | Recorder | +| Probe 上未交付的列数据 | 每个 Agent ≤ 8 MB,超出则丢弃最旧的数据并计入缺口 | Probe | +| 持续任务的总带宽 | 每条线路 ≤ 100 kbps(默认模板约 40 kbps) | Backend 校验 | +| 接收端速率校验 | 实际速率 > 声明速率 × 1.2 时中止 | 节点 | + +## 8. 端口 + +| 用途 | 默认端口 | 说明 | +| --- | --- | --- | +| Probe 控制通道(TLS,Fleet 点对点会话) | 7443/tcp | | +| UDP 数据(`udp_echo`、`udp_train`) | 7443/udp | | +| TCP/TLS 数据(`tcp_echo`、`tcp_bulk`、`connect`、`hold`) | 7444/tcp | 按首帧中的 `stream_id` 分发 | + +- **默认不使用 443**,避免与 VPS 上已有的 REALITY 或 Hysteria2 服务冲突,也不需要特权端口权限。 +- 任务参数中的 `port` 只能取 Agent 本地配置 `listen` 列表中的端口。要用 443 测量,需要节点所有者在本机把 443 加入 `listen`(并授予 `CAP_NET_BIND_SERVICE`),然后在任务中指定 `port: 443`。 +- 每个原始块的头部都记录实际使用的端口。当测量端口与被推算服务的常用端口(443)不同时,分析结果附带 `port_differs_from_service` 置信说明,因为运营商的 QoS 可能按端口区别对待。 + +## 9. 边界 + +采集端只模拟流量特征(端口、包大小、速率、握手、方向),**不实现** REALITY 或 Hysteria2 协议本身,分析结果会附带置信说明。用户态看不到 TCP 的逐段收发,所以 TCP 能拿到的最原始数据是“逐消息时间戳 + 内核采样”;pcap/eBPF 不在首版范围内。 diff --git a/docs/project-layout.md b/docs/project-layout.md new file mode 100644 index 0000000..6dfdf2c --- /dev/null +++ b/docs/project-layout.md @@ -0,0 +1,353 @@ +# 项目结构与工程约束 + +## 1. 目标 + +1. **三个独立项目加一个前端**:Probe、Agent、Backend 各自拥有解决方案,可以单独构建、测试和发布;Web 是独立的 Vite 项目(`web/`)。 +2. **通用能力只写一次**:TCP/UDP/TLS/ICMP 采集积木(类似 iperf3 的能力,但只记录原始数据)放在 `Monitor.Measurement` 中,Probe 和 Agent 共用;采集方法(Kind)由积木组合声明而成。 +3. **节点机制通用化**:注册、身份、连接、RPC、可靠上报、状态同步、宿主、更新,由与业务无关的 **Fleet** 框架提供(见 [fleet/overview.md](fleet/overview.md))。Monitor 只写业务,其他产品也可以复用 Fleet。 +4. **改一处,整条链路受益**:协议、原始数据格式、测试方案、指标、错误码、版本号、编译设置、代码风格都只有**一个真源**。改动之后,下游要么自动更新,要么在编译期或 CI 中直接报错。 +5. **模块化**:Probe/Agent 由可插拔的模块组成;Backend 按层分成多个项目,层之间的依赖在编译期强制。 +6. **Probe/Agent 轻量**:运行时只依赖 BCL 和仓库内的库,不引用任何 NuGet 包。 + +## 2. 仓库布局 + +```text +monitor/ +├─ README.md .gitignore +├─ docs/ docs/fleet/ +├─ scripts/ # publish / package / smoke / stability 等脚本(check-deps 等检查也放这里) +├─ packaging/ # install.sh、systemd 单元、OpenWrt 文件 +├─ web/ # 前端:Vite + React + TypeScript(自写),只依赖 backend/openapi/v1.json +│ +├─ global.json Directory.Build.props Directory.Packages.props Monitor.slnx +├─ .editorconfig BannedSymbols.txt # 风格与禁用 API(构建期强制) +├─ BannedSymbols.Node.txt # 仅用于常驻节点进程:纯采集边界 +│ +└─ backend/ # 所有 C# 代码(构建产物在 backend/artifacts/) + ├─ fleet/ # 通用节点框架(不认识 Monitor,以后可以拆成独立仓库) + │ ├─ src/ Fleet.Core Fleet.Identity Fleet.Rpc Fleet.Link Fleet.Hosting Fleet.Update + │ │ Fleet.Enrollment.Client Fleet.Server.{Enrollment,Link,Releases,Sqlite} + │ ├─ tools/ fleet-enroll fleet-updater fleet-release + │ ├─ samples/ EchoNode EchoServer + │ └─ tests/ + ├─ shared/ # Monitor 自己的共享库 + │ ├─ Monitor.Protocol/ # 契约真源:Kind/Plan 目录、任务集、monitor.* 方法、UDP 包、RawChunk、错误码 + │ ├─ Monitor.Measurement/ # 采集引擎:积木、Recorder、任务调和(见 measurement.md) + │ └─ tests/ + ├─ probe/ src/Monitor.Probe/ tests/ # 家宽端采集器 + ├─ agent/ src/Monitor.Agent/ tests/ # VPS 端采集器 + ├─ src/ # Backend(面板) + │ ├─ Monitor.Backend/ # 宿主与组合根(Program.cs) + │ ├─ Monitor.Backend.Api/ # API 层:HTTP 端点、DTO、错误映射 + │ ├─ Monitor.Backend.Gateway/ # 在 Fleet.Server.Link 上注册 Monitor 的方法、状态与收件箱 + │ ├─ Monitor.Backend.Application/ # 用例层 + 端口接口 + │ ├─ Monitor.Backend.Analysis/ # 分析(纯计算,见 analysis.md) + │ └─ Monitor.Backend.Storage.Sqlite/ # 端口接口的实现,同时实现 IFleetStore + ├─ tests/ + ├─ openapi/v1.json # 构建生成并提交 + └─ Monitor.Backend.slnx +``` + +节点机器上的发布物是三个独立进程,使用不同的系统用户:`monitor-agent`(或 `monitor-probe`)、`fleet-enroll`(一次性运行)、`fleet-updater`(常驻或定时运行)。 + +## 3. 依赖关系 + +```text + ┌────────┐ ┌────────┐ ┌──────────────────────────────┐ ┌─────┐ + │ Probe │ │ Agent │ │ Backend (Api/Gateway/App/ │─openapi─>│ Web │ + └─┬──┬───┘ └─┬──┬───┘ │ Analysis/Storage) │ └─────┘ + │ └───┬───┘ │ └───┬───────────────┬──────────┘ + │ ▼ │ │ │ + │ ┌───────────┴┐ │ │ + │ │Measurement │ │ │ + │ └─────┬──────┘ │ │ + ▼ ▼ ▼ ▼ + ┌──────────────────────────────────┐ ┌─────────────────────────┐ + │ Monitor.Protocol │ │ Fleet.Server.* │ + └────────────────┬─────────────────┘ └───────────┬─────────────┘ + ▼ ▼ + ┌──────────────────────────────────────────────────────────────────┐ + │ Fleet(节点侧):Core · Identity · Rpc · Link · Hosting │ + └──────────────────────────────────────────────────────────────────┘ + 反向依赖一律禁止:fleet/** ✗→ Monitor.* +``` + +简要来说: + +```text +Probe, Agent ──> Measurement ──> Monitor.Protocol ──> Fleet.Core / Fleet.Rpc +Probe, Agent ──> Fleet.Link / Fleet.Hosting / Fleet.Identity +Backend.* ──> Monitor.Protocol + Fleet.Server.* (不引用 Measurement) +fleet-enroll ──> Fleet.Enrollment.Client ──> Fleet.Identity / Core +fleet-updater ──> Fleet.Update ──> Fleet.Core (不引用 Link,也不读取节点身份) +Web ──> openapi/v1.json +``` + +| 项目 | 允许依赖 | 禁止 | +| --- | --- | --- | +| `Fleet.*`(节点侧) | BCL、其他节点侧 Fleet 库 | 任何 NuGet 运行时包;任何 `Monitor.*` | +| `Fleet.Server.*` | ASP.NET Core、`Microsoft.Data.Sqlite`(仅 Sqlite 项目)、节点侧 Fleet 库 | 任何 `Monitor.*` | +| `Monitor.Protocol` | Fleet.Core、Fleet.Rpc | 任何 I/O、套接字、日志 | +| `Monitor.Measurement` | Monitor.Protocol、Fleet.Core | NuGet;日志(只通过计数接收器上报);读取配置;统计或解释逻辑 | +| Probe / Agent | 上述库,以及 Fleet.Link / Hosting / Identity | NuGet 运行时包;彼此互相引用;Fleet.Update;Fleet.Enrollment.Client | +| Backend.* | Monitor.Protocol、Fleet.Server.*,以及第 5.2 节列出的包 | Measurement、EF Core 等依赖反射的库 | + +分析器类包(`PrivateAssets="all"`)不受限制。`scripts/check-deps` 在 CI 中检查上述规则。 + +## 4. 共享库 + +### 4.1 Fleet(通用节点框架) + +组件、扩展点与接入方式见 [fleet/overview.md](fleet/overview.md),连接细节见 [fleet/link.md](fleet/link.md),更新见 [fleet/update.md](fleet/update.md)。原来的 `Monitor.Common` 和 `Monitor.NodeHost` 已经并入 Fleet: + +| 原位置 | 现位置 | +| --- | --- | +| `Monitor.Common`(Result、缓冲区、VarInt、有界队列、重放窗口、退避、ID、令牌哈希) | `Fleet.Core` | +| `Monitor.NodeHost`(模块宿主、日志、配置、ConfigStore、服务外壳) | `Fleet.Hosting` | +| 节点密钥与签名 | `Fleet.Identity` | +| JSON-RPC 信封与路由 | `Fleet.Rpc` | +| Agent 注册 | `Fleet.Enrollment.Client` + `fleet-enroll` | + +### 4.2 Monitor.Protocol(Monitor 的契约真源) + +```text +Monitor.Protocol/ +├─ ProtocolVersion ProtocolLimits MonitorErrorCode(在 Fleet 错误码基础上扩展) +├─ Ids/ AgentId(= Fleet NodeId)、ProbeId、TaskId、TestId、ChunkId、StreamId +├─ Catalog/ +│ ├─ Kinds/ KindSpec 声明(积木组合 + ParamSpec + 记录列 + 预算) +│ ├─ Plans/ 多阶段测试方案 +│ └─ ParamSpec 参数定义(类型、默认值、上下限),节点与 Backend 共用同一套校验 +├─ Tasks/ TaskSet(Fleet 期望状态的文档类型)、调和结果 +├─ Rpc/ monitor.* 方法名、params/result、二进制通道号(从 16 开始) +├─ Wire/ UDP 包、数据连接首帧 +├─ Raw/ 自描述 RawChunk:头部、列类型、列编解码、Brotli、crc32c +└─ Json/ AgentRpcContext、ProbeRpcContext(按信道拆分,便于裁剪) +``` + +累加器、直方图等统计类型**不在 Protocol 中**,只属于 `Backend.Analysis`。 + +### 4.3 Monitor.Measurement(采集引擎) + +包含: + +- 积木:Transport、Pacer、Payload、Behavior、Sampler; +- 根据列定义自动生成的 Recorder; +- `KindAssembler`; +- `TaskReconciler`(实现 Fleet 的 `IStateReconciler`); +- `BudgetGuard`。 + +详见 [measurement.md](measurement.md) 第 6 节。 + +## 5. 模块化 + +### 5.1 Agent 与 Probe:模块组装 + +模块在 `Program.cs` 中**显式组装**,不使用 DI 容器,也不做反射扫描。连接、认证、重连、发件箱、状态同步都由 Fleet 提供,Monitor 只写业务模块: + +```text +Agent Program.cs + identity = NodeIdentity.Load(dir) ← 由 fleet-enroll 生成 + link = FleetLink.ToServer(cfg, identity, AgentRpcContext.Default) + .WithState(…, taskReconciler) 期望任务集 → 调和 + .WithOutbox(…, 64 MiB) 原始块可靠上报 + .On(monitor.test.run / monitor.test.cancel, …) + peers = FleetLink.PeerListener(cfg.Listen, identity, ProbeRpcContext.Default) Probe 会话 + host = new ModuleHost(log) + ├─ link、peers (Fleet) + ├─ ProbeDirectoryRelay 转发 monitor.probe.agents / agents_changed + ├─ UdpMux / TcpAcceptor UDP/TCP/TLS 数据端口(Measurement 共享),按 (session, stream) 分发给监听方积木;AgentAdmission 提供重放、限速 + ├─ ChunkModule 合并 Probe 与本端的列 → 切块 → link.Outbox + └─ TestModule 按需测试的阶段编排 + +Probe Program.cs + ├─ AgentDirectoryModule 维护 Agent 列表(bootstrap → monitor.probe.agents → agents_changed) + ├─ PeerPool(Fleet) 每个 Agent 一个 FleetLink.ToPeer:认证、重连 + ├─ TaskReconciler 每个会话各自的期望状态(与 Agent 相同的代码) + ├─ ColumnShipper 本端的列通过会话的 Outbox 发送(二进制通道 17) + └─ TestModule +``` + +模块之间通过构造函数传入的接口或 `Channel` 通信,禁止通过静态全局状态共享数据。 + +**新增 RPC 方法**:在 `Monitor.Protocol/Rpc` 中定义 params/result,然后在组装处加一行 `.On(Methods.X, HandleX)`。 + +### 5.2 Backend:分层与 API 层 + +```text + HTTP (public | admin) wss /api/agent/v1/rpc(Fleet.Server.Link) SSE /api/public/live + │ │ ▲ + ┌──────────────▼───────────────┐ ┌───────────▼──────────┐ │ + │ Backend.Api │ │ Backend.Gateway │ │ + │ Public/ Admin/ │ │ 注册 monitor.* 方法 │ │ + │ 认证(Cookie/CSRF/令牌) │ │ TaskSet 状态提供者 │ │ + │ 错误映射 · OpenAPI · SSE 端点 │ │ RawChunk 收件箱 │ │ + └──────────────┬───────────────┘ └───────────┬──────────┘ │ + └───────────────┬──────────────┘ │ + ▼ │ + ┌─────────────────────────────────────────────────────────┐ ┌───────────────────┴──┐ + │ Backend.Application │───>│ LiveHub(Application)│ + │ Agents · Probe · Tasks(有效任务集的计算与下发) │ │ 内存快照 + 主题订阅 │ + │ Tests · Ingest(chunk → 存储 → 分析队列)· SeriesQuery │ └──────────────────────┘ + │ AnalysisScheduler · Downsampler · Auth · Releases 视图 │ 调用 ─> Backend.Analysis + └───────────────────────────▲─────────────────────────────┘ 纯计算:算子/指标/模型 + │ 实现端口 + ┌───────────────────────────┴─────────────────────────────┐ + │ Backend.Storage.Sqlite:段文件 + SQLite(只做读写) │ + │ 同时实现 IFleetStore(Fleet 的节点和令牌表放在 monitor.db 中)│ + └─────────────────────────────────────────────────────────┘ + + 端口:IAgentStore、ITaskStore、IRawStore、IMetricStore、ITestStore、IAuthStore +``` + +规则: + +- **Api 不能引用 Storage**(编译期保证);Api 和 Gateway 只调用 Application。 +- Application 返回 `Result`。**错误码到 HTTP 状态的映射只在 Api 层做一次**(`ErrorCodeHttpMap`);到 JSON-RPC 错误的映射由 Fleet.Rpc 统一完成。 +- Api 层按调用方分目录:`Public/`、`Admin/`,公共部分放在 `Common/`(分页、ETag、认证过滤器、ProblemFactory、`EndpointConventions`)。每类资源一个端点文件,新增资源时在 `ApiModule.Map` 中加一行。 +- 注册端点、签名认证、连接注册表、状态推送、收件箱 ACK、发布通道都来自 Fleet(`MapFleetEnrollment`、`MapFleetLink`、`MapFleetReleases`)。Monitor 只提供: + - `IFleetStore` 的实现; + - `TaskSet` 的状态提供者; + - `RawChunk` 的收件箱; + - `monitor.*` 方法。 +- public 层的 DTO 单独定义,只暴露可以公开的字段,并统一经过“可见性过滤器”。 +- 请求校验:简单字段约束使用 .NET 10 内置的 Minimal API 校验;任务参数统一用 `ParamSpec` 校验(与节点共用);跨字段规则放在 Application 层。 +- Backend 使用 ASP.NET Core 自带的 DI 容器,只在组合根显式注册。 + +Backend.Analysis 只依赖 Monitor.Protocol 和 Fleet.Core,没有 I/O,可以单独做成离线 CLI 使用。 + +Backend 允许的包:ASP.NET Core 共享框架、`Microsoft.Data.Sqlite`、`Microsoft.AspNetCore.OpenApi`、`Microsoft.Extensions.ApiDescription.Server`(仅构建期使用)。 + +## 6. “改一处,整条链路受益”的机制 + +| 改动 | 真源 | 链路上的效果 | +| --- | --- | --- | +| 采集密度(频率、包大小、采样间隔) | 任务参数(API) | 经 `fleet.state.sync` 下发后立即生效,无需发布 | +| 新增采集方法(现有积木可以组合) | `Protocol/Catalog/Kinds` 中加 1 条 | 节点执行、Recorder 自动生成列、Backend 校验和目录、Analysis 按列名读取 | +| 新增采集方法 | 在 `Kinds` 里加声明(含流的 `Driver`/`Key`),在 `Measurement/Blocks` 里实现它的拨号方和监听方并登记到 `BlockCatalog` | Probe 和 Agent 同时获得,合并不需要写代码 | +| 原始数据格式 | `Protocol/Raw`(自描述) | 新增列不需要升级版本;四处使用同一份编解码器 | +| 新增统计指标 | `Analysis/Metrics` 中加 1 行 `MetricDef` | 存储、降采样、API、SSE、目录全部自动处理;回填 30 天历史 | +| 服务评分 | `Analysis/Models` | 查询时计算,立即对全部历史生效 | +| 新增 RPC 能力 | `Monitor.Protocol/Rpc` + 组装处加 1 行 | 通过 Fleet 的 capabilities 协商,旧节点不受影响 | +| 注册、认证、重连、发件箱、状态同步 | `backend/fleet/` | Monitor 和所有其他产品同时受益 | +| 更新机制 | `Fleet.Update` + `fleet-updater` | 所有产品共用;产品只提供发布清单 | +| REST DTO 与端点 | `Backend.Api` | 生成 OpenAPI,CI 检查 diff,前端的 TS 类型随之更新 | +| 错误码 | Fleet 错误码 + `MonitorErrorCode` | REST、JSON-RPC、OpenAPI、TS 全部一致 | +| 版本、编译设置、风格、禁用 API | `backend/` 下的 props、`.editorconfig`、`BannedSymbols*.txt` | 所有项目 | + +## 7. 编译设置 + +`backend/Directory.Build.props`(所有项目): + +```xml + + + net10.0 + latest + enable + enable + true + latest-recommended + true + true + true + true + true + 0.1.0 + + + + + + +``` + +`backend/fleet/src/*` 另外启用 `Microsoft.CodeAnalysis.PublicApiAnalyzers`(`PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt`),公开 API 的任何变更都会在 PR 中显示出来。 + +`backend/probe/`、`backend/agent/` 下的 `Directory.Build.props`(先导入根 props,再追加轻量设置): + +```xml + + true + Size + true + false + false + false + false + false + + + + + + +``` + +`BannedSymbols.Node.txt` 适用于常驻节点进程(Probe、Agent,以及 Fleet 的节点侧运行库),主要条目: + +- `System.Diagnostics.Process`; +- `System.Reflection.Assembly.Load*`; +- `System.Runtime.InteropServices.NativeLibrary.Load*`; +- `System.IO.File.Write*`、`File.Create`、`FileStream` 构造函数(只有 `Fleet.Hosting.ConfigStore` 可以通过 `#pragma` 豁免); +- `System.Net.Http.HttpClient`(**不再有豁免**:注册已经移到 `fleet-enroll`)。 + +`fleet-enroll` 和 `fleet-updater` 是独立的工具,使用各自的禁用清单: + +- `fleet-updater` 允许写安装目录、调用服务管理接口,但禁止读取节点身份目录; +- `fleet-enroll` 允许 HTTP,允许写入身份目录。 + +## 8. 轻量预算 + +M0 阶段先测出基线,之后作为 CI 的回归门槛(超出 10% 即失败): + +| 程序 | 发布体积 | 空闲常驻内存 | 负载 | +| --- | --- | --- | --- | +| Probe | ≤ 8 MB(Linux/OpenWrt,静态链接了 .NET 的加密、压缩、网络库;Windows 实测 5.6 MB) | ≤ 32 MB(Linux 实测约 21 MB) | 同时连接 15 个 Agent 并运行默认模板时 CPU < 2%;`hy2_ladder` 100 Mbps 时 CPU < 50%(单核) | +| Agent | ≤ 10 MB(Linux 实测 8.4 MB) | ≤ 48 MB(Linux 实测约 23 MB) | 运行默认模板时 ≤ 64 MB;同时跑一个 200 Mbps 测试时 CPU < 60%(1 vCPU) | +| fleet-enroll | ≤ 4 MB | — | 一次性运行 | +| fleet-updater | ≤ 4 MB | ≤ 16 MB | 空闲时每小时轮询一次 | +| Backend | 不设硬性限制 | ≤ 150 MB | — | + +为满足预算,实现上需要遵守: + +- 热路径使用 `Span` 和 `ArrayPool`,不逐包分配; +- UDP 使用 `SocketAddress` 系列非分配 API; +- stream 和 bulk 发送使用预先填充的缓冲区,只统计计数,不复制数据; +- 不使用 LINQ。 + +## 9. 发布 + +- 目标平台: + +| 程序 | RID | 说明 | +| --- | --- | --- | +| Agent | `linux-x64`、`linux-arm64` | VPS | +| Probe | `linux-x64`、`linux-arm64`、`win-x64` | 普通 PC 或服务器 | +| Probe(OpenWrt) | `linux-musl-x64`、`linux-musl-arm64` | 正式支持;`linux-musl-arm`(armv7)视 M0 的工具链验证结果决定是否支持 | +| fleet-enroll / fleet-updater | 与所在节点相同 | | +| Backend | `linux-x64`、`linux-arm64`、`win-x64` | | + +- **MIPS 不支持**(NativeAOT 没有 MIPS 目标)。使用 MIPS 路由器(例如 MT7621)时,把 Probe 运行在局域网内的其他设备上,比如 NAS、树莓派或 PC,测量结果同样代表这条家宽。 +- NativeAOT 需要目标平台的原生工具链:musl 目标在 Alpine 容器中构建,arm64 使用交叉编译镜像。CI 矩阵为 Windows、Linux glibc、Linux musl。 +- `scripts/publish.(sh|ps1) ` 输出到 `artifacts/publish///`。 +- 发布时由 `fleet-release` 在 CI 中生成并签名清单(见 [fleet/update.md](fleet/update.md)),节点通过 `fleet-updater` 更新。 +- Linux 上 TLS 依赖系统 OpenSSL(`libssl.so.3`)。 + +### 9.1 OpenWrt 上的 Probe + +| 事项 | 做法 | +| --- | --- | +| C 库 | musl:使用 `linux-musl-*` 目标发布 | +| TLS | .NET 在 Linux 上依赖 OpenSSL,而 OpenWrt 默认使用 mbedTLS/wolfSSL。需要安装 `libopenssl3`(安装脚本自动检查并提示)。启动时自检,缺少时明确报错,不会静默降级 | +| 全球化 | 已启用 `InvariantGlobalization`,不依赖 ICU | +| 服务管理 | procd:提供 `/etc/init.d/monitor-probe`(`procd_set_param respawn`,前台运行,日志输出到 logd);`Fleet.Hosting.ServiceShell` 在 procd 下不需要 `sd_notify` | +| 存储 | 闪存很小,而且需要避免频繁写入:二进制放在 `/usr/lib/monitor/`,配置和身份放在 `/etc/monitor/`(sysupgrade 时通过 `/lib/upgrade/keep.d/` 保留);测量数据本来就不落盘;`ConfigStore` 只在 Agent 列表变化时写入 | +| 内存 | 预算 ≤ 32 MB,适合 ≥ 256 MB 内存的路由器;在本地配置中限制重负载测试的码率(`limits.max_test_mbps`),防止路由器 CPU 成为瓶颈,反过来影响测量结果 | +| 测量偏差 | Probe 运行在网关上时,自己没有 NAT;一旦路由器 CPU 饱和,结果就会失真。因此 Probe 在 `monitor.event` 中上报自身的 CPU 繁忙标记(仅进程级别),分析层据此标注置信说明 | +| 打包 | 提供 `tar.gz` 和 procd 脚本;包格式(ipk 或 apk)按目标 OpenWrt 版本提供 | +| 更新 | `fleet-updater` 使用 procd 适配器;`keep_versions = 1`,下载前检查剩余空间,避免写满闪存 | + +## 10. Web(待开发) + +唯一输入是 `backend/openapi/v1.json`:用 `openapi-typescript` 生成类型,`openapi-fetch` 作为请求客户端;技术栈等前端立项时再决定。 diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..ba981f2 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,103 @@ +# 线协议 + +Monitor 的两条控制链路都建立在 Fleet 之上: + +- **Agent ↔ Backend**:Fleet 连接服务端(wss),见 [fleet/link.md](fleet/link.md); +- **Probe ↔ Agent**:Fleet 节点间点对点会话(TLS),见 [fleet/link.md](fleet/link.md) 第 8 节。 + +认证、心跳、重连、能力协商、可靠发件箱、期望状态同步、JSON-RPC 信封都由 Fleet 负责。本文只定义 **Monitor 自己的内容**:`monitor.*` 方法、状态文档、二进制通道,以及 Fleet 之外的数据面(UDP 包和数据连接)。所有类型都放在 `Monitor.Protocol` 中。 + +## 1. Fleet 能力的映射 + +| Monitor 需要的能力 | 使用的 Fleet 机制 | Monitor 提供的内容 | +| --- | --- | --- | +| 下发任务 | 期望状态 `fleet.state.sync` | 文档类型 `TaskSet`,包括 `tasks[]`、`peers`(Probe 公钥),以及 `probe_agents` 的版本 | +| 上报原始块 | 发件箱,二进制通道 **16** | `RawChunk`(自描述,见 [measurement.md](measurement.md) 第 4 节),`msg_id = chunk_id` | +| Probe 交付列块 | 点对点会话的发件箱,二进制通道 **17** | 列块:`u32 stream_id \| u32 first_seq \| u32 count \| 列数据(Brotli)` | +| 节点自身状态 | `fleet.heartbeat` 的附加字段 | `sessions`、`pps_in/out`、`dropped_invalid`、`dropped_rate_limited`、`queue_bytes` | +| 版本与能力 | `fleet.hello` | capabilities 包括 `monitor.kinds:<列表>`、`monitor.blocks:<列表>`;附加 `tls_spki`、`probe_ports`、`local_limits` 摘要 | + +## 2. `monitor.*` 方法 + +### Agent ↔ Backend + +| 方法 | 方向 | 类型 | 参数与结果 | +| --- | --- | --- | --- | +| `monitor.test.run` | B→A | 请求 | `test_id`、`probe_id`(在哪个 Probe 上跑)、`plan`、`phases[]` → `accepted`,或错误 `test_busy`、`probe_offline` | +| `monitor.probe.disconnect` | B→A | 请求 | `probe_id` → `accepted`(Agent 是否真的有这个会话);Agent 关闭该 Probe 的会话 | +| `monitor.test.cancel` | B→A | 请求 | `test_id` | +| `monitor.test.status` | A→B | 通知 | `test_id`、`status`(`running`、`phase_done`、`succeeded`、`failed`、`aborted`)、`phase?`、`error_code?` | +| `monitor.event` | A→B | 通知 | `kind`(`probe_connected`(`detail` 是 `probe_id`,`probe` 里带版本、远端地址和连接时间)、`probe_disconnected`、`endpoint_changed`、`session_rejected`、`clock_step`、`gap`、`cpu_saturated`(节点进程 CPU 饱和,可能影响测量,由 Probe 或 Agent 上报))、`at_utc`、`detail` | + +心跳的附加字段里有 `probes[]`:Agent 当前连着的每个 Probe(`id`、`version`、`remote`、`since_utc_ms`),每次都是完整快照,Backend 靠它纠正错过的连接事件。点对点握手的认证消息里可以带 Probe 的 `version`(只用于展示)。块头里的 `probe_id` 以 Agent 认证过的会话为准,Probe 自报的值会被覆盖。 +| `monitor.probe.agents` | A→B | 请求 | 代理 Probe 获取 Agent 列表 → `version`、`agents[]` | +| `monitor.probe.agents_changed` | B→A | 通知 | Agent 列表发生变化,由 Agent 转发给 Probe | + +### Probe ↔ Agent(点对点会话) + +| 方法 | 方向 | 类型 | 参数与结果 | +| --- | --- | --- | --- | +| `fleet.state.sync`(文档类型 `line_task_set`) | A→P | 请求 | 这条线路的任务(`id`、`kind`、`params`、`stream_id`、`port`)和 Agent 的数据面端口 | +| `monitor.clock` | P→A | 请求 | `probe_mono_us` → `agent_mono_us`;Probe 按往返中点估计两端单调时钟的偏移(取最小往返的一次),记入块头 `probe_offset_us` | +| `monitor.test.phase` | A→P | 请求 | 测试的一个阶段:`test_id`、`phase`、`kind`、`params`、`stream_id`、`duration_s`、`port` → `accepted`;Probe 在回复之前准备好自己一侧的角色(单向数据流:先发几个探活包让 Agent 学到源地址) | +| `monitor.test.phase_end` | A→P | 请求 | `sent_count`(Agent 发出的包数)→ `fragments`:Probe 收尾,把列块交给 Agent,回复交出的个数;Agent 等全部列块合并上传之后才进入下一阶段 | +| `monitor.test.abort` | A→P | 通知 | `test_id` | +| `monitor.probe.agents` | P→A | 请求 | `have_version` → `version`、`agents[]`:Agent 代 Probe 向 Backend 查询(链路断开时 `fleet_node_offline`) | +| `monitor.probe.agents_changed` | A→P | 通知 | `version`、`agents[]`(Agent 从 Backend 转发) | +| `fleet.outbox.ack` | 双向 | 通知 | Probe 的列块(通道 17)是至少一次投递,Agent 每收到一个就确认,重复到达的只处理一次 | + +会话密钥不需要单独的方法:点对点握手(TLS 固定证书 → `peer.challenge` → Probe 用身份私钥签 `(peer_id, nonce, client_pub, server_pub)` → 两端各自 ECDH + HKDF)直接得到 `session_id` 和 `session_key`,`peer.auth` 的应答只回显 `session_id` 用来互相确认。 + +### 列块(Probe → Agent,通道 17) + +Probe 只记录自己一侧的列,Agent 在数据面记录另一侧,由 Agent 合并、盖章、上传(见 `measurement.md` 第 5 节)。合并**由 Kind 的流声明驱动**(`StreamSpec`:`Driver`、`Key`,每一列的归属由它的时钟决定),没有按 Kind 手写的模式: + +| 流声明 | 例子 | Probe 的列块 | Agent 补充 | +| --- | --- | --- | --- | +| `Driver = Probe`,有 `Key`,Agent 一侧有列 | `udp_echo`、`tcp_echo` 的 `msg` | `seq`、`size`、`t1`、`t4` | 按 `seq` 补上 `t2`、`t3`(没有记录表示上行丢失) | +| `Driver = Agent`,有 `Key` | `udp_train` 的 `pkt` | `pkt(seq, t_recv)`,头部 `_seq_from` / `_seq_to` | 行由 Agent 发出的包决定(`size`、`t_send`),按 `seq` 取 `t_recv`(缺失表示丢失) | +| 没有 `Key`,或只有主导方一侧有列 | `connect`、`hold`、`step` | 完整的行 | —(按声明的列顺序原样透传) | + +单块最多 `MaxRecordsPerChunk`(50 000)条记录:Probe 必须把大的阶段按序号范围切成多个列块(例如 100 Mbit/s、1000 B 的 10 秒阶段是 125 000 个包 → 4 个列块),`phase_end` 的应答报告总块数。块头里 Probe 必须带上 `agent_id`(它知道自己在和哪个 Agent 通信)。 + +新增方法只需要在 `Monitor.Protocol/Rpc` 中定义,并在节点组装处加一行 `.On(...)`。旧节点会通过 capabilities 协商自动避开不支持的方法。 + +## 3. 数据面(不属于 Fleet) + +### 3.1 UDP 包 + +```text +偏移 长度 字段 +0 2 magic 0x4D4E ("MN") +2 1 version 1 +3 1 type 1 = EchoRequest,2 = EchoReply,3 = Data(单向) +4 8 session_id +12 4 stream_id 标识任务或测试阶段 +16 4 seq 在同一 stream 内单调递增 +20 8 send_ts_us 发送方单调时钟(EchoReply 原样带回请求中的值) +28 N padding 补足到 Payload 积木决定的包大小 +28+N 16 mac HMAC-SHA256(session_key, 前面全部字节),截断至 16 字节 +``` + +**接收方处理规则**(任一步失败即静默丢弃并计数,**不回复**): + +1. 长度、magic、version、type 合法; +2. `session_id` 和 `stream_id` 都处于活跃状态; +3. MAC 校验通过; +4. `seq` 不在重放窗口中; +5. 速率不超过声明速率的 1.2 倍,也不超过本地硬上限。 + +通过后先**记录原始列**。收到 `EchoRequest` 时,立即回复一个长度**完全相同**的包,发往请求的**实际源地址**(这样可以跟随 NAT 映射变化),并记录回复时刻。 + +### 3.2 TCP/TLS 数据连接 + +- 连接都由 Probe 发起;首帧为 `session_id | stream_id | nonce | mac`,认证失败直接关闭连接。 +- 应用层消息使用 `VarInt 长度 | 正文` 分帧;回显类消息的正文为 `seq | send_ts_us | padding`。 + +## 4. 版本演进 + +- Fleet 层的兼容规则见 [fleet/link.md](fleet/link.md)。 +- `monitor.*` 方法:新增方法或可选字段不需要升级版本,接收方忽略未知字段。 +- RawChunk:块头自描述,新增列或新增 Kind 不需要升级 `container_version`;只有容器格式变化时才升级。 +- UDP 包和数据连接首帧的布局变化时,必须升级 `protocol_version`,并更新黄金字节测试。 +- Agent 同时支持当前版本和上一个版本的 Probe;Backend 能解码保留期内所有的 `container_version`。 diff --git a/docs/query.md b/docs/query.md new file mode 100644 index 0000000..256fbc3 --- /dev/null +++ b/docs/query.md @@ -0,0 +1,209 @@ +# 展示数据查询(前端取数) + +本文定义前端从 Backend 拉取展示数据的方式:时间段、步长、指标选择、响应格式、实时跟随、缓存与限额。端点清单见 [api.md](api.md) 第 2 节;指标与累加器见 [analysis.md](analysis.md)。 + +## 1. 设计目标 + +1. **任意时间段**:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。 +2. **短时间段足够精细**:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。 +3. **汇总精确**:“这段时间内的丢包率、p95”由累加器精确合并得出,**不是各点的平均**。 +4. **快**:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。 +5. **新增指标时前端不用改**:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。 + +## 2. 时间段与步长 + +### 2.1 参数 + +| 参数 | 取值 | 说明 | +| --- | --- | --- | +| `range` | `5m`、`15m`、`30m`、`1h`、`3h`、`6h`、`12h`、`24h`、`7d`、`30d`、`90d`、`365d`,或任意 `<数字>` | 相对时间段,`to = now`,可以跟随实时数据 | +| `from` + `to` | RFC 3339 UTC | 绝对区间,左闭右开 `[from, to)`;与 `range` 互斥 | +| `step` | `auto`(默认),或下列常用步长之一 | 指定的步长比数据源所能提供的更细时,自动降级,并在响应中标记 `degraded` | +| `anchor` | `now`(默认)或 `latest` | 只用于序列接口的相对时间段(`range=`):`latest` 以这条线路最后一个样本为终点(最后样本在最近 5 秒内就仍以当前时间为终点)。离线的线路因此显示它最后一段数据,而不是空图;响应里的 `from/to` 是实际使用的窗口,`live` 为 false | +| `tz` | IANA 时区,默认 `UTC` | 只影响 ≥ 1d 的分桶对齐,比如“按天”统计时从本地零点切分 | + +常用步长:`1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d` + +### 2.2 自动步长 + +`step = 常用步长中 ≥ 区间长度 / 720 的最小值`,即每条曲线最多约 720 个点。 + +| 时间段 | 步长 | 点数 | 数据源 | +| --- | --- | --- | --- | +| 5m | 1s | 300 | 内存热层 | +| 15m | 2s | 450 | 内存热层 | +| 30m | 5s | 360 | 内存热层 | +| 1h | 5s | 720 | 内存热层 | +| 6h | 30s | 720 | 10s 层 | +| 24h | 2m | 720 | 1m 层 | +| 7d | 15m | 672 | 5m 层 | +| 30d | 1h | 720 | 1h 层 | +| 90d | 3h | 720 | 1h 层 | + +- 桶对齐到 UTC 的整数倍步长。 +- 起点不对齐的绝对区间:第一个桶和最后一个桶可能不完整,会在响应的 `edge_partial` 中标出。 + +## 3. 数据分层 + +```text + 精细 ◀────────────────────────────────────────────────────▶ 粗略 + ┌─────────────────┬──────────────┬──────────────┬─────────────┬─────────────┐ + │ 热层(内存) │ metric_10s │ metric_1m │ metric_5m │ metric_1h │ + │ 1s 累加器 │ 保留 48 小时 │ 保留 30 天 │ 保留 365 天 │ 永久 │ + │ 最近 1 小时(环形缓冲)│ SQLite │ SQLite │ SQLite │ SQLite │ + └────────┬────────┴──────────────┴──────────────┴─────────────┴─────────────┘ + │ 重启后热层为空 → 按需从原始块回填(最近 1 小时,后台以低优先级执行) + 原始段文件(30 天)── 超出热层时间范围的 1s~5s 精度查询 → 从原始块现算(区间 ≤ 15 分钟) +``` + +**选择数据源的规则**:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择**最粗**的一层,然后把累加器按步长现场合并。 + +示例: + +- `step = 30s` 时,取 10s 层,每 3 个桶合并一次; +- `step = 2m` 时,取 1m 层,每 2 个桶合并一次。 + +合并方式是计数相加、直方图逐桶相加,所以结果精确。 + +| 情况 | 处理方式 | +| --- | --- | +| 时间段在最近 1 小时内,步长 ≥ 1s | 热层 | +| 时间段在 1 小时之前、30 天之内,步长 < 10s,区间 ≤ 15 分钟 | 从原始块现算(全局最多同时 2 个查询) | +| 同上,但区间 > 15 分钟 | 降级为 10s 层或 1m 层,并标记 `degraded: true` | +| 所需的层已经过期 | 降级为下一个更粗的可用层,并标记 `degraded: true` | + +**热层内存估算**:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。 + +## 4. 指标选择 + +### 4.1 按 id 选取 + +`metrics=loss.down,tcp.retrans,rtt.p95` + +id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节;所有可用的 id 可以从 `/api/public/catalog/metrics` 获取。 + +### 4.2 按分组(preset)选取 + +`preset=udp,tcp` + +分组在 `Backend.Analysis` 的注册表中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。 + +| 分组 | 指标 | +| --- | --- | +| `udp` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run_max`、`reorder`、`dup` | +| `tcp` | `tcp.rtt.p50`、`tcp.rtt.p95`、`tcp.rtt.p99`、`tcp.rtt.p999`、`tcp.rtt.max`、`tcp.rtt.tail`、`tcp.slow`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans`、`tcp.srtt.p95` | +| `connect` | `connect.success`、`connect.tcp_ms.p95`、`connect.tls_ms.p95`、`connect.fail.timeout`、`connect.fail.rst`、`connect.fail.tls` | +| `session` | `hold.resets.per_h`、`hold.lifetime_max`、`probe.endpoint_changes` | +| `scores` | `score.base`、`score.mc`、`score.hy2`、`score.reality` | + +目录中每个 id 都附带 `unit`、`label`、`description`、`better`(`lower` 或 `higher`,用于前端配色),以及 `thresholds`(优、良、差三档阈值,由模型给出)。 + +## 5. 响应格式(列式) + +```json +{ + "agent_id": "agt_…", + "from": "2026-09-28T10:00:00.000Z", "to": "2026-09-28T10:15:00.000Z", + "step": "2s", "source": "hot", "live": true, "degraded": false, + "t": [1759053600000, 1759053602000, 1759053604000], + "values": { + "loss.down": [0.0, 0.0, 10.0], + "rtt.p95": [48.1, 49.0, 61.2], + "tcp.retrans": [0.0, 0.1, null] + }, + "summary": { + "metrics": { "loss.down": 0.42, "rtt.p95": 61.3, "tcp.retrans": 0.08 }, + "samples": { "udp_echo": 8990, "tcp_echo": 17980 }, + "coverage": 0.998 + }, + "edge_partial": { "first": false, "last": true }, + "gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }], + "events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }], + "versions": { "loss.down": "a1f3…" } +} +``` + +- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api-guidelines.md](api-guidelines.md) 中“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。 +- 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。 +- `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。 +- `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。 + +## 6. 端点 + +| 端点 | 用途 | 主要参数 | +| --- | --- | --- | +| `GET /api/public/overview` | 首页排行:每条线路在该时间段内的汇总,并支持排序 | `range`(默认 `5m`)、`preset` 或 `metrics`、`sort`、`order` | +| `GET /api/public/lines/{line_id}/series` | 单条线路的时序 | `range` 或 `from/to`、`step`、`preset` 或 `metrics` | +| `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `agents`、`metric`(单个)、`range` 或 `from/to`、`step` | +| `GET /api/public/lines/{line_id}/distribution` | 该时间段内的分布(直方图、CDF) | `metric`(直方图类,如 `rtt`、`jitter.down`、`tcp.rtt`)、`range` 或 `from/to` | +| `GET /api/public/lines/{line_id}/heatmap` | 时间 × 延迟桶的热力图(类似 smokeping) | `metric`、`range` 或 `from/to`、`step` | +| `GET /api/public/catalog/presets` | 分组定义 | — | + +`overview` 的响应示例: + +```json +{ + "range": "15m", "from": "…", "to": "…", + "lines": [ + { "agent_id": "agt_…", "name": "jp-1", "online": true, + "summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 }, + "coverage": 1.0 } + ] +} +``` + +`compare` 的响应示例:`{ "t":[…], "values":{ "agt_a":[…], "agt_b":[…] }, "summary":{ "agt_a":0.1, "agt_b":0.4 } }` + +`distribution` 的响应示例:`{ "metric":"rtt", "unit":"ms", "buckets":[{"le":40.0,"count":812}, …], "p50":44.2, "p95":61.0, "p99":140.2, "count":8990 }` + +`heatmap` 的响应示例:`{ "t":[…], "edges":[…], "counts":[[…],[…]] }`,其中 `counts[i]` 是时间桶 `t[i]` 在各个延迟桶中的计数。 + +## 7. 实时跟随 + +```text +前端选择“近 15 分钟” + ① GET /lines/{id}/series?range=15m&preset=udp,tcp → 一次性获取 450 个点和汇总 + ② SSE /api/public/live?topics=series:{id}:2s:udp,tcp → 每当原始块到达(约每 10 s), + 推送一批新的 2s 桶和最新的汇总 + event: series + data: {"agent_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}} + ③ 前端把新点追加到末尾,并丢弃超出窗口的旧点;用推送来的汇总替换原来的汇总 + +前端切换为“自定义 09:00–10:00”→ 只执行 ①(live=false) +``` + +- SSE 的订阅主题格式为 `series:{line_id}:{step}:{preset}`,只允许 ≤ 1m 的步长;更粗的步长由前端定时重新拉取即可。 +- `overview` 也可以跟随实时:订阅 `overview:{range}:{preset}`,每 10 s 推送一次最新汇总。 +- 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,**典型值为 10~12 s**。1s 步长的点会按批次到达,而不是每秒一个。 + +## 8. 缓存与限额 + +| 请求类型 | 缓存策略 | +| --- | --- | +| 已经结束的绝对区间(`to` 早于当前时间 − 2 分钟) | `Cache-Control: public, max-age=31536000, immutable`,并带 ETag(由参数和指标版本计算) | +| 相对时间段(`to = now`) | `Cache-Control: no-store` | +| 目录(catalog) | `max-age=300`,并带 ETag | + +| 限额 | 默认值 | 超出时 | +| --- | --- | --- | +| 单次响应的点数 | 每条曲线 ≤ 2000 | 422 `range_too_large`,并给出建议的步长 | +| 单次请求的指标数 | ≤ 32 | 422 `too_many_metrics` | +| `compare` 的线路数 | ≤ 20 | 422 | +| 从原始块现算的查询 | 全局最多同时 2 个,区间 ≤ 15 分钟 | 429 `too_many_requests`(带 `Retry-After`)或者降级 | +| 查询超时 | 10 s | 503 | + +## 9. 实现位置 + +```text +Backend.Api/Public/SeriesEndpoints.cs 参数解析、校验、缓存头(不含业务) +Backend.Application/Query/ + ├─ RangeResolver range 或 from/to 与 step → 对齐后的桶序列 + ├─ SourcePlanner 选择数据源(热层 / 10s / 1m / 5m / 1h / 原始块现算),判断是否降级 + ├─ SeriesQuery 读取累加器 → 按步长合并 → 导出统计量 → 列式结果 + ├─ SummaryQuery 整个时间段的精确合并(overview 和 series 共用) + └─ DistributionQuery 合并直方图 → 分桶、CDF、热力图 +Backend.Application/Live/LiveHub 热层环形缓冲(1s,1 小时)+ SSE 主题 +Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码) +``` + +新增指标时,这套查询代码不需要任何改动:`SeriesQuery` 只通过注册表按 id 读取累加器并导出值。 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..5aaf7d2 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,110 @@ +# 里程碑 + +每个里程碑都以“可运行、可验证”作为完成标准;可执行程序在各自的目标 RID 上(见 [project-layout.md](project-layout.md) 第 9 节,Probe 包括 `linux-musl-x64` 和 `linux-musl-arm64`)的 NativeAOT 发布必须通过,并且零 AOT/trim 警告。 + +## M0 骨架 + +- 按 [project-layout.md](project-layout.md) 建立仓库:`backend/` 下的 props、`.editorconfig`、`BannedSymbols.txt` 与 `BannedSymbols.Node.txt`、`backend/fleet/` 骨架、三个 `.slnx`、两个 Monitor 共享库、Backend 的六个项目。 +- CI:Windows/Linux 矩阵、路径过滤、`dotnet format --verify-no-changes`、`scripts/check-deps`、`scripts/check-openapi`、AOT 发布,并记录体积和内存基线。 + +**验收**: +- 三个空程序以 AOT 方式发布并启动; +- 在 Agent 中调用 `Process.Start` 时构建失败; +- 空的 Probe 能在 OpenWrt aarch64 真机或 QEMU 上启动,并完成一次 TLS 握手(验证 musl 与 `libopenssl3`); +- 给出 `linux-musl-arm`(armv7)工具链的验证结论。 + +## F1 Fleet 核心(与 M1 并行) + +- `Fleet.Core`、`Fleet.Identity`、`Fleet.Rpc`、`Fleet.Link`(连接服务端、点对点会话、发件箱、期望状态同步)、`Fleet.Hosting`、`Fleet.Enrollment.Client`、`fleet-enroll`、`Fleet.Server.Enrollment`、`Fleet.Server.Link`、`Fleet.Server.Sqlite`。 +- `samples/EchoNode` 与 `EchoServer`:用不依赖 Monitor 的方式跑通注册 → 认证 → 状态同步 → 发件箱上报 → 断线重连。 + +**验收**: +- `scripts/check-deps` 证明 `backend/fleet/**` 不引用 `Monitor.*`; +- EchoNode 在 Backend 重启、网络中断 10 分钟后能恢复,上报的消息不重复也不丢失(队列未满时); +- 伪造签名、重放 nonce、调用本地未允许的方法,全部被拒绝。 + +## M1 声明与积木(本机回环) + +- `Protocol`:`ParamSpec`、`KindSpec`、自描述 RawChunk 编解码(附黄金字节测试)、JSON-RPC 信封。 +- `Measurement`:Transport(udp/tcp/tls)、Pacer(fixed/ladder)、Payload、Behavior(echo/sink/source/connect/hold)、Sampler(tcp_info),以及 Recorder、`KindAssembler`、`BudgetGuard`。 +- `tools/monitor-bench`:用两个本地进程运行任意 Kind,导出 RawChunk,用来调试引擎。 +- 测试:通过丢包注入中继验证逐包记录的完整性;100 Mbps 下发送节奏误差 < 1%;**只加一条 Kind 声明、不写代码,就能运行一种新的组合**。 + +## M2 Probe ↔ 多个 Agent + +- Probe 与 Agent 之间的签名认证、会话、MAC、重放窗口、限速、源地址漫游;`TaskReconciler`;列的交付与合并;每 10 秒切块。 +- Probe 同时与多个 Agent 维持会话(先用静态列表)。 +- 开发模式:Agent 把 RawChunk 写入本地目录(正式运行时不落盘)。 + +**验收**:一个家宽 Probe 同时连接 3 台以上 VPS,连续运行 24 小时;原始块完整,内存稳定;宽带重拨后能自动恢复。 + +## M3 Backend:注册、RPC、存储 + +- 在 Fleet 之上接入:`IFleetStore` 实现、`TaskSet` 状态提供者(有效任务集的计算与下发)、`RawChunk` 收件箱、`monitor.*` 方法、`monitor.probe.agents` 同步。 +- 段文件与索引、发件箱 ACK、去重、缺口;管理员账号、Cookie 会话、API 令牌;`/api/admin` 中的节点、令牌、Probe 身份、任务管理。 + +**验收**:用一行安装命令部署一台新 VPS,自动注册 → 自动生成线路任务 → Probe 自动连接 → 1 分钟内在 `/api/admin` 中看到它的原始块。整个过程不需要手工配置。 + +## M4 分析层与实时 + +- 累加器、算子、首批 `MetricDef`、`base`/`mc`/`hy2`/`reality` 模型 v1;流式路径与持久路径;降采样;指标指纹与回填。 +- 查询([query.md](query.md)):`RangeResolver`、`SourcePlanner`、1s 热层(1 小时)、`metric_10s`、现场合并、精确汇总;`/overview?range=`、`/series`、`/compare`、`/distribution`、`/heatmap`、`/catalog/*`(包括 presets);SSE 的 `series` 和 `overview` 主题;按需测试与测试结果。 +- **查询验收**:近 5 分钟 1s 步长和近 15 分钟 2s 步长的响应时间 < 50 ms;7 天 15 分钟步长的响应时间 < 200 ms;同一时间段内,汇总值与“从原始块直接计算”的结果一致(直方图分位数误差 ≤ 4.5%)。 + +**验收**: +- **扩展性**:新增一个指标只需一行 `MetricDef`,重启后通过 API 可以看到它,并且 30 天历史已经回填; +- **校准**:在两条线路上实际部署 Hysteria2 和 REALITY 服务并实测,与推算结果对比,把偏差记录进 `docs/calibration.md`。 + +## M5 规模与保留 + +- 用模拟数据验证 15 个 Agent 运行 30 天的情况:存储量、查询延迟(30 天跨度 < 500 ms)、回填耗时、SSE 在 100 个连接下的表现。 +- 保留清理、磁盘占用指标、目标任务(`icmp_echo`、`connect`)与 `allowed_target_cidrs`。 + +## F2 Fleet 更新(在 M6 之前) + +- `Fleet.Update`、`fleet-updater`(systemd 与 Windows 服务适配器、健康信标、回滚、防回滚)、`fleet-release`(CI 签名)、`Fleet.Server.Releases`(通道、灰度、版本汇总)。 + +**验收**: +- 用 EchoNode 和 monitor-agent 两个应用验证“同一个 updater 管理多个产品”; +- 验签失败、sha256 不符、新版本健康检查超时,这三种情况都能正确拒绝或回滚; +- 面板被替换为恶意面板时,节点不会安装未签名的版本。 + +## M6 加固与部署 + +- OpenWrt 打包:procd 脚本、ipk/apk 包、sysupgrade 保留列表、`libopenssl3` 依赖检查;在 aarch64 路由器上跑满 24 小时,并运行一次 `hy2_ladder`(观察 CPU 饱和标记)。 +- 安装脚本的下载和验签(现在的 `install.sh` 从解压好的发布目录安装)、`install.ps1` 和 Windows 服务、`fleet-updater` 的配置。 +- 安全:对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放测试;检查登录限流和 CSRF。 + +## 以后 + +- 告警(Webhook);真实 QUIC 握手积木(依赖 msquic,需要单独评估);pcap/eBPF 采集。 + +## 实现状态(后端与 Agent) + +已实现并有自动化测试(287 个,另有 2 个跳过的测试,其中 1 个需要运行中 NativeAOT 产物): + +- **多 Probe**:Probe 身份由 Backend 生成(创建、轮换密钥、吊销、删除),一条线路是一对(Probe,Agent),Agent 为每个 Probe 会话各起一组流,数据按 `line:` 隔离;Agent 上报连着的 Probe(id、版本、远端地址、时间),管理端可以断开某个 Probe;吊销立即生效。 +- **`tcp_bulk` 原语**:批量传输(fwd / rev、本地码率上限、两端的 tcp_info),评分用持续 1 秒的吞吐峰值。 +- **Agent 主机状态**:只上报汇总数字(没有型号和 IP),实时推给展示页,每分钟存一个样本。 +- **前端**:`web/display`(Agent 卡片总览、线路详情、按 Probe 的线路对比,拖动放大、只提供有数据的时间范围)和 `web/admin`(Agent、Probe、任务、按需测试、系统),共享「纸墨」主题;页面里可以把数据源指到别的后端(API 令牌 + 可选的 CORS 白名单 `Monitor__CorsOrigins`)。 +- **真实线路试跑**:广东移动家宽 → 日本 IIJ 的 VPS(见 deployment.md)。 + +- **F1 的服务端部分**:`Fleet.Core/Identity/Rpc`、`Fleet.Server.Abstractions/Enrollment/Link`(注册、签名认证、期望状态同步、发件箱收件箱、服务端发起的调用)。 +- **M3**:注册、RPC、段文件与 SQLite、任务集下发、管理 API。 +- **M4(除校准外)**:累加器、算子、指标注册制、四个模型、流式与持久两条路径、降采样、指标指纹与回填、查询层(范围、步长、数据分层、精确汇总、列式响应)、SSE、按需测试。 +- **M5 的一部分**:保留清理。 +- **F1 的节点部分**:`Fleet.Link`(链路、发件箱、点对点监听与连接)、`Fleet.Hosting`、`Fleet.Enrollment.Client` 与 `fleet-enroll`。 +- **Agent(M1 的一部分)**:`Monitor.Agent`(NativeAOT 可发布)与 `Monitor.Measurement`(列块合并、预算守卫)。UDP/TCP 数据面(认证、重放窗口、限速、端点学习)、线路任务调和、目标任务(`icmp_echo`、`connect`)、按需测试编排(`udp_train` 由 Agent 发包)。测试里的假 Probe(`ProbeSimulator`)通过真实的点对点握手连接真实的 Agent 与后端,端到端覆盖。详见 `backend/agent/README.md`。 +- **Probe(M0/M2 的一部分)**:`Monitor.Probe`(NativeAOT,5.6 MB,常驻约 22 MB)——Agent 列表(bootstrap → 由 Agent 代为查询 → 推送,写回 `agents.json`)、每个 Agent 一个自动重连的会话、`udp_echo`/`tcp_echo`/`connect`/`hold` 角色、`udp_train` 接收端(按序号范围切块)、时钟偏移估计、有界发件箱。真实 Probe + 真实 Agent + 真实后端的端到端测试,以及用发布的 AOT 二进制做过的冒烟(内存稳定、CPU < 1%)。详见 `backend/probe/README.md`。 +- **采集积木统一(重构)**:Probe 和 Agent 的数据面合并成 `Monitor.Measurement` 里的一份实现(每种采集方法一个文件、拨号方和监听方一对角色,`UdpMux`/`Tcp`/`RowStore` 共用);合并改成由 Kind 的流声明(`Driver`、`Key`)驱动;测试方案移回 Backend;架构测试禁止采集层出现业务概念。 +- **TCP 内核采样与长尾**:`tcp_echo` 两端各自采样套接字的 `TCP_INFO`(Linux,`union` 流拼接);`tcp.rtt` 增加 `p999`、`max`、长尾比 `tail`,新增 `tcp.slow`(块内基线的慢回显占比);序列接口增加 `anchor=latest`。 +- **打包与部署(M6 的一部分)**:`scripts/publish.sh`(linux-musl-x64、linux-x64 在 NativeAOT SDK 容器里构建)、`scripts/package.sh`、`packaging/`(`install.sh`、systemd 单元、OpenWrt procd 脚本)、节点启动时的 OpenSSL 自检。在 Alpine、Debian、OpenWrt 官方 rootfs、带 systemd 的 Debian 容器里验证过。详见 `docs/deployment.md`。 + +未实现: + +- `monitor-bench`(M0/M2);arm64 构建(需要 arm64 主机或 CI)、真正的 OpenWrt 设备;`udp_train` 只支持 `direction=rev`;跨公网线路只试跑过一条(几分钟),还没有长期试跑。 +- 告警;定时调度和按需测试的长期峰值趋势;Windows Probe 没有 tcp_info;Probe 会话结束时最后 ≤10 秒的数据(不修,极端情况)。多个 Probe 同时连时,码率和包速率的预算按单个 Probe 会话计算,总负载是它的倍数,靠会话数上限(默认 8)约束。 +- `fleet-updater`、`fleet-release`、`Fleet.Update`、`Fleet.Server.Releases`、`Fleet.Server.Sqlite`(F2;Monitor 自己实现 `IFleetStore`)。 +- `/api/admin/releases`、`/install.sh`、`/install.ps1`(安装脚本返回 501,依赖打包 M6)。 +- M4 的校准(需要在真实线路上部署 Hysteria2 与 REALITY 对比)和 M5 的 30 天规模验证;模型评分曲线是 v1 的经验值。 +- 查询的 `tz` 只支持 UTC(其他值返回 400);`probe.endpoint_changes` 指标未进入注册表(计数在 Agent 运行时状态里)。 diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..bc6afd1 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,190 @@ +# Backend 存储 + +实现位于 `Monitor.Backend.Storage.Sqlite`,负责实现 `Backend.Application` 中定义的端口接口,**只做读写,不含业务逻辑**。降采样和分析由 Application 调用 Analysis 完成。 + +## 1. 目录布局 + +```text +data/ +├─ monitor.db # SQLite:配置、身份、原始块索引、指标、测试结果 +└─ raw/ + └─ 2026-09-27/ # 按 UTC 日期分目录,保留期满整目录删除 + ├─ agt_01J…/ # 每条线路或每个 Agent 一个目录 + │ ├─ udp_echo.seg # 段文件:同一天、同一 Kind 的块依次追加 + │ ├─ tcp_echo.seg + │ └─ tsk_…_icmp_echo.seg # 目标任务 + └─ tests/tst_01J…/phase0.seg … +``` + +- **段文件**:把收到的 RawChunk 原样追加写入(块本身已经带 crc,并且自描述),SQLite 只记录 `(segment, offset, length)`。每 10 秒一块也不会产生海量小文件。 +- **写入顺序**:追加写入 → `fsync`(每批合并执行一次)→ 插入索引 → 由收件箱确认(Fleet 回复 `fleet.outbox.ack`)。启动时截断没有索引记录的段文件尾部。 +- **去重**:`chunk_id` 已存在时,直接回复 ACK。 + +## 2. SQLite 配置 + +- `journal_mode=WAL`、`synchronous=NORMAL`、`foreign_keys=ON`、`busy_timeout=5000`。 +- **单写入者**:所有写操作经过一个有界 `Channel`,由一个后台循环执行;读操作使用独立的只读连接池。 +- 时间统一存储为 Unix 毫秒 `INTEGER`(UTC);表结构版本记录在 `PRAGMA user_version`,迁移脚本为内置 SQL。 + +## 3. 表结构 + +`agents`、`join_tokens` 以及公钥和状态版本字段,同时作为 Fleet 的 `IFleetStore` 存储(由 `Monitor.Backend.Storage.Sqlite` 实现),和 Monitor 的其他数据放在同一个 `monitor.db` 中,由同一个单写入者写入。发布与灰度相关的表(`fleet_releases`、`fleet_rollouts`)由 `Fleet.Server.Releases` 自带的迁移脚本创建(职责见 [fleet/update.md](fleet/update.md) 第 6 节)。 + +```sql +-- 身份与注册 ------------------------------------------------------------- +CREATE TABLE agents ( + id TEXT PRIMARY KEY, -- agt_ + name TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, -- pending | active | disabled + pubkey BLOB NOT NULL, -- ECDSA P-256 身份公钥 + tls_spki BLOB NOT NULL, -- 面向 Probe 的证书指纹 + public_host TEXT NOT NULL, -- 默认取注册请求的来源 IP,可以修改 + probe_tcp_port INTEGER NOT NULL, probe_udp_ports TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', + public_visible INTEGER NOT NULL DEFAULT 1, -- 是否在 public API 中展示 + kinds TEXT, agent_version TEXT, local_limits TEXT, -- 来自 fleet.hello + task_version INTEGER NOT NULL DEFAULT 0, applied_task_version INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, last_seen_at INTEGER, row_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE join_tokens ( + id TEXT PRIMARY KEY, token_sha256 BLOB NOT NULL UNIQUE, note TEXT, + labels TEXT NOT NULL DEFAULT '[]', auto_approve INTEGER NOT NULL, + max_uses INTEGER NOT NULL, uses INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL, revoked_at INTEGER, created_at INTEGER NOT NULL +); + +CREATE TABLE host_samples ( -- 主机资源占用,每个 Agent 每分钟一行(schema v3) + agent_id TEXT NOT NULL, at INTEGER NOT NULL, + cpu REAL, mem_used INTEGER, mem_total INTEGER, disk_used INTEGER, disk_total INTEGER, + net_rx_bps INTEGER, net_tx_bps INTEGER, uptime INTEGER, + PRIMARY KEY (agent_id, at) +) WITHOUT ROWID; + +CREATE TABLE probes ( -- 每个 Probe 身份一行(吊销只设 revoked_at) + id TEXT PRIMARY KEY, name TEXT NOT NULL, pubkey BLOB NOT NULL, + created_at INTEGER NOT NULL, revoked_at INTEGER +); + +CREATE TABLE users ( + id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, + password_hash BLOB NOT NULL, password_salt BLOB NOT NULL, iterations INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE sessions ( + id_sha256 BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL +); +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, scope TEXT NOT NULL, -- read | admin + token_sha256 BLOB NOT NULL UNIQUE, created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER +); +CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); -- 如 site.public = true + +-- 任务 ------------------------------------------------------------------ +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, -- tsk_ + scope TEXT NOT NULL, -- line | target + agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE, -- 为 NULL 时,是适用于所有 Agent 的 line 模板 + base_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, -- 针对某个 Agent 覆盖模板时,指向模板 + kind TEXT NOT NULL, + target TEXT, -- 仅 target 任务 + params TEXT NOT NULL DEFAULT '{}', + schedule TEXT NOT NULL, -- continuous | every:<分钟> + enabled INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, row_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE tests ( -- 按需测试(once) + id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, plan TEXT NOT NULL, params TEXT NOT NULL, + trigger TEXT NOT NULL, -- api | schedule: + status TEXT NOT NULL, -- queued | running | succeeded | failed | aborted + error_code TEXT, requested_at INTEGER NOT NULL, started_at INTEGER, finished_at INTEGER +); +CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at); + +-- 原始数据索引 ------------------------------------------------------------ +CREATE TABLE raw_segments ( + id INTEGER PRIMARY KEY, day TEXT NOT NULL, path TEXT NOT NULL UNIQUE, bytes INTEGER NOT NULL +); +CREATE TABLE raw_chunks ( + chunk_id TEXT PRIMARY KEY, segment_id INTEGER NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, + agent_id TEXT NOT NULL, series TEXT NOT NULL, -- line | target: + kind TEXT NOT NULL, task_id TEXT, test_id TEXT, phase INTEGER, + start_ts INTEGER NOT NULL, duration_ms INTEGER NOT NULL, partial INTEGER NOT NULL +) WITHOUT ROWID; +CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts); +CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL; + +-- 指标(通用存储:每个桶一行,内容为 指标id → 累加器状态 的映射)---------------- +-- 分层:metric_10s(48 小时)、metric_1m(30 天)、metric_5m(365 天)、metric_1h(永久) +-- 另有 1s 精度的内存热层(最近 1 小时,不落盘),见 query.md 第 3 节 +CREATE TABLE metric_1m ( -- 60 s + agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, + partial INTEGER NOT NULL, + metrics BLOB NOT NULL, -- AccumulatorCodec 编码的 {metric_id: state} + PRIMARY KEY (agent_id, series, window_start) +) WITHOUT ROWID; +-- metric_10s 结构相同(由流式路径直接写入,不经过降采样) +-- metric_5m / metric_1h 结构相同(window_start 换成 bucket_start,另加 windows 计数) + +CREATE TABLE test_results ( + test_id TEXT PRIMARY KEY, metrics BLOB NOT NULL, series BLOB -- series 为逐秒或逐档的曲线 +) WITHOUT ROWID; + +CREATE TABLE metric_versions (metric_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, + recompute_from INTEGER, recompute_cursor INTEGER); + +CREATE TABLE analysis_queue ( + agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, + not_before INTEGER NOT NULL, reason TEXT NOT NULL, -- new | late_chunk | recompute + PRIMARY KEY (agent_id, series, window_start) +) WITHOUT ROWID; +CREATE TABLE rollup_dirty (level INTEGER NOT NULL, agent_id TEXT NOT NULL, series TEXT NOT NULL, + bucket_start INTEGER NOT NULL, PRIMARY KEY (level, agent_id, series, bucket_start)) WITHOUT ROWID; + +-- 事件 ------------------------------------------------------------------ +CREATE TABLE events (id INTEGER PRIMARY KEY, agent_id TEXT, kind TEXT NOT NULL, + at INTEGER NOT NULL, detail TEXT); +CREATE INDEX ix_events_time ON events (at); +``` + +**要点:新增 Kind 或指标都不需要改表结构。** 指标是 `metric_*.metrics` 里的一个键;Kind 只是 `raw_chunks.kind` 的一个取值。 + +## 4. 保留 + +| 数据 | 默认保留 | +| --- | --- | +| 持续任务的原始段文件 | **30 天** | +| 按需测试的原始段文件 | 90 天 | +| 热层(1s,内存) | 最近 1 小时 | +| `metric_10s` | 48 小时 | +| `metric_1m` | 30 天 | +| `metric_5m` | 365 天 | +| `metric_1h`、`tests`、`test_results` | 永久 | +| `events` | 365 天 | + +降采样由 Application 中的 `Downsampler` 负责:读取下一级的累加器状态,用 `AccumulatorCodec.Merge` 合并后写回。所有保留期都可以配置。 + +## 5. 容量估算 + +以 15 个 Agent、默认模板(`udp_echo` 10 Hz + `tcp_echo` 20 Hz)为例: + +| 数据 | 估算 | +| --- | --- | +| 原始数据 | 每条线路约 7 MB/天(udp_echo)+ 14 MB/天(tcp_echo 及内核采样)≈ 21 MB/天;15 条线路约 0.3 GB/天,30 天约 **9.5 GB** | +| 按需测试 | 一次 `hy2_ladder` 约 1.5 MB;每条线路每天 4 次,90 天约 8 GB | +| SQLite | 指标窗口和降采样约 1~2 GB/年 | + +写入量约每秒 3 块,单写入者绰绰有余。M5 阶段会用模拟数据验证这些估算。 + +## 6. 查询 + +时间段、自动步长、数据源选择、现场合并和降级规则见 [query.md](query.md)。 + +## 7. 与实现的差异(以 `Schema.cs` 为准) + +- `agents`:`name` 不再 `UNIQUE`,改为“已建立(非 pending)节点之间唯一”的部分唯一索引,pending 节点可以重名;新增 `task_hash`(有效任务集的哈希,用来判断版本是否要递增)、`rejected`(节点上报的拒绝原因)、`meta`(注册元数据)。 +- `tests`:新增 `phase`、`phase_count`;`test_results` 的 `metrics` / `series` 保存 JSON。 +- `analysis_queue`:新增 `seq`,每次入队递增;完成时只删除序号没变的项,处理期间到达的迟到块不会丢更新。 +- `rollup_dirty`:先处理低层(5 分钟)再处理高层(1 小时)。 diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100644 index 0000000..05d7d73 --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,189 @@ +#!/bin/sh +# Installs monitor-agent or monitor-probe from an extracted release directory (the directory this script lives in). +# POSIX sh: runs on Debian/Ubuntu/Alpine/OpenWrt (busybox). +# +# ./install.sh agent --server https://panel.example.com --join --name hk-1 +# ./install.sh probe [--probe-json /path/to/probe.json] +# ./install.sh uninstall agent|probe [--purge] +# +# Binaries go to /usr/lib/monitor (root-owned: the collector cannot rewrite its own executable); +# config and identity go to /etc/monitor (owned by the unprivileged `monitor` user). +set -eu + +PREFIX=/usr/lib/monitor +CONFIG=/etc/monitor +USER_NAME=monitor +HERE="$(cd "$(dirname "$0")" && pwd)" + +# busybox on OpenWrt has no `install` +put() { cp "$2" "$3" && chmod "$1" "$3"; } +die() { echo "install.sh: $*" >&2; exit 1; } +say() { echo "==> $*"; } + +[ "$(id -u)" -eq 0 ] || die "run as root" + +init_system() { + if [ -d /run/systemd/system ]; then echo systemd + elif [ -f /etc/openwrt_release ]; then echo procd + else echo none; fi +} + +check_openssl() { + for dir in /lib /usr/lib /lib64 /usr/lib64 /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu /lib/x86_64-linux-gnu /lib/aarch64-linux-gnu; do + ls "$dir"/libssl.so.3* >/dev/null 2>&1 && return 0 + done + die "OpenSSL 3 (libssl.so.3) is required. Debian/Ubuntu: apt install libssl3 | Alpine: apk add libssl3 | OpenWrt: opkg install libopenssl3 (newer snapshots: apk add libopenssl3)" +} + +ensure_user() { + id "$USER_NAME" >/dev/null 2>&1 && return 0 + if command -v useradd >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin "$USER_NAME" + elif command -v adduser >/dev/null 2>&1; then + adduser -S -D -H -s /sbin/nologin "$USER_NAME" 2>/dev/null || adduser -S -H -s /bin/false "$USER_NAME" 2>/dev/null || true + fi + if ! id "$USER_NAME" >/dev/null 2>&1 && [ -w /etc/passwd ] && [ -w /etc/group ]; then + # OpenWrt: no useradd/adduser in the default busybox; add a system user by hand + uid=900 + while grep -q "^[^:]*:[^:]*:$uid:" /etc/passwd /etc/group 2>/dev/null; do uid=$((uid + 1)); done + echo "$USER_NAME:x:$uid:" >>/etc/group + echo "$USER_NAME:x:$uid:$uid:$USER_NAME:/var/run/$USER_NAME:/bin/false" >>/etc/passwd + fi + id "$USER_NAME" >/dev/null 2>&1 || { + echo "warning: could not create the '$USER_NAME' user; the service will run as root" >&2 + return 1 + } +} + +install_binary() { + src="$1" + [ -f "$HERE/$src" ] || die "$HERE/$src not found (is this an extracted release?)" + mkdir -p "$PREFIX" + put 0755 "$HERE/$src" "$PREFIX/$src" +} + +write_agent_config() { + cat >"$CONFIG/agent.json" <&2 ;; + esac + + ready=0 + if [ "$role" = agent ]; then + if [ -n "$server" ] && [ ! -f "$CONFIG/node.json" ]; then + [ -n "$join" ] && [ -n "$name" ] || die "--server needs --join and --name to enroll" + say "enrolling as $name" + if [ "$have_user" -eq 1 ]; then + su -s /bin/sh "$USER_NAME" -c "'$PREFIX/fleet-enroll' --server '$server' --join '$join' --name '$name' --identity-dir '$CONFIG'" + else + "$PREFIX/fleet-enroll" --server "$server" --join "$join" --name "$name" --identity-dir "$CONFIG" + fi + fi + if [ ! -f "$CONFIG/agent.json" ] && [ -n "$server" ]; then write_agent_config "$server"; fi + if [ -f "$CONFIG/agent.json" ] && [ -f "$CONFIG/node.json" ]; then ready=1; fi + else + if [ -n "$probe_json" ]; then + put 0600 "$probe_json" "$CONFIG/probe.json" + if [ "$have_user" -eq 1 ]; then chown "$USER_NAME" "$CONFIG/probe.json"; fi + fi + if [ -f "$CONFIG/probe.json" ]; then ready=1; fi + fi + + if [ "$ready" -eq 1 ] && [ "$no_start" -eq 0 ]; then + case "$init" in + systemd) systemctl enable --now "monitor-$role" ;; + procd) /etc/init.d/monitor-probe enable; /etc/init.d/monitor-probe restart ;; + esac + say "monitor-$role is installed" + else + say "installed, not started yet. Next:" + if [ "$role" = agent ]; then + echo " $PREFIX/fleet-enroll --server --join --name --identity-dir $CONFIG (as the '$USER_NAME' user)" + echo " write $CONFIG/agent.json (see README.md), then: systemctl enable --now monitor-agent" + else + echo " save the panel's probe identity (POST /api/admin/probe/identity) as $CONFIG/probe.json (mode 0600, owner $USER_NAME)" + echo " then: systemctl enable --now monitor-probe (OpenWrt: /etc/init.d/monitor-probe enable && /etc/init.d/monitor-probe start)" + fi + fi +} + +uninstall() { + role="$1"; purge=0 + if [ "${2:-}" = "--purge" ]; then purge=1; fi + case "$(init_system)" in + systemd) + systemctl disable --now "monitor-$role" 2>/dev/null || true + rm -f "/etc/systemd/system/monitor-$role.service" + systemctl daemon-reload ;; + procd) + /etc/init.d/monitor-probe stop 2>/dev/null || true + /etc/init.d/monitor-probe disable 2>/dev/null || true + rm -f /etc/init.d/monitor-probe /lib/upgrade/keep.d/monitor ;; + esac + rm -f "$PREFIX/monitor-$role" + if [ "$role" = agent ]; then rm -f "$PREFIX/fleet-enroll"; fi + rmdir "$PREFIX" 2>/dev/null || true + if [ "$purge" -eq 1 ]; then + rm -rf "$CONFIG" + say "removed $CONFIG (identity included: the node must enroll again)" + else + say "kept $CONFIG (use --purge to remove the identity as well)" + fi +} + +cmd="${1:-}" +case "$cmd" in + agent|probe) shift; install_role "$cmd" "$@" ;; + uninstall) + shift + role="${1:-}" + [ "$role" = agent ] || [ "$role" = probe ] || die "usage: install.sh uninstall agent|probe [--purge]" + shift + uninstall "$role" "$@" ;; + *) die "usage: install.sh agent|probe [options] | uninstall agent|probe [--purge]" ;; +esac diff --git a/packaging/openwrt/monitor-probe.init b/packaging/openwrt/monitor-probe.init new file mode 100644 index 0000000..a1a2351 --- /dev/null +++ b/packaging/openwrt/monitor-probe.init @@ -0,0 +1,26 @@ +#!/bin/sh /etc/rc.common +# procd service for monitor-probe (OpenWrt). Installed as /etc/init.d/monitor-probe. + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/lib/monitor/monitor-probe +CONF=/etc/monitor/probe.json + +start_service() { + [ -f "$CONF" ] || { + logger -t monitor-probe "$CONF not found: download it from the panel (POST /api/admin/probe/identity)" + return 1 + } + procd_open_instance + procd_set_param command "$PROG" --config "$CONF" + # respawn: threshold(s) timeout(s) retry (0 = forever) + procd_set_param respawn 3600 5 0 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param limits nofile=4096 + # Run unprivileged when the installer could create the user (busybox adduser may be missing) + id monitor >/dev/null 2>&1 && procd_set_param user monitor + procd_close_instance +} diff --git a/packaging/openwrt/monitor.keep b/packaging/openwrt/monitor.keep new file mode 100644 index 0000000..0e84b1b --- /dev/null +++ b/packaging/openwrt/monitor.keep @@ -0,0 +1 @@ +/etc/monitor/ diff --git a/packaging/systemd/monitor-agent.service b/packaging/systemd/monitor-agent.service new file mode 100644 index 0000000..ebcc774 --- /dev/null +++ b/packaging/systemd/monitor-agent.service @@ -0,0 +1,43 @@ +[Unit] +Description=Monitor agent (network line quality collector) +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=monitor +Group=monitor +ExecStart=/usr/lib/monitor/monitor-agent --config /etc/monitor/agent.json +Restart=always +RestartSec=5 + +# The agent is a pure collector: it reads its config and identity, never writes files, never starts processes. +ReadOnlyPaths=/etc/monitor +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +LockPersonality=yes +# NativeAOT does not JIT, so writable+executable memory is never needed. +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +# CAP_NET_RAW only for ICMP target tasks. Ports below 1024 need CAP_NET_BIND_SERVICE (add it in a drop-in). +CapabilityBoundingSet=CAP_NET_RAW +AmbientCapabilities=CAP_NET_RAW + +MemoryMax=128M +TasksMax=64 +LimitNOFILE=4096 + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/monitor-probe.service b/packaging/systemd/monitor-probe.service new file mode 100644 index 0000000..db90db4 --- /dev/null +++ b/packaging/systemd/monitor-probe.service @@ -0,0 +1,43 @@ +[Unit] +Description=Monitor probe (network line quality measurement origin) +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=monitor +Group=monitor +ExecStart=/usr/lib/monitor/monitor-probe --config /etc/monitor/probe.json +Restart=always +RestartSec=5 + +# The probe is a pure collector: it never starts processes and writes no measurement data. +# The only file the probe writes is agents.json (the latest agent list) next to probe.json. +ReadWritePaths=/etc/monitor +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +LockPersonality=yes +# NativeAOT does not JIT, so writable+executable memory is never needed. +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +# No capabilities needed: the probe only dials out. +CapabilityBoundingSet= + +MemoryMax=64M +TasksMax=64 +LimitNOFILE=4096 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100644 index 0000000..8ab721b --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# 打包:scripts/package.sh → backend/artifacts/packages/monitor---.tar.gz(附 .sha256) +# 内容:节点二进制(agent 还带 fleet-enroll)、systemd 单元、OpenWrt procd 脚本、install.sh。 +# 发布产物要先用 scripts/publish.sh 生成(agent 需要 agent 和 fleet-enroll 两个)。 +set -euo pipefail +role="${1:?usage: package.sh }" +rid="${2:?usage: package.sh }" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="$(sed -n 's#.*\(.*\).*#\1#p' "$root/Directory.Build.props" | head -1)" +name="monitor-$role-$version-$rid" +stage="$root/backend/artifacts/packages/stage/$name" + +rm -rf "$stage" +mkdir -p "$stage/systemd" +cp "$root/backend/artifacts/publish/$role/$rid/monitor-$role" "$stage/" +cp "$root/packaging/install.sh" "$stage/" +cp "$root/packaging/systemd/monitor-$role.service" "$stage/systemd/" +cp "$root/$role/README.md" "$stage/README.md" +if [ "$role" = agent ]; then + cp "$root/backend/artifacts/publish/fleet-enroll/$rid/fleet-enroll" "$stage/" +else + mkdir -p "$stage/openwrt" + cp "$root/packaging/openwrt/"* "$stage/openwrt/" +fi + +mkdir -p "$root/backend/artifacts/packages" +tar -C "$root/backend/artifacts/packages/stage" -czf "$root/backend/artifacts/packages/$name.tar.gz" "$name" +(cd "$root/backend/artifacts/packages" && sha256sum "$name.tar.gz" > "$name.tar.gz.sha256" && ls -l "$name.tar.gz" && cat "$name.tar.gz.sha256") diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 0000000..ab745e4 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# 发布一个节点程序:scripts/publish.sh +# 输出到 backend/artifacts/publish///。linux-musl-x64(Alpine)和 linux-x64(Ubuntu noble,glibc)在 NativeAOT SDK 容器里构建(任何主机都可以,包括 Windows), +# 其他 rid 用本机的 dotnet(NativeAOT 不能交叉编译到别的操作系统,所以 win-x64 要在 Windows 上、linux-x64 要在 glibc 的 Linux 上构建)。 +set -euo pipefail + +project="${1:?usage: publish.sh }" +rid="${2:?usage: publish.sh }" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +case "$project" in + agent) csproj="backend/agent/src/Monitor.Agent" ;; + probe) csproj="backend/probe/src/Monitor.Probe" ;; + backend) csproj="backend/src/Monitor.Backend" ;; + fleet-enroll) csproj="backend/fleet/tools/fleet-enroll" ;; + *) echo "unknown project: $project" >&2; exit 2 ;; +esac + +out="backend/artifacts/publish/$project/$rid" +args=(publish "$csproj" -c Release -r "$rid" -p:PublishAot=true -p:DebugType=none -o "/src/$out" --artifacts-path /artifacts -nologo -v q) + +case "$rid" in + linux-musl-x64) + # SDK 镜像的版本可以比 global.json 新(rollForward = latestFeature) + docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot dotnet "${args[@]}" + ;; + linux-x64) + docker run --rm -v "$root:/src" -v monitor-nuget:/root/.nuget -v "monitor-artifacts-$rid:/artifacts" -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot dotnet "${args[@]}" + ;; + linux-musl-arm64|linux-arm64) + echo "$rid needs an arm64 Alpine builder (native or qemu): run this script on an arm64 host or in CI" >&2 + exit 3 + ;; + *) + cd "$root/backend" # global.json 在 backend/ 下 + dotnet publish "${csproj#backend/}" -c Release -r "$rid" -p:PublishAot=true -o "$root/$out" -nologo -v q + ;; +esac + +ls -l "$root/$out" | sed -n '1,20p' diff --git a/scripts/smoke-linux.sh b/scripts/smoke-linux.sh new file mode 100644 index 0000000..0c64f29 --- /dev/null +++ b/scripts/smoke-linux.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# 在 Linux 容器里用发布出来的 NativeAOT 二进制跑一遍完整链路:后端 → 注册 Agent → Agent → Probe → 数据落库。 +# +# RID=linux-musl-x64 IMAGE=alpine:3.22 (默认) +# RID=linux-x64 IMAGE=debian:bookworm-slim +# MODE=install 用打包产物里的 install.sh 安装(agent 注册、probe 身份、以 monitor 用户运行),而不是直接运行二进制 +# +# 用法(在仓库根目录;Windows 下先 export MSYS_NO_PATHCONV=1;MODE=install 需要先运行 scripts/package.sh): +# docker run --rm -e RID -e MODE -v "$PWD/backend/artifacts:/art:ro" -v "$PWD/scripts/smoke-linux.sh:/smoke.sh:ro" $IMAGE sh /smoke.sh [秒数] +set -eu +seconds="${1:-60}" +rid="${RID:-linux-musl-x64}" +mode="${MODE:-direct}" +pub=/art/publish + +if command -v apk >/dev/null 2>&1; then + apk add --no-cache curl jq libssl3 libstdc++ >/dev/null +else + apt-get update >/dev/null && apt-get install -y --no-install-recommends curl jq ca-certificates libssl3 >/dev/null +fi + +work=/tmp/smoke +mkdir -p "$work/data" "$work/probe" +base=http://127.0.0.1:18080 + +export ASPNETCORE_URLS="$base" Monitor__DataDirectory="$work/data" Monitor__InitialAdminUser=admin \ + Monitor__InitialAdminPassword="correct horse battery" Monitor__SecureCookies=false +(cd "$pub/backend/$rid" && ./monitor-backend >"$work/backend.log" 2>&1 &) +for i in $(seq 1 60); do curl -fs "$base/healthz" >/dev/null 2>&1 && break; sleep 1; done +curl -fs "$base/healthz" >/dev/null || { cat "$work/backend.log"; echo "backend did not start"; exit 1; } +echo "backend up" + +login=$(curl -si -X POST "$base/api/admin/auth/login" -H 'Content-Type: application/json' -d '{"username":"admin","password":"correct horse battery"}') +cookie=$(echo "$login" | grep -i '^set-cookie' | head -1 | sed 's/^[^:]*: //; s/;.*//' | tr -d '\r') +csrf=$(echo "$login" | tail -1 | jq -r .csrf_token) +token=$(curl -fs -X POST "$base/api/admin/api-tokens" -H "Cookie: $cookie" -H "X-CSRF-Token: $csrf" -H 'Content-Type: application/json' -d '{"name":"smoke","scope":"admin"}' | jq -r .plaintext) +auth="Authorization: Bearer $token" +join=$(curl -fs -X POST "$base/api/admin/join-tokens" -H "$auth" -H 'Content-Type: application/json' -d '{"auto_approve":true,"max_uses":1,"expires_in_hours":1}' | jq -r .plaintext) + +if [ "$mode" = install ]; then + # 安装到 /usr/lib/monitor 和 /etc/monitor,以 monitor 用户运行(没有 systemd:自己启动) + mkdir -p /tmp/pkg && for role in agent probe; do tar -C /tmp/pkg -xzf /art/packages/monitor-$role-*-$rid.tar.gz; done + (cd /tmp/pkg/monitor-agent-* && ./install.sh agent --server "$base" --join "$join" --name smoke-agent --no-start) + ls -l /usr/lib/monitor /etc/monitor + run_as() { su -s /bin/sh monitor -c "exec $*"; } + run_as /usr/lib/monitor/monitor-agent --config /etc/monitor/agent.json >"$work/agent.log" 2>&1 & + agent_pid=$! + sleep 3 + curl -fs -X POST "$base/api/admin/probe/identity" -H "$auth" -H 'Content-Type: application/json' -d '{}' >"$work/probe/probe.json" + (cd /tmp/pkg/monitor-probe-* && ./install.sh probe --probe-json "$work/probe/probe.json" --no-start) + run_as /usr/lib/monitor/monitor-probe --config /etc/monitor/probe.json >"$work/probe.log" 2>&1 & + probe_pid=$! +else + mkdir -p "$work/agent-id" + "$pub/fleet-enroll/$rid/fleet-enroll" --server "$base" --join "$join" --name smoke-agent --identity-dir "$work/agent-id" + printf '{"fleet":{"server":"%s","identity_dir":"%s"},"listen":{"bind":"127.0.0.1"}}' "$base" "$work/agent-id" >"$work/agent.json" + "$pub/agent/$rid/monitor-agent" --config "$work/agent.json" >"$work/agent.log" 2>&1 & + agent_pid=$! + sleep 3 + curl -fs -X POST "$base/api/admin/probe/identity" -H "$auth" -H 'Content-Type: application/json' -d '{}' >"$work/probe/probe.json" + "$pub/probe/$rid/monitor-probe" --config "$work/probe/probe.json" >"$work/probe.log" 2>&1 & + probe_pid=$! +fi + +rss() { awk '/VmRSS/ {printf "%.1f", $2/1024}' "/proc/$1/status"; } +i=0 +while [ "$i" -lt "$seconds" ]; do + sleep 10; i=$((i + 10)) + echo "t+${i}s probe=$(rss "$probe_pid")MB agent=$(rss "$agent_pid")MB" +done + +echo "agents: $(curl -fs "$base/api/admin/agents" -H "$auth" | jq -c '[.items[] | {name, status, probe_connected}]')" +echo "chunks: $(curl -fs "$base/api/admin/raw/chunks" -H "$auth" | jq -c '[.items[].kind] | group_by(.) | map({(.[0]): length}) | add')" +agent_id=$(curl -fs "$base/api/admin/agents" -H "$auth" | jq -r '.items[0].id') +echo "tcp summary: $(curl -fs "$base/api/public/lines/$agent_id/series?range=5m&step=1s&metrics=tcp.rtt.p50,tcp.rtt.p999,tcp.rtt.max,tcp.rtt.tail,tcp.slow,tcp.retrans,tcp.srtt.p95" -H "$auth" | jq -c '.summary.metrics')" +echo "--- probe.log"; tail -4 "$work/probe.log" +echo "--- agent.log"; tail -4 "$work/agent.log" diff --git a/scripts/smoke-systemd.sh b/scripts/smoke-systemd.sh new file mode 100644 index 0000000..6954109 --- /dev/null +++ b/scripts/smoke-systemd.sh @@ -0,0 +1,31 @@ +# 在带 systemd 的 Debian 容器里用 install.sh 安装并以 systemd 单元运行(验证沙箱指令与 NativeAOT 兼容)。 +# docker run -d --name sysd --privileged --cgroupns=host -v /sys/fs/cgroup:/sys/fs/cgroup:rw -v "$PWD/backend/artifacts:/art:ro" jrei/systemd-debian:12 +# docker cp scripts/smoke-systemd.sh sysd:/s.sh && docker exec sysd bash /s.sh +set -eu +apt-get update >/dev/null && apt-get install -y --no-install-recommends curl jq ca-certificates libssl3 >/dev/null +base=http://127.0.0.1:18080 +mkdir -p /tmp/data +cd /art/publish/backend/linux-x64 +ASPNETCORE_URLS=$base Monitor__DataDirectory=/tmp/data Monitor__InitialAdminUser=admin Monitor__InitialAdminPassword="correct horse battery" Monitor__SecureCookies=false nohup ./monitor-backend >/tmp/backend.log 2>&1 & +for i in $(seq 1 60); do curl -fs $base/healthz >/dev/null 2>&1 && break; sleep 1; done +login=$(curl -si -X POST $base/api/admin/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"correct horse battery"}') +cookie=$(echo "$login" | grep -i '^set-cookie' | head -1 | sed 's/^[^:]*: //; s/;.*//' | tr -d '\r') +csrf=$(echo "$login" | tail -1 | jq -r .csrf_token) +token=$(curl -fs -X POST $base/api/admin/api-tokens -H "Cookie: $cookie" -H "X-CSRF-Token: $csrf" -H 'Content-Type: application/json' -d '{"name":"t","scope":"admin"}' | jq -r .plaintext) +auth="Authorization: Bearer $token" +join=$(curl -fs -X POST $base/api/admin/join-tokens -H "$auth" -H 'Content-Type: application/json' -d '{"auto_approve":true,"max_uses":1,"expires_in_hours":1}' | jq -r .plaintext) +mkdir -p /tmp/pkg; for r in agent probe; do tar -C /tmp/pkg -xzf /art/packages/monitor-$r-*-linux-x64.tar.gz; done +(cd /tmp/pkg/monitor-agent-* && ./install.sh agent --server $base --join "$join" --name sysd-agent) +sleep 3 +curl -fs -X POST $base/api/admin/probe/identity -H "$auth" -H 'Content-Type: application/json' -d '{}' >/tmp/probe.json +(cd /tmp/pkg/monitor-probe-* && ./install.sh probe --probe-json /tmp/probe.json) +sleep 25 +systemctl is-active monitor-agent monitor-probe +echo "agents: $(curl -fs $base/api/admin/agents -H "$auth" | jq -c '[.items[] | {name, status, probe_connected}]')" +echo "chunks: $(curl -fs $base/api/admin/raw/chunks -H "$auth" | jq -c '[.items[].kind] | length')" +systemctl show monitor-agent -p MemoryCurrent -p NRestarts | tr '\n' ' '; systemctl show monitor-probe -p MemoryCurrent -p NRestarts | tr '\n' ' '; echo +ps -o user,rss,comm -C monitor-agent,monitor-probe +journalctl -u monitor-agent -u monitor-probe --no-pager | tail -8 +echo "--- sandbox check: agent cannot write /etc/monitor"; nsenter -t $(systemctl show monitor-agent -p MainPID --value) -m sh -c 'touch /etc/monitor/x 2>&1; ls /proc/self/status >/dev/null' 2>&1 | head -2 +grep -E "NoNewPrivs|CapEff|Seccomp:" /proc/$(systemctl show monitor-agent -p MainPID --value)/status +grep -E "NoNewPrivs|CapEff|Seccomp:" /proc/$(systemctl show monitor-probe -p MainPID --value)/status diff --git a/scripts/smoke-wsl.py b/scripts/smoke-wsl.py new file mode 100644 index 0000000..10c6d6b --- /dev/null +++ b/scripts/smoke-wsl.py @@ -0,0 +1,77 @@ +"""跨机器的真实链路验证:Windows 上的 Probe(NativeAOT exe)→ WSL2 里的 Agent 和 Backend(真实的虚拟网络、不同的单调时钟原点)。 + +前提:WSL 里有 Debian(root)、tc(netem 注入丢包/延迟/黑洞用);已经用 scripts/publish.sh 发布了 linux-x64 的 agent、backend、fleet-enroll, +并且 Windows 上发布了 probe(dotnet publish backend/probe/src/Monitor.Probe -c Release -r win-x64 -p:PublishAot=true -o backend/artifacts/aot-probe)。 +把 linux-x64 的产物复制到 WSL 的 /root/mon/,在 WSL 里用 setsid -f nohup 启动 monitor-backend(监听 0.0.0.0:29181),再运行本脚本。 +故障注入见 docs/deployment.md 第 3 节。 +""" +import json, os, subprocess, sys, time, urllib.request, urllib.error + +# 要连的是本机的虚拟网络(WSL),不能走环境变量里的 HTTP(S)_PROXY +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + +ROOT = r"C:\Users\JustMe\Desktop\monitor-proj" +PROBE_EXE = os.path.join(ROOT, "backend", "artifacts", "aot-probe", "monitor-probe.exe") +WORK = r"C:\Users\JustMe\AppData\Local\Temp\realnet" +PORT = 29181 + + +def wsl(cmd, check=True): + r = subprocess.run(["wsl", "-d", "Debian", "-u", "root", "-e", "bash", "-c", cmd], capture_output=True, text=True) + if check and r.returncode != 0: + print("WSL FAILED:", cmd, r.stdout, r.stderr) + raise SystemExit(1) + return r.stdout.strip() + + +WSLIP = wsl("ip -4 addr show eth0 | grep -oP '(?<=inet )[0-9.]+'") +BASE = f"http://{WSLIP}:{PORT}" + + +def req(method, path, body=None, headers=None, ctype="application/json"): + data = None if body is None else json.dumps(body).encode() + h = {"Content-Type": ctype} + h.update(headers or {}) + r = urllib.request.Request(BASE + path, data=data, method=method, headers=h) + try: + with OPENER.open(r, timeout=30) as resp: + text = resp.read().decode() + return resp, (json.loads(text) if text else None) + except urllib.error.HTTPError as e: + print("HTTP", e.code, e.read().decode()) + raise + + +def main(): + os.makedirs(WORK, exist_ok=True) + print("wsl ip", WSLIP) + resp, login = req("POST", "/api/admin/auth/login", {"username": "admin", "password": os.environ.get("MONITOR_ADMIN_PASSWORD", "correct horse battery")}) + cookie = resp.headers["Set-Cookie"].split(";")[0] + _, tok = req("POST", "/api/admin/api-tokens", {"name": "net", "scope": "admin"}, {"Cookie": cookie, "X-CSRF-Token": login["csrf_token"]}) + auth = {"Authorization": "Bearer " + tok["plaintext"]} + json.dump({"base": BASE, "token": tok["plaintext"], "wslip": WSLIP}, open(WORK + r"\state.json", "w")) + _, join = req("POST", "/api/admin/join-tokens", {"auto_approve": True, "max_uses": 1, "expires_in_hours": 1}, auth) + + wsl("pkill monitor-agent; cd /root/mon && rm -rf id agent.json") + print(wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join {join['plaintext']} --name wsl-agent --identity-dir /root/mon/id")) + cfg = {"fleet": {"server": f"http://127.0.0.1:{PORT}", "identity_dir": "/root/mon/id"}, "listen": {"bind": "0.0.0.0"}} + wsl("cat > /root/mon/agent.json <<'EOF'\n" + json.dumps(cfg) + "\nEOF") + wsl("cd /root/mon && (setsid nohup ./monitor-agent --config /root/mon/agent.json > agent.log 2>&1 < /dev/null &)") + time.sleep(3) + _, agents = req("GET", "/api/admin/agents", None, auth) + agent = agents["items"][0] + print("agent", agent["id"], agent["status"], "online", agent.get("online")) + # the backend only sees 127.0.0.1 as the agent's address: the panel needs the address the probe can reach + req("PATCH", "/api/admin/agents/" + agent["id"], {"public_host": WSLIP}, auth, "application/merge-patch+json") + _, ident = req("POST", "/api/admin/probes", {"name": "home"}, auth) + print("bootstrap", ident["bootstrap"]) + os.makedirs(WORK + r"\probe", exist_ok=True) + json.dump(ident, open(WORK + r"\probe\probe.json", "w")) + json.dump({"base": BASE, "token": tok["plaintext"], "wslip": WSLIP, "agent_id": agent["id"]}, open(WORK + r"\state.json", "w")) + log = open(WORK + r"\probe.log", "w") + p = subprocess.Popen([PROBE_EXE, "--config", WORK + r"\probe\probe.json"], stdout=log, stderr=subprocess.STDOUT) + open(WORK + r"\probe.pid", "w").write(str(p.pid)) + print("probe pid", p.pid) + + +main() diff --git a/scripts/stability_wsl.py b/scripts/stability_wsl.py new file mode 100644 index 0000000..cf91d09 --- /dev/null +++ b/scripts/stability_wsl.py @@ -0,0 +1,613 @@ +""" +稳定性实测:真实的初始化、正常关闭、崩溃关闭、链路故障与恢复。 + +拓扑:Windows 上的 Probe(NativeAOT exe)→ WSL2 里的 Agent → 同一个 WSL 里的 Backend。 +Probe↔Agent 走真实的虚拟网络(eth0),Agent↔Backend 走 WSL 里的回环(故障用 tc 注入在 lo 上)。 + +前提:WSL 里有 Debian(root)和 tc;已发布 linux-x64 的 agent、backend、fleet-enroll(scripts/publish.sh), +以及 Windows 的 probe(dotnet publish backend/probe/src/Monitor.Probe -c Release -r win-x64 -p:PublishAot=true -o backend/artifacts/aot-probe)。 + +用法:python scripts/stability_wsl.py [场景名 ...] (不带参数运行全部) +场景:init graceful crash probe_link backend_link silent soak +""" +import ctypes +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request + +# 要连的是本机的虚拟网络(WSL),不能走环境变量里的 HTTP(S)_PROXY +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROBE_EXE = os.path.join(ROOT, "backend", "artifacts", "aot-probe", "monitor-probe.exe") +PUB = "/mnt/" + ROOT[0].lower() + ROOT[2:].replace("\\", "/") + "/backend/artifacts/publish" +WORK = os.path.join(os.environ.get("TEMP", "."), "stability") +PORT = 29181 +ADMIN_PASSWORD = os.environ.get("MONITOR_ADMIN_PASSWORD", "correct horse battery") # 测试用后端的初始管理员密码 +RESULTS = [] +HIDDEN = subprocess.CREATE_NO_WINDOW # 所有子进程都不弹窗 + + +# ------------------------------------------------------------------ WSL / process helpers + +def wsl(cmd, check=True, timeout=120): + r = subprocess.run(["wsl", "-d", "Debian", "-u", "root", "-e", "bash", "-c", cmd], capture_output=True, text=True, timeout=timeout, creationflags=HIDDEN) + if check and r.returncode != 0: + raise RuntimeError(f"wsl failed ({r.returncode}): {cmd}\n{r.stdout}\n{r.stderr}") + return r.stdout.strip() + + +WSLIP = wsl("ip -4 addr show eth0 | grep -oP '(?<=inet )[0-9.]+'") +BASE = f"http://{WSLIP}:{PORT}" +TOKEN = "" + +LAUNCHER = r"""#!/bin/sh +# start.sh : run detached, append to .log, record the exit code in .exit +name=$1; shift +rm -f /root/mon/$name.exit +setsid -f sh -c 'cd /root/mon; "$@" >> /root/mon/'$name'.log 2>&1; echo $? > /root/mon/'$name'.exit' sh "$@" < /dev/null +""" + + +def now(): + return time.time() + + +def check(name, ok, detail=""): + RESULTS.append((name, bool(ok), detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" - {detail}" if detail else ""), flush=True) + return ok + + +def wait_until(cond, timeout, step=0.5): + start = now() + while now() - start < timeout: + try: + if cond(): + return now() - start + except Exception: + pass + time.sleep(step) + return None + + +def api(method, path, body=None, ctype="application/json", timeout=15): + headers = {"Content-Type": ctype} + if TOKEN: + headers["Authorization"] = "Bearer " + TOKEN + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers) + with OPENER.open(req, timeout=timeout) as resp: + text = resp.read().decode() + return json.loads(text) if text else None + + +def backend_up(): + try: + OPENER.open(BASE + "/healthz", timeout=2) + return True + except Exception: + return False + + +# ------------------------------------------------------------------ components + +def pids(name): + out = wsl(f"pgrep -x {name} || true") + return [int(x) for x in out.split()] + + +def rss_mb(name): + p = pids(name) + if not p: + return None + return int(wsl(f"ps -o rss= -p {p[0]}")) / 1024 + + +def start_backend(): + wsl(f"cd /root/mon && /root/mon/start.sh backend env ASPNETCORE_URLS=http://0.0.0.0:{PORT} Monitor__DataDirectory=/root/mon/data " + f"Monitor__InitialAdminUser=admin 'Monitor__InitialAdminPassword={ADMIN_PASSWORD}' Monitor__SecureCookies=false ./monitor-backend") + return wait_until(backend_up, 30) + + +def start_agent(): + wsl("/root/mon/start.sh agent ./monitor-agent --config /root/mon/agent.json") + return wait_until(lambda: bool(pids("monitor-agent")), 10) + + +def stop_proc(name, sig="TERM", timeout=30): + """Send a signal and wait for the process to exit; returns (seconds, exit code or None).""" + start = now() + wsl(f"pkill -{sig} -x {name} || true") + gone = wait_until(lambda: not pids(name), timeout, 0.2) + code = wsl(f"cat /root/mon/{name.replace('monitor-', '')}.exit 2>/dev/null || true") + return (now() - start if gone is not None else None), (int(code) if code.strip().lstrip('-').isdigit() else None) + + +def send_ctrl_break(pid): + """ + Ctrl+Break to a process that owns its own (hidden) console: attach to that console, broadcast the event, detach. + Ctrl+Break rather than Ctrl+C: a console process can inherit "ignore Ctrl+C" from its parent (it does under most job runners), + Ctrl+Break cannot be ignored that way. .NET reports it as SIGQUIT. + """ + from ctypes import wintypes + k = ctypes.WinDLL("kernel32", use_last_error=True) + handler = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)(lambda event: True) # swallow the event in this process + k.FreeConsole() + if not k.AttachConsole(pid): + raise OSError(f"AttachConsole({pid}) failed: {ctypes.get_last_error()}") + k.SetConsoleCtrlHandler(handler, True) + k.GenerateConsoleCtrlEvent(1, 0) + time.sleep(0.3) + k.FreeConsole() + k.SetConsoleCtrlHandler(handler, False) + + +class Probe: + def __init__(self): + self.proc = None + self.log = os.path.join(WORK, "probe.log") + + def start(self): + os.makedirs(WORK, exist_ok=True) + handle = open(self.log, "a") + # 自己的隐藏控制台:这样才能从外面给它发 Ctrl+Break + flags = subprocess.CREATE_NO_WINDOW + self.proc = subprocess.Popen([PROBE_EXE, "--config", os.path.join(WORK, "probe", "probe.json")], stdout=handle, stderr=subprocess.STDOUT, creationflags=flags) + return self.proc.pid + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def stop(self, mode="graceful", timeout=20): + if not self.alive(): + return 0.0, self.proc.poll() if self.proc else None + start = now() + if mode == "graceful": + send_ctrl_break(self.proc.pid) + else: + self.proc.kill() + try: + code = self.proc.wait(timeout) + except subprocess.TimeoutExpired: + self.proc.kill() + return None, None + return now() - start, code + + def suspend(self): + """Freeze the process (its TCP connections stay open and go silent, like a hung machine).""" + handle = ctypes.windll.kernel32.OpenProcess(0x0800, False, self.proc.pid) # PROCESS_SUSPEND_RESUME + ctypes.windll.ntdll.NtSuspendProcess(handle) + ctypes.windll.kernel32.CloseHandle(handle) + + def resume(self): + handle = ctypes.windll.kernel32.OpenProcess(0x0800, False, self.proc.pid) + ctypes.windll.ntdll.NtResumeProcess(handle) + ctypes.windll.kernel32.CloseHandle(handle) + + def working_set_mb(self): + if not self.alive(): + return None + out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {self.proc.pid}).WorkingSet64"], capture_output=True, text=True, creationflags=HIDDEN).stdout.strip() + return int(out) / 1048576 if out else None + + def log_text(self): + return open(self.log, encoding="utf-8", errors="replace").read() if os.path.exists(self.log) else "" + + +PROBE = Probe() +AGENT_ID = "" + + +# ------------------------------------------------------------------ environment setup + +def setup(fresh=True): + """Clean state, copy the artifacts, start backend/agent, create the probe identity. The probe is not started.""" + global TOKEN, AGENT_ID + PROBE.stop("kill") + wsl("pkill -9 -x monitor-agent; pkill -9 -x monitor-backend; tc qdisc del dev eth0 ingress 2>/dev/null; tc qdisc del dev eth0 root 2>/dev/null; tc qdisc del dev lo root 2>/dev/null; tc qdisc del dev lo ingress 2>/dev/null; true") + if fresh: + wsl(f"rm -rf /root/mon && mkdir -p /root/mon && cp {PUB}/backend/linux-x64/* {PUB}/agent/linux-x64/* {PUB}/fleet-enroll/linux-x64/* /root/mon/ && chmod +x /root/mon/monitor-* /root/mon/fleet-enroll") + wsl("cat > /root/mon/start.sh <<'EOF'\n" + LAUNCHER + "EOF\nchmod +x /root/mon/start.sh") + if os.path.isdir(WORK): + subprocess.run(["cmd", "/c", "rmdir", "/s", "/q", WORK], capture_output=True, creationflags=HIDDEN) + os.makedirs(os.path.join(WORK, "probe"), exist_ok=True) + assert start_backend() is not None, "backend did not start" + if fresh: + req = urllib.request.Request(BASE + "/api/admin/auth/login", data=json.dumps({"username": "admin", "password": ADMIN_PASSWORD}).encode(), headers={"Content-Type": "application/json"}, method="POST") + with OPENER.open(req, timeout=15) as resp: + cookie = resp.headers["Set-Cookie"].split(";")[0] + csrf = json.loads(resp.read())["csrf_token"] + req = urllib.request.Request(BASE + "/api/admin/api-tokens", data=json.dumps({"name": "stab", "scope": "admin"}).encode(), headers={"Content-Type": "application/json", "Cookie": cookie, "X-CSRF-Token": csrf}, method="POST") + TOKEN = json.loads(OPENER.open(req, timeout=15).read())["plaintext"] + join = api("POST", "/api/admin/join-tokens", {"auto_approve": True, "max_uses": 5, "expires_in_hours": 1})["plaintext"] + wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join {join} --name wsl-agent --identity-dir /root/mon/id") + cfg = {"fleet": {"server": f"http://127.0.0.1:{PORT}", "identity_dir": "/root/mon/id"}, "listen": {"bind": "0.0.0.0"}} + wsl("cat > /root/mon/agent.json <<'EOF'\n" + json.dumps(cfg) + "\nEOF") + assert start_agent() is not None + assert wait_until(lambda: api("GET", "/api/admin/agents")["items"][0]["online"], 20) is not None, "agent did not come online" + agent = api("GET", "/api/admin/agents")["items"][0] + AGENT_ID = agent["id"] + if fresh: + api("PATCH", "/api/admin/agents/" + AGENT_ID, {"public_host": WSLIP}, "application/merge-patch+json") + ident = api("POST", "/api/admin/probes", {"name": "home"}) + json.dump(ident, open(os.path.join(WORK, "probe", "probe.json"), "w")) + PROBE.start() + assert wait_until(agent_view_probe_connected, 30) is not None, "probe did not connect" + + +def agent_view(): + return api("GET", "/api/admin/agents/" + AGENT_ID) + + +def agent_view_probe_connected(): + return agent_view()["probe_connected"] + + +def agent_online(): + return agent_view()["online"] + + +def metrics(rng="2m", names="loss.total,loss.up,loss.down,rtt.p50,tcp.rtt.p50,tcp.rtt.max"): + s = api("GET", f"/api/public/lines/{AGENT_ID}/series?range={rng}&step=1s&metrics={names}") + return s["summary"]["metrics"], s["summary"]["samples"] + + +def gaps(minutes=3): + """Buckets (10 s) in the last N minutes, after the first data, whose udp sample count is below half of the expected 100.""" + s = api("GET", f"/api/public/lines/{AGENT_ID}/series?range={minutes}m&step=10s&metrics=n.udp_echo,n.tcp_echo") + udp = s["values"]["n.udp_echo"] + first = next((i for i, v in enumerate(udp) if v is not None and v >= 50), len(udp)) # buckets before the first data are not gaps + return [i for i, v in enumerate(udp[:-1]) if i > first and (v is None or v < 50)], len(udp) + + +def stored_chunks(): + return len(api("GET", f"/api/admin/raw/chunks?agent_id={AGENT_ID}&limit=1000")["items"]) + + +def healthy(): + try: + return agent_online() and agent_view_probe_connected() + except Exception: + return False + + +def ensure_healthy(timeout=60): + if not backend_up(): + start_backend() + if not pids("monitor-agent"): + start_agent() + if not PROBE.alive(): + PROBE.start() + return wait_until(healthy, timeout) + + +# ------------------------------------------------------------------ scenarios + +def scenario_init(): + print("== init: cold starts in every order, bad configuration") + # 1. everything down, then start in the opposite order: probe -> agent -> backend (each must retry until its peer is up) + PROBE.stop("kill") + wsl("pkill -9 -x monitor-agent; pkill -9 -x monitor-backend; true") + time.sleep(1) + PROBE.start() + time.sleep(3) + start_agent() + time.sleep(3) + t0 = now() + start_backend() + took = wait_until(healthy, 90) + check("probe -> agent -> backend start order converges", took is not None, f"{took:.1f}s after the backend came up" if took else "never became healthy") + chunks_flow = wait_until(lambda: stored_chunks() > 0, 40) + check("data flows after the cold start", chunks_flow is not None) + + # 2. backend first, then agent, then probe (the normal order) after a full stop + PROBE.stop("kill") + wsl("pkill -9 -x monitor-agent; pkill -9 -x monitor-backend; true") + start_backend() + start_agent() + PROBE.start() + took = wait_until(healthy, 60) + check("backend -> agent -> probe start order converges", took is not None, f"{took:.1f}s" if took else "") + + # 3. the probe starts when its agent list is only a stale cache (agents.json wrong): bootstrap is still tried + # 4. bad configuration: clear errors and a non-zero exit code, no crash dump + bad = os.path.join(WORK, "bad.json") + open(bad, "w").write("{ not json") + r = subprocess.run([PROBE_EXE, "--config", bad], capture_output=True, text=True, timeout=20, creationflags=HIDDEN) + check("probe: malformed config exits 1 with a readable error", r.returncode == 1 and "startup failed" in r.stderr + r.stdout, (r.stderr + r.stdout).strip()[:120]) + r = subprocess.run([PROBE_EXE, "--config", os.path.join(WORK, "missing.json")], capture_output=True, text=True, timeout=20, creationflags=HIDDEN) + check("probe: missing config exits 1 with the file name", r.returncode == 1 and "missing.json" in r.stderr + r.stdout, (r.stderr + r.stdout).strip()[:120]) + out = wsl("cd /root/mon && echo '{ not json' > bad.json && ./monitor-agent --config bad.json 2>&1; echo exit=$?", check=False) + check("agent: malformed config exits 1", "exit=1" in out and "startup failed" in out, out.strip()[-120:]) + out = wsl("cd /root/mon && printf '{\"fleet\":{\"server\":\"http://127.0.0.1:1\",\"identity_dir\":\"/nonexistent\"}}' > noid.json && ./monitor-agent --config noid.json 2>&1; echo exit=$?", check=False) + check("agent: no identity says to run fleet-enroll first", "exit=1" in out and "fleet-enroll" in out, out.strip()[-140:]) + out = wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join bogus --name x --identity-dir /tmp/enroll-x 2>&1; echo exit=$?", check=False) + check("fleet-enroll: a bad join token fails clearly (non-zero exit)", "exit=0" not in out, out.strip()[-140:]) + out = wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join bogus --name x --identity-dir /root/mon/id 2>&1; echo exit=$?", check=False) + check("fleet-enroll: refuses to overwrite an existing identity", "exit=0" not in out and "already enrolled" in out, out.strip()[-140:]) + ensure_healthy() + + +def scenario_graceful(): + print("== graceful: SIGTERM / Ctrl+Break of each component, then restart") + ensure_healthy() + # probe (Ctrl+Break in its own console) + secs, code = PROBE.stop("graceful") + check("probe exits on Ctrl+Break within 5 s with code 0", secs is not None and secs < 5 and code == 0, f"{secs and round(secs, 2)}s code={code}") + seen = wait_until(lambda: not agent_view_probe_connected(), 10) + check("backend sees the probe disconnect", seen is not None, f"{seen and round(seen, 1)}s") + PROBE.start() + check("probe restarts and reconnects", wait_until(healthy, 30) is not None) + + # agent + secs, code = stop_proc("monitor-agent", "TERM") + log = wsl("tail -5 /root/mon/agent.log") + check("agent exits on SIGTERM within 5 s with code 0", secs is not None and secs < 5 and code == 0, f"{secs and round(secs, 2)}s code={code}") + check("agent logs a clean stop (no crash trace)", "stopping" in log and "Unhandled" not in log, log.splitlines()[-1][:100] if log else "") + seen = wait_until(lambda: not agent_online(), 10) + check("backend sees the agent go offline", seen is not None, f"{seen and round(seen, 1)}s") + start_agent() + check("agent restarts, reconnects to backend and probe", wait_until(healthy, 40) is not None) + + # backend + secs, code = stop_proc("monitor-backend", "TERM", 30) + log = wsl("tail -5 /root/mon/backend.log") + check("backend exits on SIGTERM within 15 s with code 0", secs is not None and secs < 15 and code == 0, f"{secs and round(secs, 2)}s code={code}") + check("backend log has no unhandled exception", "Unhandled" not in log and "Exception" not in log, log.replace(chr(10), ' | ')[-160:]) + time.sleep(5) + start_backend() + check("backend restarts, agent and probe converge again", wait_until(healthy, 60) is not None) + ensure_healthy() + + +def scenario_crash(): + print("== crash: kill -9 / taskkill /F of each component, data durability") + ensure_healthy() + time.sleep(25) + before = stored_chunks() + + # backend crash: the agent keeps its outbox; after restart nothing acknowledged is lost + t = now() + wsl("pkill -9 -x monitor-backend") + time.sleep(25) # agent outbox accumulates (2-3 chunk rounds) + start_backend() + back = wait_until(healthy, 60) + check("backend kill -9: agent reconnects and everything converges", back is not None, f"{back and round(back, 1)}s after restart") + time.sleep(20) + after = stored_chunks() + g, n = gaps(3) + check("backend kill -9: chunks buffered by the agent are delivered after the restart (no gap)", not g, f"gaps in {n} buckets: {g}; chunks {before}->{after}") + + # agent crash + wsl("pkill -9 -x monitor-agent") + time.sleep(5) + start_agent() + check("agent kill -9: recovers", wait_until(healthy, 40) is not None) + + # probe crash + PROBE.stop("kill") + time.sleep(5) + PROBE.start() + check("probe kill: recovers", wait_until(healthy, 40) is not None) + time.sleep(25) + m, samples = metrics("30s") + check("after all three crashes the line reports correct data (no phantom loss)", (m.get("loss.up") or 0) < 1 and (m.get("loss.down") or 0) < 1, f"loss.up={m.get('loss.up')} loss.down={m.get('loss.down')}") + + # crash in the middle of an on-demand test + test = api("POST", f"/api/admin/lines/{AGENT_ID}/tests", {"plan": "hy2_ladder", "params": {"size": 1250}}) + time.sleep(12) + PROBE.stop("kill") + state = wait_until(lambda: api("GET", "/api/public/tests/" + test["id"])["status"] in ("failed", "aborted"), 30) + final = api("GET", "/api/public/tests/" + test["id"]) + check("probe crash mid-test: the test ends (failed/aborted), it does not hang", state is not None, f"status={final['status']} error={final.get('error_code')}") + PROBE.start() + wait_until(healthy, 40) + test2 = api("POST", f"/api/admin/lines/{AGENT_ID}/tests", {"plan": "hy2_ladder", "params": {"size": 1250}}) + done = wait_until(lambda: api("GET", "/api/public/tests/" + test2["id"])["status"] in ("succeeded", "failed", "aborted"), 90, 2) + check("a new test runs to completion after the crash (the agent is not stuck busy)", done is not None and api("GET", "/api/public/tests/" + test2["id"])["status"] == "succeeded", api("GET", "/api/public/tests/" + test2["id"])["status"]) + ensure_healthy() + + +def netem(device, spec, direction="egress", ports=(7443, 7444)): + """Impair only the data/control ports of the agent (so the rest of WSL is unaffected).""" + clear_netem(device) + if direction == "egress": + wsl(f"tc qdisc add dev {device} root handle 1: prio && tc qdisc add dev {device} parent 1:3 handle 30: netem {spec}") + for p in ports: + wsl(f"tc filter add dev {device} protocol ip parent 1:0 prio 3 u32 match ip sport {p} 0xffff flowid 1:3") + else: + wsl(f"tc qdisc add dev {device} handle ffff: ingress") + for p in ports: + action = "action drop" if spec == "drop" else "" + wsl(f"tc filter add dev {device} parent ffff: protocol ip u32 match ip dport {p} 0xffff {action}") + + +def clear_netem(device): + wsl(f"tc qdisc del dev {device} root 2>/dev/null; tc qdisc del dev {device} ingress 2>/dev/null; true") + + +def scenario_probe_link(): + print("== probe<->agent link: flapping, long blackhole (half-open), reconnect storms") + ensure_healthy() + time.sleep(20) + mem0 = (PROBE.working_set_mb(), rss_mb("monitor-agent")) + + # 1. flapping: 3 s blackholes every 10 s, 8 times + for _ in range(8): + netem("eth0", "drop", "ingress") + time.sleep(3) + clear_netem("eth0") + time.sleep(7) + check("flapping link: probe still connected afterwards", wait_until(healthy, 30) is not None) + + # 2. a 70 s blackhole: both ends must notice or survive, and recover once the path returns + t = now() + netem("eth0", "drop", "ingress") + time.sleep(70) + clear_netem("eth0") + back = wait_until(healthy, 120) + check("70 s blackhole: converges back to healthy", back is not None, f"{back and round(back, 1)}s after the path returned") + time.sleep(35) + m, _ = metrics("30s") + check("after the blackhole the line is measured correctly again", (m.get("loss.total") or 0) < 2, f"loss.total={m.get('loss.total')}") + + # 3. reconnect storm: kill the agent 12 times in a row + for _ in range(12): + wsl("pkill -9 -x monitor-agent; true") + time.sleep(0.5) + start_agent() + time.sleep(2) + check("12 agent restarts in a row: converges", wait_until(healthy, 60) is not None) + time.sleep(30) + g, n = gaps(1) + check("... and the data is flowing again", not g, f"gaps: {g}") + + # 4. restart storm of the probe + for _ in range(8): + PROBE.stop("kill") + time.sleep(0.3) + PROBE.start() + time.sleep(2) + check("8 probe restarts in a row: converges", wait_until(healthy, 60) is not None) + time.sleep(40) + mem1 = (PROBE.working_set_mb(), rss_mb("monitor-agent")) + check("memory stays bounded after the storms", mem1[0] < mem0[0] + 15 and mem1[1] < mem0[1] + 15, f"probe {mem0[0]:.1f}->{mem1[0]:.1f} MB, agent {mem0[1]:.1f}->{mem1[1]:.1f} MB") + ensure_healthy() + + +def scenario_backend_link(): + print("== agent<->backend link: outage buffering, replay, duplicates") + ensure_healthy() + time.sleep(30) + # an outage on loopback (tc on lo: the agent<->backend link) shorter than the buffering capacity + netem("lo", "drop", "ingress", ports=(PORT,)) + t = now() + time.sleep(45) + clear_netem("lo") + check("45 s agent<->backend outage: agent stays up and reconnects", wait_until(agent_online, 90) is not None, f"{now() - t:.0f}s") + time.sleep(35) + g, n = gaps(4) + check("data produced during the outage is replayed from the agent's outbox (no gap)", not g, f"gaps in {n} buckets: {g}") + + # duplicates: chunk ids are idempotent. The count of stored chunks grows by the expected 2 per 10 s, not more + c0 = stored_chunks() + time.sleep(30) + c1 = stored_chunks() + check("steady state: ~2 chunks / 10 s (udp + tcp), no duplicates", 4 <= c1 - c0 <= 8, f"{c1 - c0} chunks in 30 s") + + # the backend is stopped for a while (graceful): the agent keeps collecting, delivers after + stop_proc("monitor-backend", "TERM", 30) + time.sleep(40) + start_backend() + check("backend down 40 s then up: everything converges", wait_until(healthy, 90) is not None) + time.sleep(35) + g, n = gaps(4) + check("... with no gap in the data", not g, f"gaps: {g}") + ensure_healthy() + + +def scenario_silent(): + print("== silent death: a frozen process keeps its sockets open and says nothing (hung machine, lost power)") + ensure_healthy() + time.sleep(10) + + # a frozen probe: the agent must notice and tell the backend, instead of keeping a dead session forever + PROBE.suspend() + t = now() + seen = wait_until(lambda: not agent_view_probe_connected(), 150, 1) + check("frozen probe: the backend learns the probe is gone", seen is not None, f"{seen and round(seen)}s" if seen else "still shown as connected after 150 s") + PROBE.resume() + check("... and when the probe thaws it comes back", wait_until(healthy, 90) is not None) + + # a frozen agent: the probe must drop the session and keep retrying; the backend must mark the agent offline + wsl("pkill -STOP -x monitor-agent") + seen_off = wait_until(lambda: not agent_online(), 150, 1) + check("frozen agent: the backend marks it offline", seen_off is not None, f"{seen_off and round(seen_off)}s" if seen_off else "still online after 150 s") + wsl("pkill -CONT -x monitor-agent") + check("... and when the agent thaws everything converges", wait_until(healthy, 90) is not None) + + # a frozen backend: the agent must notice (WebSocket ping timeout) and reconnect once the backend thaws + wsl("pkill -STOP -x monitor-backend") + time.sleep(70) + wsl("pkill -CONT -x monitor-backend") + check("frozen backend (70 s): converges after it thaws", wait_until(healthy, 90) is not None) + time.sleep(30) + g, n = gaps(3) + check("... and the data produced while it was frozen is delivered", not g, f"gaps: {g}") + ensure_healthy() + + +def scenario_soak(): + print("== soak: 8 minutes with random faults; memory, convergence and data continuity") + ensure_healthy() + time.sleep(20) + mem0 = (PROBE.working_set_mb(), rss_mb("monitor-agent"), rss_mb("monitor-backend")) + import random + rnd = random.Random(7) + end = now() + 8 * 60 + faults = 0 + while now() < end: + fault = rnd.choice(["blackhole", "loss", "agent_kill", "probe_kill", "none", "none"]) + faults += fault != "none" + if fault == "blackhole": + netem("eth0", "drop", "ingress") + time.sleep(rnd.uniform(1, 6)) + clear_netem("eth0") + elif fault == "loss": + netem("eth0", "delay 20ms 5ms loss 5%", "egress") + time.sleep(rnd.uniform(5, 12)) + clear_netem("eth0") + elif fault == "agent_kill": + wsl("pkill -9 -x monitor-agent; true") + time.sleep(1) + start_agent() + elif fault == "probe_kill": + PROBE.stop("kill") + time.sleep(0.5) + PROBE.start() + time.sleep(rnd.uniform(8, 20)) + clear_netem("eth0") + check(f"after {faults} random faults everything converges", ensure_healthy(90) is not None) + time.sleep(45) + mem1 = (PROBE.working_set_mb(), rss_mb("monitor-agent"), rss_mb("monitor-backend")) + check("memory bounded", mem1[0] < mem0[0] + 15 and mem1[1] < mem0[1] + 20 and mem1[2] < mem0[2] + 40, f"probe {mem0[0]:.0f}->{mem1[0]:.0f}, agent {mem0[1]:.0f}->{mem1[1]:.0f}, backend {mem0[2]:.0f}->{mem1[2]:.0f} MB") + m, _ = metrics("30s") + check("the final minute is measured correctly", (m.get("loss.up") or 0) < 1 and (m.get("loss.down") or 0) < 1, f"{m.get('loss.up')} {m.get('loss.down')}") + + +SCENARIOS = { + "init": scenario_init, + "graceful": scenario_graceful, + "crash": scenario_crash, + "probe_link": scenario_probe_link, + "backend_link": scenario_backend_link, + "silent": scenario_silent, + "soak": scenario_soak, +} + + +def main(): + names = sys.argv[1:] or list(SCENARIOS) + print(f"wsl ip {WSLIP}") + setup(fresh=True) + for name in names: + try: + SCENARIOS[name]() + except Exception as ex: + check(f"{name} ran to completion", False, repr(ex)[:300]) + PROBE.stop("kill") + wsl("pkill -9 -x monitor-agent; pkill -9 -x monitor-backend; tc qdisc del dev eth0 root 2>/dev/null; tc qdisc del dev eth0 ingress 2>/dev/null; tc qdisc del dev lo ingress 2>/dev/null; true") + failed = [r for r in RESULTS if not r[1]] + print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed") + for name, _, detail in failed: + print(f"FAILED: {name} - {detail}") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..f73d5f9 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +# 复制成 .env.local:本机开发时前端把 /api 转发到哪个后端(可以是线上的公网地址) +BACKEND_URL=https://mo.oeo.one diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..8d06fe9 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env.local diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..20b9fe0 --- /dev/null +++ b/web/README.md @@ -0,0 +1,27 @@ +# 前端 + +两个独立的 Vite + React + TypeScript 工程,共享 `shared/`(主题令牌、API 客户端与类型、图表、通用组件)。 + +| 目录 | 内容 | 访问 | +| --- | --- | --- | +| `display/` | 展示端:Agent 卡片总览(线路质量 + 主机资源占用,实时刷新)、线路详情(延迟、丢包、抖动、TCP、UDP、评分、历史)、线路对比 | `/`,匿名可看;站点没有公开时提示登录 | +| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、Probe(创建、轮换密钥、吊销、删除、连接)、任务、按需测试、系统(设置、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 | +| `shared/` | `theme/`(「纸墨」主题令牌和基础样式)、`api/`(客户端、从 OpenAPI 生成的类型)、`charts/`(uPlot 封装)、`ui/`(通用组件) | | + +```sh +npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces) +npm run dev # 两个一起启动 +BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/ +BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/ +# 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display +npm run build # 各自输出到 display/dist 和 admin/dist +npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts +npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌 +``` + +连别的后端(本机跑前端、连线上的):点页面右上角(管理端登录页右下角)的插头图标,填后端地址和 API 令牌(展示端:站点私有时需要只读令牌;管理端:admin 范围的令牌),保存后刷新。设置存在浏览器里;跨域时不带 Cookie,所以后端要设 `Monitor__CorsOrigins=<这个前端的来源>`(如 `http://localhost:5173`)。也可以用构建时的 `VITE_API_BASE` 给默认地址。 + +约定: +- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 +- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 +- 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。 diff --git a/web/admin/index.html b/web/admin/index.html new file mode 100644 index 0000000..bad5a5c --- /dev/null +++ b/web/admin/index.html @@ -0,0 +1,12 @@ + + + + + + 线路质量 · 管理 + + +
+ + + diff --git a/web/admin/package.json b/web/admin/package.json new file mode 100644 index 0000000..006f82d --- /dev/null +++ b/web/admin/package.json @@ -0,0 +1,28 @@ +{ + "name": "monitor-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\"" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "stylelint": "^16.12.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx new file mode 100644 index 0000000..e241a18 --- /dev/null +++ b/web/admin/src/App.tsx @@ -0,0 +1,134 @@ +import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react' +import { useState, type FormEvent } from 'react' +import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { isRemote } from '@shared/api/source' +import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' +import { useAuth } from './auth' +import { AgentDetail } from './pages/AgentDetail' +import { Agents } from './pages/Agents' +import { Probes } from './pages/Probes' +import { System } from './pages/System' +import { Tasks } from './pages/Tasks' +import { Tests } from './pages/Tests' + +function Login() { + const { login, loginWithToken } = useAuth() + const remote = isRemote() + const [token, setTokenText] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async (e: FormEvent) => { + e.preventDefault() + setBusy(true) + setError('') + try { + if (remote) await loginWithToken(token) + else await login(username, password) + } catch (err) { + setError(describeError(err)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

线路质量

+

管理端

+
+ {remote ? ( + + setTokenText(e.target.value)} autoComplete="off" autoFocus required /> + + ) : ( + <> + + setUsername(e.target.value)} autoComplete="username" autoFocus required /> + + + setPassword(e.target.value)} autoComplete="current-password" required /> + + + )} + {error ?
{error}
: null} + +
+
+ +
+
+ ) +} + +const NAV = [ + { section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] }, + { section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] }, + { section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] }, +] + +export function App() { + const { me, ready, logout } = useAuth() + if (!ready) { + return ( +
+ +
+ ) + } + if (!me) return + + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/web/admin/src/admin.css b/web/admin/src/admin.css new file mode 100644 index 0000000..99e0e55 --- /dev/null +++ b/web/admin/src/admin.css @@ -0,0 +1,194 @@ +/* 管理端:左侧导航 + 内容区(M3 风格:选中项是胶囊,内容区是圆角卡片) */ +.shell { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100%; +} + +.side { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 12px; + background: var(--app-card-inset); + overflow: auto; +} + +.side .brand { + padding: 8px 14px 16px; + font-size: 18px; + font-weight: 700; + color: var(--app-ink); + display: block; +} + +.side .brand:hover { + text-decoration: none; +} + +.side .section { + padding: 16px 14px 4px; + font-size: 12px; + color: var(--app-ink-faint); +} + +.side a.nav { + display: flex; + align-items: center; + gap: 12px; + height: 44px; + padding: 0 14px; + border-radius: var(--r-pill); + color: var(--app-ink); + font-weight: 500; +} + +.side a.nav:hover { + text-decoration: none; + background: color-mix(in srgb, var(--app-ink), transparent calc((1 - var(--state-hover)) * 100%)); +} + +.side a.nav.active { + background: var(--app-accent-container); + color: var(--app-on-accent-container); +} + +.side .spacer { + flex: 1; +} + +.me { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--r-card); + background: var(--app-card); +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--app-accent); + color: var(--app-card); + display: grid; + place-items: center; + font-weight: 600; +} + +.main { + padding: 24px 32px 64px; + min-width: 0; +} + +.main > header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.main > header h1 { + font-size: 24px; + font-weight: 700; +} + +.login { + min-height: 100%; + display: grid; + place-items: center; + padding: 24px; +} + +.login .card { + width: min(400px, 100%); + padding: 32px; +} + +.login h1 { + font-size: 28px; + font-weight: 800; + margin-bottom: 4px; +} + +.kv { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 8px 16px; +} + +.kv dt { + color: var(--app-ink-faint); +} + +.kv dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.form-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-grid .wide { + grid-column: 1 / -1; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.switch input { + accent-color: var(--app-accent); + width: 18px; + height: 18px; +} + +.test-status { + text-transform: none; +} + +@media (max-width: 900px) { + .shell { + grid-template-columns: minmax(0, 1fr); + } + + .side { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } + + .side .section, + .side .spacer, + .me .grow { + display: none; + } + + .main { + padding: 16px; + } + + .form-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx new file mode 100644 index 0000000..ba2ce92 --- /dev/null +++ b/web/admin/src/auth.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' +import { api, ApiError, setCsrf } from '@shared/api/client' +import { isRemote, setToken } from '@shared/api/source' +import type { LoginResponse, MeDto } from '@shared/api/types' + +interface Auth { + me: MeDto | null + ready: boolean + login: (username: string, password: string) => Promise + /** 连别的后端时用 API 令牌登录(不用 Cookie)。 */ + loginWithToken: (token: string) => Promise + logout: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [me, setMe] = useState(null) + const [ready, setReady] = useState(false) + + useEffect(() => { + api + .get('/api/admin/auth/me') + .then((m) => { + setCsrf(m.csrf_token ?? null) + setMe(m) + }) + .catch((e: unknown) => { + if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e) + }) + .finally(() => setReady(true)) + }, []) + + const login = useCallback(async (username: string, password: string) => { + const r = await api.post('/api/admin/auth/login', { username, password }) + setCsrf(r.csrf_token) + setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token }) + }, []) + + const loginWithToken = useCallback(async (token: string) => { + setToken(token) + try { + const m = await api.get('/api/admin/auth/me') + setCsrf(m.csrf_token ?? null) + setMe(m) + } catch (e) { + setToken('') + throw e + } + }, []) + + const logout = useCallback(async () => { + try { + if (isRemote()) setToken('') + else await api.post('/api/admin/auth/logout') + } finally { + setCsrf(null) + setMe(null) + } + }, []) + + return {children} +} + +export function useAuth(): Auth { + const value = useContext(AuthContext) + if (!value) throw new Error('AuthProvider is missing') + return value +} diff --git a/web/admin/src/main.tsx b/web/admin/src/main.tsx new file mode 100644 index 0000000..a2c04ad --- /dev/null +++ b/web/admin/src/main.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import '@shared/theme/base.css' +import { configureSource } from '@shared/api/source' +import { applyStoredTheme } from '@shared/hooks' +import { ToastProvider } from '@shared/ui' +import { App } from './App' +import { AuthProvider } from './auth' +import './admin.css' + +configureSource('admin') +applyStoredTheme() + +createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx new file mode 100644 index 0000000..92d25bc --- /dev/null +++ b/web/admin/src/pages/AgentDetail.tsx @@ -0,0 +1,323 @@ +import { ChevronLeft, FlaskConical, Pencil, Unplug } from 'lucide-react' +import { useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { AgentDto, EventDto, Page, ProbeDto } from '@shared/api/types' +import { fmtAgo, fmtNum, fmtTime } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' +import { AgentStatus } from './Agents' + +function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) { + const { busy, run } = useAction() + const [name, setName] = useState(agent.name) + const [labels, setLabels] = useState(agent.labels.join(', ')) + const [host, setHost] = useState(agent.public_host) + const [visible, setVisible] = useState(agent.public_visible) + return ( + + + + + } + > +
+
+ + setName(e.target.value)} /> + +
+
+ + setHost(e.target.value)} /> + +
+
+ + setLabels(e.target.value)} /> + +
+
+ +
+
+
+ ) +} + +type Action = 'approve' | 'disable' | 'enable' | 'takeover' | 'delete' + +const ACTIONS: Record = { + approve: { label: '审批通过', text: '审批之后,这个 Agent 会被启用并加入所有 Probe 的列表。' }, + enable: { label: '启用', text: '启用之后,这个 Agent 会重新加入 Probe 的列表。' }, + disable: { label: '停用', text: '停用之后,Agent 的连接会被断开,它也会从 Probe 的列表里消失。数据保留。', danger: true }, + takeover: { label: '接管同名节点', text: '用这个待审批的节点替换同名的旧节点,并保留旧节点的历史数据。', danger: true }, + delete: { label: '删除', text: '删除 Agent。已经入库的数据按保留策略自然过期。', danger: true }, +} + +export function AgentDetail() { + const { id = '' } = useParams() + const navigate = useNavigate() + const agent = useQuery(() => api.get(`/api/admin/agents/${id}`), [id], 5000) + const probes = useQuery(() => api.get>('/api/admin/probes'), []) + const events = useQuery(() => api.get>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000) + const [editing, setEditing] = useState(false) + const [pending, setPending] = useState() + const { run } = useAction() + + const a = agent.data + const probeName = (probeId: string) => probes.data?.items.find((p) => p.id === probeId)?.name ?? probeId + + const act = async (action: Action) => { + if (action === 'delete') { + const ok = await run(() => api.del(`/api/admin/agents/${id}`), '已删除') + if (ok) navigate('/agents') + } else { + await run(() => api.post(`/api/admin/agents/${id}/${action}`), '已完成') + agent.reload() + } + } + + return ( + <> + + + Agent + +
+

{a?.name ?? id}

+ {a ? : null} + + {agent.loading && !a ? : null} + {a ? ( + <> + + {a.status === 'pending' ? ( + <> + + + + ) : a.status === 'disabled' ? ( + + ) : ( + + )} + + + ) : null} + +
+ + + + {a ? ( +
+
+
连着的 Probe({a.probes.length})
+ {a.probes.length === 0 ? ( + 现在没有 Probe 连着这个 Agent + ) : ( + + + + + + + + + + + {a.probes.map((p) => ( + + + + + + + + ))} + +
Probe版本来源地址连接时间 +
+
{probeName(p.probe_id)}
+
{p.probe_id}
+
{p.version ?? '—'}{p.remote ?? '—'} + {fmtTime(p.connected_since)}({fmtAgo(p.connected_since)}) + + + + + 测试 + + + +
+ )} +
+ +
+
+
基本信息
+
+
ID
+
{a.id}
+
公开地址
+
{a.public_host || '—'}
+
控制端口
+
{a.probe_tcp_port}/tcp
+
数据端口
+
{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}
+
版本
+
{a.agent_version ?? '—'}
+
展示页可见
+
{a.public_visible ? '是' : '否'}
+
注册时间
+
{fmtTime(a.created_at)}
+
最后在线
+
{fmtTime(a.last_seen_at)}
+
标签
+
+
+ {a.labels.length ? a.labels.map((x) => {x}) : —} +
+
+
支持的采集
+
+
+ {a.kinds.map((x) => ( + {x} + ))} +
+
+
+
+
+
运行状态
+
+
任务同步
+
+ 期望版本 {a.task_version},已应用 {a.applied_task_version} + {a.task_version !== a.applied_task_version ? 未同步 : 已同步} +
+ {a.rejected.length ? ( + <> +
被拒绝的任务
+
+ {a.rejected.map((r) => ( +
+ {r.id} {r.reason} +
+ ))} +
+ + ) : null} +
NAT 端点变化
+
{a.endpoint_changes} 次
+ {a.heartbeat ? ( + <> +
心跳
+
{fmtAgo(a.heartbeat.at)}
+
收发包速率
+
+ 入 {fmtNum(a.heartbeat.pps_in)} pps / 出 {fmtNum(a.heartbeat.pps_out)} pps +
+
丢弃(无效 / 限速)
+
+ {fmtNum(a.heartbeat.dropped_invalid)} / {fmtNum(a.heartbeat.dropped_rate_limited)} +
+
待上报队列
+
{fmtNum(a.heartbeat.queue_bytes / 1024)} KiB
+ + ) : null} + {a.local_limits ? ( + <> +
本地上限
+
+ {fmtNum(a.local_limits.max_total_mbps)} Mbit/s,{fmtNum(a.local_limits.max_total_pps)} pps,{a.local_limits.max_concurrent_tests} 个并发测试 +
+ + ) : null} +
+
+
+ +
+
最近事件
+ {(events.data?.items ?? []).length === 0 ? ( + 没有事件 + ) : ( + + + {events.data?.items.map((e) => ( + + + + + + ))} + +
+ {fmtTime(e.at)} + {e.kind}{e.detail ?? ''}
+ )} +
+
+ ) : null} + + {editing && a ? setEditing(false)} onSaved={agent.reload} /> : null} + {pending ? ( + setPending(undefined)} onConfirm={() => act(pending)}> + {ACTIONS[pending].text} + + ) : null} + + ) +} diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx new file mode 100644 index 0000000..ce45475 --- /dev/null +++ b/web/admin/src/pages/Agents.tsx @@ -0,0 +1,276 @@ +import { Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types' +import { fmtAgo, fmtTime } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' + +export const STATUS: Record = { + active: { label: '已启用', tone: 'ok' }, + pending: { label: '待审批', tone: 'warn' }, + disabled: { label: '已停用', tone: 'bad' }, +} + +export function AgentStatus({ agent }: { agent: Pick }) { + const s = STATUS[agent.status] ?? { label: agent.status, tone: 'warn' as const } + return ( + + + {s.label} + + ) +} + +function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const { busy, run } = useAction() + const [note, setNote] = useState('') + const [labels, setLabels] = useState('') + const [autoApprove, setAutoApprove] = useState(true) + const [maxUses, setMaxUses] = useState(1) + const [hours, setHours] = useState(24) + const [created, setCreated] = useState() + + if (created) { + return ( + + 完成 + + } + > +
+
令牌明文只显示这一次,请现在就复制。
+ +
{created.install_command}
+
+ +
+
+ +
{created.plaintext}
+
+ +
+
+

+ {created.token.auto_approve ? '使用这个令牌注册的 Agent 会自动启用。' : '使用这个令牌注册的 Agent 需要你在列表里审批之后才会启用。'} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。 +

+
+
+ ) + } + + return ( + + + + + } + > +

+ 添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。 +

+
+
+ + setNote(e.target.value)} placeholder="HK-1" /> + +
+
+ + setLabels(e.target.value)} placeholder="hk, cn2" /> + +
+ + setMaxUses(Number(e.target.value))} /> + + + setHours(Number(e.target.value))} /> + +
+ +
+
+
+ ) +} + +export function Agents() { + const navigate = useNavigate() + const agents = useQuery(() => api.get>('/api/admin/agents'), [], 10_000) + const tokens = useQuery(() => api.get>('/api/admin/join-tokens'), []) + const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all') + const [adding, setAdding] = useState(false) + const [revoking, setRevoking] = useState() + const { run } = useAction() + + const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter) + const pending = (agents.data?.items ?? []).filter((a) => a.status === 'pending').length + + return ( + <> +
+

Agent

+ + {agents.loading && !agents.data ? : null} + + +
+ + + +
+ +
+ {list.length === 0 ? ( + {agents.data ? '没有符合条件的 Agent。点击右上角「添加 Agent」创建加入令牌。' : '加载中…'} + ) : ( + + + + + + + + + + + + + + {list.map((a) => ( + navigate(`/agents/${a.id}`)}> + + + + + + + + + ))} + +
状态名称地址版本连着的 Probe最后在线标签
+ + {a.name}{a.public_host || '—'}{a.agent_version ?? '—'} + 0 ? 'ok' : ''}>{a.probes.length} 个 + {fmtAgo(a.last_seen_at)} +
+ {a.labels.map((x) => ( + {x} + ))} +
+
+ )} +
+ +
+
加入令牌
+ {(tokens.data?.items ?? []).length === 0 ? ( + 没有加入令牌 + ) : ( + + + + + + + + + + + + {tokens.data?.items.map((t) => { + const expired = Date.parse(t.expires_at) < Date.now() + const state = t.revoked_at ? '已吊销' : expired ? '已过期' : t.uses >= t.max_uses ? '已用完' : '可用' + return ( + + + + + + + + + ) + })} + +
备注使用注册后过期状态 +
{t.note || —} + {t.uses} / {t.max_uses} + {t.auto_approve ? '自动启用' : '需要审批'}{fmtTime(t.expires_at)} + {state} + + {state === '可用' ? ( + + ) : null} +
+ )} +
+
+ + {adding ? setAdding(false)} onCreated={tokens.reload} /> : null} + {revoking ? ( + setRevoking(undefined)} + onConfirm={async () => { + await run(() => api.del(`/api/admin/join-tokens/${revoking.id}`), '已吊销') + tokens.reload() + }} + > + 吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。 + + ) : null} + + ) +} diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx new file mode 100644 index 0000000..bafa092 --- /dev/null +++ b/web/admin/src/pages/Probes.tsx @@ -0,0 +1,245 @@ +import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types' +import { fmtAgo, fmtTime } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' + +/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */ +function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) { + const json = JSON.stringify(identity, null, 2) + return ( + + 我已保存 + + } + > +
+
私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。
+
+ + +
+ +
{`monitor-probe --config /etc/monitor/probe.json`}
+
+
+ + 查看内容 + +
+            {json}
+          
+
+
+
+ ) +} + +function NameDialog({ title, initial, onClose, onSubmit }: { title: string; initial: string; onClose: () => void; onSubmit: (name: string) => Promise }) { + const [name, setName] = useState(initial) + const [busy, setBusy] = useState(false) + return ( + + + + + } + > + + setName(e.target.value)} autoFocus /> + + + ) +} + +type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto } + +export function Probes() { + const probes = useQuery(() => api.get>('/api/admin/probes'), [], 8000) + const { run } = useAction() + const [creating, setCreating] = useState(false) + const [renaming, setRenaming] = useState() + const [pending, setPending] = useState() + const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>() + + const list = probes.data?.items ?? [] + + const confirmText = { + rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' }, + revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' }, + delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' }, + } + + return ( + <> +
+

Probe

+ + {probes.loading && !probes.data ? : null} + + +
+ + + +
+ {probes.data && list.length === 0 ? ( +
+ 还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。 +
+ ) : null} + {list.map((p) => ( +
+
+ {p.name} + {p.revoked ? 已吊销 : 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}} + {p.id} + + + {!p.revoked ? ( + <> + + + + ) : null} + + +
+

+ 创建于 {fmtTime(p.created_at)} + {p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''} +

+ {p.connections.length > 0 ? ( + + + + + + + + + + + {p.connections.map((c) => ( + + + + + + + ))} + +
Agent版本来源地址连接时间
+ + {c.agent_name} + + {c.version ?? '—'}{c.remote ?? '—'} + {fmtTime(c.connected_since)}({fmtAgo(c.connected_since)}) +
+ ) : null} +
+ ))} +
+ + {creating ? ( + setCreating(false)} + onSubmit={async (name) => { + let created: ProbeIdentityDto | undefined + const ok = await run(async () => { + created = await api.post('/api/admin/probes', { name }) + }) + if (ok && created) { + setIdentity({ title: `Probe ${name} 已创建`, value: created }) + probes.reload() + } + return ok + }} + /> + ) : null} + {renaming ? ( + setRenaming(undefined)} + onSubmit={async (name) => { + const ok = await run(() => api.patch(`/api/admin/probes/${renaming.id}`, { name }), '已保存') + probes.reload() + return ok + }} + /> + ) : null} + {pending ? ( + setPending(undefined)} + onConfirm={async () => { + const { kind, probe } = pending + if (kind === 'rotate') { + let rotated: ProbeIdentityDto | undefined + const ok = await run(async () => { + rotated = await api.post(`/api/admin/probes/${probe.id}/rotate`) + }) + if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated }) + } else if (kind === 'revoke') { + await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销') + } else { + await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除') + } + probes.reload() + }} + > + {confirmText[pending.kind].text} + + ) : null} + {identity ? setIdentity(undefined)} /> : null} + + ) +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx new file mode 100644 index 0000000..c221e05 --- /dev/null +++ b/web/admin/src/pages/System.tsx @@ -0,0 +1,434 @@ +import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { api } from '@shared/api/client' +import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import { fmtNum, fmtTime } from '@shared/format' +import { translateSetting } from '@shared/i18n' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' + +type Tab = 'settings' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' + +function Settings() { + const settings = useQuery(() => api.get>('/api/admin/settings'), []) + const { busy, run } = useAction() + const [draft, setDraft] = useState>({}) + useEffect(() => setDraft({}), [settings.data]) + + const items = settings.data?.items ?? [] + const dirty = Object.keys(draft).length > 0 + return ( +
+ + + + + + + + + + + {items.map((s) => { + const value = draft[s.key] ?? s.value + return ( + + + + + + ) + })} + +
设置值说明
{s.key} + {s.type === 'bool' ? ( + + ) : ( + setDraft({ ...draft, [s.key]: e.target.value })} /> + )} + + {translateSetting(s.key, s.description)} + {s.value !== s.default ? (默认 {s.default}) : null} +
+
+ + 「site.public」打开之后,展示页对匿名访客公开。 +
+
+ ) +} + +function Tokens() { + const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) + const { run } = useAction() + const [creating, setCreating] = useState(false) + const [name, setName] = useState('') + const [scope, setScope] = useState<'read' | 'admin'>('read') + const [created, setCreated] = useState() + const [revoking, setRevoking] = useState() + return ( +
+
+ 给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。 + +
+ + {(tokens.data?.items ?? []).length === 0 ? ( + 没有 API 令牌 + ) : ( + + + + + + + + + + + {tokens.data?.items.map((t) => ( + + + + + + + + ))} + +
名称权限创建最后使用 +
{t.name} + {t.scope} + {fmtTime(t.created_at)}{t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} + {!t.revoked_at ? ( + + ) : null} +
+ )} + {creating ? ( + setCreating(false)} + actions={ + <> + + + + } + > +
+ + setName(e.target.value)} autoFocus /> + + + + +
+
+ ) : null} + {created ? ( + setCreated(undefined)} actions={}> +
+
令牌明文只显示这一次,请现在就复制。
+
{created.plaintext}
+
+ +
+
+
+ ) : null} + {revoking ? ( + setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> + 使用「{revoking.name}」的程序会立刻失去访问权限。 + + ) : null} +
+ ) +} + +function Users() { + const users = useQuery(() => api.get>('/api/admin/users'), []) + const { run } = useAction() + const [dialog, setDialog] = useState<{ user?: UserDto }>() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + return ( +
+
+ 能登录管理端的账号。 + +
+ + + + + + + + + + {users.data?.items.map((u) => ( + + + + + + ))} + +
用户名创建 +
{u.username}{fmtTime(u.created_at)} + +
+ {dialog ? ( + setDialog(undefined)} + actions={ + <> + + + + } + > +
+ {!dialog.user ? ( + + setUsername(e.target.value)} autoFocus /> + + ) : null} + + setPassword(e.target.value)} autoComplete="new-password" /> + +
+
+ ) : null} +
+ ) +} + +function Events() { + const [kind, setKind] = useState('') + const events = useQuery(() => api.get>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000) + return ( +
+
+ setKind(e.target.value)} /> + {events.loading && !events.data ? : null} +
+ + {(events.data?.items ?? []).length === 0 ? ( + 没有事件 + ) : ( + + + {events.data?.items.map((e) => ( + + + + + + + ))} + +
+ {fmtTime(e.at)} + {e.kind} + {e.agent_id ?? ''} + {e.detail ?? ''}
+ )} +
+ ) +} + +function Raw() { + const [kind, setKind] = useState('') + const [agent, setAgent] = useState('') + const chunks = useQuery(() => api.get>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent]) + return ( +
+
+ setAgent(e.target.value)} /> + setKind(e.target.value)} /> + 原始块按保留期限保存,可以下载后离线分析。 +
+ + {(chunks.data?.items ?? []).length === 0 ? ( + 没有符合条件的原始块 + ) : ( + + + + + + + + + + + + {chunks.data?.items.map((c) => ( + + + + + + + + + ))} + +
时间采集方法序列时长大小 +
{fmtTime(c.start)} + {c.kind} + {c.partial ? 不完整 : null} + + {c.series} + {fmtNum(c.duration_ms / 1000, 1)} s{fmtNum(c.bytes / 1024, 1)} KiB + + + 下载 + +
+ )} +
+ ) +} + +function Analysis() { + const state = useQuery(() => api.get('/api/admin/analysis/recompute'), [], 5000) + const health = useQuery(() => api.get<{ status: string; database: boolean; raw_directory: boolean }>('/healthz'), [], 10_000) + const d = state.data + return ( +
+
+
服务状态
+ {health.data ? ( +
+ {health.data.status} + 数据库 {health.data.database ? '正常' : '异常'} + 原始数据目录 {health.data.raw_directory ? '正常' : '异常'} +
+ ) : ( + + )} +
+
+
指标重算
+

新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。

+ + {d ? ( +
+
状态
+
+ {d.state} +
+
重算的指标
+
{d.metrics.length ? d.metrics.join(', ') : '—'}
+
进度
+
+ {fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口 +
+
排队中的窗口
+
{fmtNum(d.queue_length)}
+
+ ) : ( + + )} +
+
+ ) +} + +export function System() { + const [tab, setTab] = useState('settings') + return ( + <> +
+

系统

+
+
+ + {tab === 'settings' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : } +
+ + ) +} diff --git a/web/admin/src/pages/Tasks.tsx b/web/admin/src/pages/Tasks.tsx new file mode 100644 index 0000000..92539d9 --- /dev/null +++ b/web/admin/src/pages/Tasks.tsx @@ -0,0 +1,317 @@ +import { Pencil, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { api } from '@shared/api/client' +import type { AgentDto, KindDto, Page, ParamDto, TaskDto } from '@shared/api/types' +import { fmtTime } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' + +type ScopeFilter = 'all' | 'template' | 'override' | 'target' + +const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template') + +function summary(params: Record): string { + const entries = Object.entries(params) + return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ') +} + +/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */ +function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: Record; onChange: (v: Record) => void }) { + const set = (name: string, value: unknown, fallback: unknown) => { + const next = { ...values } + if (value === '' || value === undefined || value === fallback) delete next[name] + else next[name] = value + onChange(next) + } + return ( +
+ {params.map((p) => { + const current = values[p.name] + const hint = [p.description, p.min !== null && p.min !== undefined ? `最小 ${p.min}` : '', p.max !== null && p.max !== undefined ? `最大 ${p.max}` : '', p.unit ?? ''].filter(Boolean).join(' · ') + return ( + + {p.type === 'bool' ? ( + + ) : p.values && p.values.length > 0 ? ( + + ) : ( + set(p.name, p.type === 'string' ? e.target.value : e.target.value === '' ? '' : Number(e.target.value), p.default)} + /> + )} + + ) + })} +
+ ) +} + +function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) { + const { busy, run } = useAction() + const editing = Boolean(task) + const [mode, setMode] = useState<'template' | 'override' | 'target'>(task ? (kindOf(task) as 'template' | 'override' | 'target') : 'override') + const [kindId, setKindId] = useState(task?.kind ?? '') + const [agentId, setAgentId] = useState(task?.agent_id ?? agents[0]?.id ?? '') + const [baseId, setBaseId] = useState(task?.base_task_id ?? '') + const [target, setTarget] = useState(task?.target ?? '') + const [values, setValues] = useState>(task ? { ...(task.params as Record) } : {}) + const [enabled, setEnabled] = useState(task?.enabled ?? true) + + const base = templates.find((t) => t.id === baseId) + const effectiveKind = mode === 'override' ? (base?.kind ?? '') : kindId + const kind = kinds.find((k) => k.id === effectiveKind) + const kindChoices = kinds.filter((k) => k.scopes.includes(mode === 'target' ? 'target' : 'line')) + + const submit = async () => { + const ok = await run(async () => { + if (task) { + await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: mode === 'target' ? target : undefined }) + } else { + await api.post('/api/admin/tasks', { + scope: mode === 'target' ? 'target' : 'line', + kind: effectiveKind, + target: mode === 'target' ? target : null, + params: values, + enabled, + agent_id: mode === 'template' ? null : agentId, + base_task_id: mode === 'override' ? baseId : null, + }) + } + }, '已保存') + if (ok) { + onSaved() + onClose() + } + } + + return ( + + + + + } + > +
+ {!editing ? ( + + { + setMode(v) + setValues({}) + setKindId('') + }} + /> + + ) : null} + {mode !== 'template' ? ( + + + + ) : null} + {mode === 'override' && !editing ? ( + + + + ) : null} + {mode !== 'override' && !editing ? ( + + + + ) : null} + {mode === 'target' ? ( + + setTarget(e.target.value)} /> + + ) : null} + {kind ? ( + <> + {kind.description ?

{kind.description}

: null} + + + ) : null} + +
+
+ ) +} + +export function Tasks() { + const tasks = useQuery(() => api.get>('/api/admin/tasks'), []) + const kinds = useQuery(() => api.get>('/api/public/catalog/kinds'), []) + const agents = useQuery(() => api.get>('/api/admin/agents'), []) + const [filter, setFilter] = useState('all') + const [editing, setEditing] = useState() + const [deleting, setDeleting] = useState() + const { run } = useAction() + + const all = tasks.data?.items ?? [] + const list = all.filter((t) => filter === 'all' || kindOf(t) === filter) + const agentName = (id: string | null) => (id ? (agents.data?.items.find((a) => a.id === id)?.name ?? id) : '全部 Agent') + + return ( + <> +
+

任务

+ + {tasks.loading && !tasks.data ? : null} + + +
+ +
+ +
+ {list.length === 0 ? ( + {tasks.data ? '没有任务' : '加载中…'} + ) : ( + + + + + + + + + + + + + {list.map((t) => { + const k = kindOf(t) + return ( + + + + + + + + + + ) + })} + +
采集方法类型作用于参数状态更新 +
{t.kind} + {k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'} + + {agentName(t.agent_id)} + {t.target ?
{t.target}
: null} +
+ {summary(t.params as Record)} + + + {fmtTime(t.updated_at)} + + + + +
+ )} +
+
+ {editing && kinds.data && agents.data ? ( + kindOf(t) === 'template')} + onClose={() => setEditing(undefined)} + onSaved={tasks.reload} + /> + ) : null} + {deleting ? ( + setDeleting(undefined)} + onConfirm={async () => { + await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除') + tasks.reload() + }} + > + 删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。 + + ) : null} + + ) +} diff --git a/web/admin/src/pages/Tests.tsx b/web/admin/src/pages/Tests.tsx new file mode 100644 index 0000000..70c3da1 --- /dev/null +++ b/web/admin/src/pages/Tests.tsx @@ -0,0 +1,240 @@ +import { FlaskConical, Square } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types' +import { fmtMbps, fmtMs, fmtPct, fmtTime, scoreTone } from '@shared/format' +import { translatePlan } from '@shared/i18n' +import { useQuery } from '@shared/hooks' +import { Chip, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui' + +const STATUS: Record = { + queued: { label: '排队中', tone: '' }, + running: { label: '运行中', tone: 'accent' }, + succeeded: { label: '成功', tone: 'ok' }, + failed: { label: '失败', tone: 'bad' }, + aborted: { label: '已取消', tone: 'warn' }, +} + +const ACTIVE = new Set(['queued', 'running']) + +function Result({ test }: { test: TestDto }) { + const r = test.result + if (!r) return {ACTIVE.has(test.status) ? '测试进行中…' : test.error_code ? `失败:${test.error_code}` : '没有结果'} + const m = r.metrics + const pick = (id: string, fmt: (v: number) => string, label: string) => (m[id] === undefined ? null : ( +
+ {label} + {fmt(m[id])} +
+ )) + return ( +
+
+ {pick('tcp.bulk.peak.max', fmtMbps, '吞吐峰值')} + {pick('tcp.bulk.mbps.p50', fmtMbps, '吞吐中位数')} + {pick('connect.success', (v) => fmtPct(v, 1), '建连成功率')} + {pick('connect.tcp_ms.p95', fmtMs, 'TCP 建连 p95')} + {pick('connect.tls_ms.p95', fmtMs, 'TLS 握手 p95')} + {pick('hold.lifetime_max', (v) => `${(v / 1000).toFixed(0)} s`, '最长保持')} + {pick('hy2.sustainable_mbps', fmtMbps, 'Hy2 可持续码率')} + {pick('hy2.qos_onset_mbps', fmtMbps, '限速起点')} + {pick('tcp.retrans', fmtPct, 'TCP 重传')} +
+
+ {Object.entries(r.scores).map(([model, score]) => ( + + {model} {score.score === null ? '—' : Math.round(score.score)} + {score.estimated ? '(估算)' : ''} + + ))} + {r.caveats.map((c) => ( + + {c} + + ))} +
+ {r.steps.length > 0 ? ( + + + + + + + + + + + + {r.steps.map((s) => ( + + + + + + + + ))} + +
阶梯目标码率丢包抖动 p95实际吞吐
#{s.index + 1}{fmtMbps(s.target_mbps)}{fmtPct(s.loss_pct)}{fmtMs(s.jitter_p95_ms)}{fmtMbps(s.delivered_mbps)}
+ ) : null} +
+ ) +} + +export function Tests() { + const [params, setParams] = useSearchParams() + const agents = useQuery(() => api.get>('/api/admin/agents'), [], 10_000) + const probes = useQuery(() => api.get>('/api/admin/probes'), []) + const plans = useQuery(() => api.get>('/api/public/catalog/plans'), []) + const { busy, run } = useAction() + + const active = (agents.data?.items ?? []).filter((a) => a.status === 'active') + const agentId = params.get('agent') ?? active[0]?.id ?? '' + const agent = active.find((a) => a.id === agentId) + const connected = agent?.probes ?? [] + const probeId = params.get('probe') ?? connected[0]?.probe_id ?? '' + const [planId, setPlanId] = useState('') + const [selected, setSelected] = useState() + const line = agentId && probeId ? `${agentId}~${probeId}` : '' + + useEffect(() => { + if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id) + }, [plans.data, planId]) + + const tests = useQuery(() => (line ? api.get>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000) + const detail = useQuery(() => (selected ? api.get(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000) + const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id + const rawPlan = plans.data?.items.find((p) => p.id === planId) + const plan = rawPlan ? translatePlan(rawPlan.id, rawPlan.title, rawPlan.description) : undefined + + const set = (patch: Record) => { + const next = new URLSearchParams(params) + for (const [k, v] of Object.entries(patch)) next.set(k, v) + setParams(next, { replace: true }) + } + + return ( + <> +
+

按需测试

+ {agents.loading && !agents.data ? : null} +
+ + +
+
+
发起测试
+

+ 测试会在选中的线路(一个 Agent 和一个连着它的 Probe)上跑,会占用链路带宽,请不要太频繁。 +

+
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+
+
+ 这条线路的测试 +
+ {(tests.data?.items ?? []).length === 0 ? ( + {line ? '还没有测试' : '选择一条线路'} + ) : ( + + + {tests.data?.items.map((t) => ( + setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}> + + + + + + ))} + +
+ {STATUS[t.status]?.label ?? t.status} + {t.plan}{fmtTime(t.requested_at)} + {ACTIVE.has(t.status) ? ( + + ) : null} +
+ )} +
+
+
结果
+ {detail.data ? ( + <> +

+ {detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status} + {detail.data.phase !== null ? ` · 阶段 ${detail.data.phase + 1} / ${detail.data.phases.length}` : ''} +

+ + + ) : ( + 选择左边的一条测试查看结果 + )} +
+
+
+ + ) +} diff --git a/web/admin/stylelint.config.mjs b/web/admin/stylelint.config.mjs new file mode 100644 index 0000000..f600987 --- /dev/null +++ b/web/admin/stylelint.config.mjs @@ -0,0 +1,9 @@ +// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌 +export default { + rules: { + 'color-no-hex': true, + 'color-named': 'never', + 'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'], + }, + overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }], +} diff --git a/web/admin/tsconfig.app.json b/web/admin/tsconfig.app.json new file mode 100644 index 0000000..c085999 --- /dev/null +++ b/web/admin/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { "@shared/*": ["../shared/*"] } + }, + "include": ["src", "../shared"], + "exclude": ["../shared/api/schema.ts"] +} diff --git a/web/admin/tsconfig.json b/web/admin/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/web/admin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/admin/tsconfig.node.json b/web/admin/tsconfig.node.json new file mode 100644 index 0000000..bdace33 --- /dev/null +++ b/web/admin/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/admin/vite.config.ts b/web/admin/vite.config.ts new file mode 100644 index 0000000..8586f3e --- /dev/null +++ b/web/admin/vite.config.ts @@ -0,0 +1,23 @@ +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' +import { defineConfig, loadEnv } from 'vite' + +// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example): +// BACKEND_URL=https://mo.oeo.one npm run dev:display +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '') + const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '') + return { + base: '/admin/', + plugins: [react()], + resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } }, + server: { + port: 5174, + fs: { allow: ['..'] }, + proxy: { + '/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, + '/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, + }, + }, + } +}) diff --git a/web/dev.mjs b/web/dev.mjs new file mode 100644 index 0000000..2a7ff36 --- /dev/null +++ b/web/dev.mjs @@ -0,0 +1,7 @@ +// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。 +import { spawn } from 'node:child_process' + +const children = ['display', 'admin'].map((name) => spawn('npm', ['run', 'dev', '-w', name], { stdio: 'inherit', shell: true })) +const stop = () => children.forEach((c) => c.kill()) +process.on('SIGINT', stop) +process.on('exit', stop) diff --git a/web/display/index.html b/web/display/index.html new file mode 100644 index 0000000..2f89ebb --- /dev/null +++ b/web/display/index.html @@ -0,0 +1,12 @@ + + + + + + 线路质量 + + +
+ + + diff --git a/web/display/package.json b/web/display/package.json new file mode 100644 index 0000000..383847c --- /dev/null +++ b/web/display/package.json @@ -0,0 +1,28 @@ +{ + "name": "monitor-display", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\"" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "stylelint": "^16.12.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/web/display/src/App.tsx b/web/display/src/App.tsx new file mode 100644 index 0000000..2ce4f2f --- /dev/null +++ b/web/display/src/App.tsx @@ -0,0 +1,36 @@ +import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { SourceButton, ThemeToggle } from '@shared/ui' +import { Compare } from './pages/Compare' +import { Line } from './pages/Line' +import { Overview } from './pages/Overview' + +export function App() { + return ( + <> +
+
+ + 线路质量 + + + + +
+
+ + } /> + } /> + } /> + } /> + + + ) +} diff --git a/web/display/src/display.css b/web/display/src/display.css new file mode 100644 index 0000000..8f0edb5 --- /dev/null +++ b/web/display/src/display.css @@ -0,0 +1,462 @@ +/* 展示端:极简博客式的页面——顶栏、居中的内容列、大号标题、细分隔线 */ +.topbar { + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in srgb, var(--app-bg), transparent 8%); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--app-outline-soft); +} + +.topbar .inner { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + gap: 24px; +} + +.brand { + font-size: 18px; + font-weight: 700; + color: var(--app-ink); +} + +.brand:hover { + text-decoration: none; +} + +.topnav { + display: flex; + gap: 4px; + margin-left: auto; +} + +.topnav a { + padding: 6px 14px; + border-radius: var(--r-pill); + color: var(--app-ink-muted); + font-weight: 500; +} + +.topnav a:hover { + text-decoration: none; + background: var(--app-card-inset); +} + +.topnav a.active { + background: var(--app-accent-container); + color: var(--app-on-accent-container); +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 40px 24px 80px; +} + +.hero h1 { + font-size: 40px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.hero .sub { + margin-top: 8px; + color: var(--app-ink-muted); +} + +.section-label { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--app-accent); + font-weight: 700; +} + +.section-head { + display: flex; + align-items: flex-end; + gap: 16px; + margin: 40px 0 16px; + flex-wrap: wrap; +} + +.section-head h2 { + font-size: 20px; + font-weight: 700; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 24px; +} + +.line-row td:first-child { + min-width: 200px; +} + +.line-name { + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.line-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.scores { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.score-chip { + display: inline-flex; + flex-direction: column; + align-items: center; + min-width: 52px; + padding: 4px 10px; + border-radius: var(--r-control); + background: var(--app-card-inset); + line-height: 1.25; +} + +.score-chip b { + font-size: 15px; + font-variant-numeric: tabular-nums; +} + +.score-chip small { + font-size: 10px; + color: var(--app-ink-muted); +} + +.score-chip.ok { + background: var(--app-ok-container); + color: var(--app-ok); +} + +.score-chip.warn { + background: var(--app-warn-container); + color: var(--app-warn); +} + +.score-chip.bad { + background: var(--app-bad-container); + color: var(--app-bad); +} + +.chart-card { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chart-card h3 { + font-size: 14px; + font-weight: 600; +} + +.crumbs { + display: flex; + align-items: center; + gap: 8px; + color: var(--app-ink-muted); + margin-bottom: 12px; +} + +.event-list { + display: flex; + flex-direction: column; +} + +.event-list > div { + display: flex; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid var(--app-outline-soft); +} + +.event-list > div:last-child { + border-bottom: 0; +} + +.pick-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pick { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: var(--r-pill); + background: var(--app-card-inset); + cursor: pointer; + user-select: none; +} + +.pick.on { + background: var(--app-accent-container); + color: var(--app-on-accent-container); +} + +.pick input { + display: none; +} + +@media (max-width: 700px) { + .hero h1 { + font-size: 30px; + } + + .table .hide-sm { + display: none; + } +} + +/* ── Agent 卡片 ── */ +.cards { + margin-top: 24px; + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); +} + +.agent-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; +} + +.agent-card.offline { + opacity: 0.62; +} + +.agent-head { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-name { + font-size: 17px; + font-weight: 700; +} + +.agent-head .chip { + margin-left: auto; +} + +.agent-head .chip + .faint { + font-size: 12px; +} + +.lines { + display: flex; + flex-direction: column; + gap: 4px; +} + +.line-link { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--r-control); + background: var(--app-card-inset); + color: var(--app-ink); +} + +.line-link:hover { + text-decoration: none; + background: var(--app-card-hover); +} + +.scores.compact { + gap: 3px; +} + +.scores.compact .score-chip { + min-width: 0; + padding: 1px 6px; + flex-direction: row; + gap: 4px; + align-items: baseline; +} + +.scores.compact .score-chip b { + font-size: 12px; +} + +.scores.compact .score-chip small { + display: none; +} + +.host { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--app-outline-soft); +} + +.host.stale .meter i { + background: var(--app-idle); +} + +.meter-row { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 44px; + align-items: center; + column-gap: 10px; +} + +.meter-row .meter-detail { + grid-column: 2 / 4; + font-size: 11px; + margin-top: -2px; +} + +.meter-label { + color: var(--app-ink-muted); + font-size: 12px; +} + +.meter { + height: 8px; + border-radius: var(--r-pill); + background: var(--app-card-inset); + overflow: hidden; +} + +.meter i { + display: block; + height: 100%; + border-radius: var(--r-pill); + background: var(--app-accent); + transition: width 0.4s; +} + +.meter i.warn { + background: var(--app-warn); +} + +.meter i.bad { + background: var(--app-bad); +} + +.meter-value { + text-align: right; + font-size: 12px; +} + +.host-grid { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + margin-top: 4px; + font-size: 12px; +} + +.host-grid .stat { + flex-direction: row; + gap: 12px; + align-items: baseline; +} + +.host-grid .stat .k { + min-width: 56px; +} + +.live-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 10px; + border-radius: 50%; + background: var(--app-idle); +} + +.live-dot.on { + background: var(--app-ok); +} + +.line-link > * { + white-space: nowrap; +} + +.range-picker { + display: flex; + flex-direction: column; +} + +/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */ +.winners { + margin-top: 24px; + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +.winner { + display: flex; + flex-direction: column; + gap: 2px; + padding: 14px 18px; +} + +.winner-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 17px; + font-weight: 700; + color: var(--app-ok); +} + +.matrix td.r { + padding-top: 6px; + padding-bottom: 6px; +} + +.cell { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 0; + border-radius: var(--r-control); + background: transparent; + color: var(--app-ink); + cursor: pointer; + font-variant-numeric: tabular-nums; +} + +.cell:hover { + background: var(--app-card-hover); +} + +.cell.on { + background: var(--app-accent-container); +} + +.cell svg { + color: var(--app-ok); +} diff --git a/web/display/src/lib.tsx b/web/display/src/lib.tsx new file mode 100644 index 0000000..208ac78 --- /dev/null +++ b/web/display/src/lib.tsx @@ -0,0 +1,56 @@ +import { ApiError } from '@shared/api/client' +import { describeError } from '@shared/ui' +import { scoreTone } from '@shared/format' + +export const RANGES = [ + { value: '15m', label: '15 分钟' }, + { value: '1h', label: '1 小时' }, + { value: '6h', label: '6 小时' }, + { value: '24h', label: '24 小时' }, + { value: '7d', label: '7 天' }, +] as const + +export type RangeValue = (typeof RANGES)[number]['value'] + +export const isRange = (v: string | null): v is RangeValue => RANGES.some((r) => r.value === v) + +export const SCORE_MODELS = [ + { id: 'score.reality', label: 'REALITY' }, + { id: 'score.hy2', label: 'Hy2' }, + { id: 'score.mc', label: 'MC' }, + { id: 'score.base', label: '综合' }, +] as const + +export function ScoreChips({ metrics, compact }: { metrics: Record; compact?: boolean }) { + return ( +
+ {SCORE_MODELS.map((m) => { + const v = metrics[m.id] + return ( + + {v === null || v === undefined ? '—' : Math.round(v)} + {m.label} + + ) + })} +
+ ) +} + +/** 站点没有公开时匿名访问会被拒绝:给出一个说得清楚的提示,而不是一条错误。 */ +export function PageError({ error }: { error: unknown }) { + if (!error) return null + if (error instanceof ApiError && error.code === 'site_private') { + return ( +
+
站点未公开
+

管理员还没有把站点设为公开。管理员请先 登录管理端,在「系统 → 设置」里打开「站点公开」,或者带着登录状态访问这里。

+
+ ) + } + return
{describeError(error)}
+} + +export const lineLabel = (name: string, probe: string) => `${name} · ${probe}` + +export const num = (v: number | null | undefined) => (v === null || v === undefined ? null : v) diff --git a/web/display/src/live.ts b/web/display/src/live.ts new file mode 100644 index 0000000..b1ac44a --- /dev/null +++ b/web/display/src/live.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react' +import type { Page } from '@shared/api/types' +import { api } from '@shared/api/client' +import { openLive } from '@shared/api/source' + +/** 展示页能拿到的主机状态:只有汇总的数字,没有型号和 IP。 */ +export interface Host { + cpu_percent: number | null + mem_used: number | null + mem_total: number | null + disk_used: number | null + disk_total: number | null + net_rx_total: number | null + net_tx_total: number | null + net_rx_bps: number | null + net_tx_bps: number | null + uptime_seconds: number | null + at: string +} + +export interface HostItem { + agent_id: string + name: string + online: boolean + stale: boolean + last_seen_at: string | null + host: Host | null +} + +/** + * Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。 + * 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。 + */ +export function useHosts(): { hosts: Map; live: boolean } { + const [hosts, setHosts] = useState>(new Map()) + const [live, setLive] = useState(false) + + useEffect(() => { + let alive = true + api + .get>('/api/public/hosts') + .then((page) => alive && setHosts(new Map(page.items.map((i) => [i.agent_id, i])))) + .catch(() => undefined) + + const source = openLive('/api/public/live?topics=hosts,nodes') + source.onopen = () => setLive(true) + source.onerror = () => setLive(false) + source.addEventListener('host', (event) => { + const data = JSON.parse((event as MessageEvent).data) as { agent_id: string; online: boolean; host: Host } + setHosts((previous) => { + const next = new Map(previous) + const old = previous.get(data.agent_id) + next.set(data.agent_id, { agent_id: data.agent_id, name: old?.name ?? data.agent_id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host }) + return next + }) + }) + source.addEventListener('node', (event) => { + const data = JSON.parse((event as MessageEvent).data) as { id: string; online: boolean } + setHosts((previous) => { + const old = previous.get(data.id) + if (!old) return previous + const next = new Map(previous) + next.set(data.id, { ...old, online: data.online, stale: data.online ? old.stale : true }) + return next + }) + }) + return () => { + alive = false + source.close() + } + }, []) + + return { hosts, live } +} + +export function fmtBytes(v: number | null | undefined): string { + if (v === null || v === undefined) return '—' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = v + let i = 0 + while (value >= 1024 && i < units.length - 1) { + value /= 1024 + i++ + } + return `${value.toFixed(value >= 100 || i === 0 ? 0 : 2)} ${units[i]}` +} + +export function fmtSpeed(v: number | null | undefined): string { + return v === null || v === undefined ? '—' : `${fmtBytes(v)}/s` +} + +export function fmtUptime(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) return '—' + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + return d > 0 ? `${d} 天 ${h} 小时 ${m} 分` : h > 0 ? `${h} 小时 ${m} 分` : `${m} 分 ${Math.floor(seconds % 60)} 秒` +} + +export const pct = (used: number | null, total: number | null): number | null => (used !== null && total !== null && total > 0 ? (100 * used) / total : null) diff --git a/web/display/src/main.tsx b/web/display/src/main.tsx new file mode 100644 index 0000000..8d7b644 --- /dev/null +++ b/web/display/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import '@shared/theme/base.css' +import { configureSource } from '@shared/api/source' +import { applyStoredTheme } from '@shared/hooks' +import { ToastProvider } from '@shared/ui' +import { App } from './App' +import './display.css' + +configureSource('display') +applyStoredTheme() + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/web/display/src/pages/Compare.tsx b/web/display/src/pages/Compare.tsx new file mode 100644 index 0000000..58ece2f --- /dev/null +++ b/web/display/src/pages/Compare.tsx @@ -0,0 +1,213 @@ +import { Star } from 'lucide-react' +import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types' +import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' +import { fmtMs, fmtPct } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Empty, Segmented, Spinner } from '@shared/ui' +import { lineLabel, PageError } from '../lib' +import { PRESETS, type PresetValue } from '../ranges' + +interface MetricInfo { + id: string + label: string + unit: 'ms' | '%' | '' + better: 'lower' | 'higher' +} + +const METRICS: MetricInfo[] = [ + { id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' }, + { id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' }, + { id: 'loss.total', label: '丢包', unit: '%', better: 'lower' }, + { id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' }, + { id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' }, + { id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' }, + { id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' }, + { id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' }, + { id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' }, + { id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' }, + { id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' }, + { id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' }, + { id: 'score.base', label: '综合评分', unit: '', better: 'higher' }, +] + +const COLORS = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6'] +const MAX_LINES = 6 + +const isPreset = (v: string | null): v is PresetValue => PRESETS.some((p) => p.value === v) + +function fmt(m: MetricInfo, v: number | null | undefined): string { + if (v === null || v === undefined) return '—' + return m.unit === 'ms' ? fmtMs(v) : m.unit === '%' ? fmtPct(v) : v.toFixed(0) +} + +interface Cell { + line: OverviewLineDto + value: number | null + /** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */ + rank: number | null + /** 比这一列最好的差多少(百分比);最好的是 0。 */ + gap: number | null +} + +export function Compare() { + const [params, setParams] = useSearchParams() + const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d' + const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0] + const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params]) + + const overview = useQuery(() => api.get('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000) + + // 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好) + const matrix = useMemo(() => { + const lines = overview.data?.lines ?? [] + const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) + const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1])) + const byProbe = new Map() + for (const [probeId] of probes) { + const cells: Cell[] = lines + .filter((l) => l.probe_id === probeId) + .map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null })) + const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!)) + const best = ranked[0]?.value ?? null + ranked.forEach((c, i) => { + c.rank = i + 1 + c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100 + }) + byProbe.set(probeId, cells) + } + return { probes, agents, byProbe } + }, [overview.data, metric]) + + // 默认选每个 Probe 里最好的那条线路(最多 6 条) + const defaults = useMemo(() => { + const ids: string[] = [] + for (const [probeId] of matrix.probes) { + const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) + if (best) ids.push(best.line.id) + } + return ids.slice(0, MAX_LINES) + }, [matrix]) + const selected = picked.length > 0 ? picked : defaults + + const compare = useQuery( + () => (selected.length ? api.get('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)), + [selected.join(','), metric.id, range], + 15_000, + ) + + const update = (patch: Record) => { + const next = new URLSearchParams(params) + for (const [k, v] of Object.entries(patch)) { + if (v) next.set(k, v) + else next.delete(k) + } + setParams(next, { replace: true }) + } + const toggle = (id: string) => { + const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id].slice(-MAX_LINES) + update({ lines: next.join(',') }) + } + + const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)])) + const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] })) + + // 每个 Probe 的最佳线路 + const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) })) + + return ( +
+
+

线路对比

+

同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。

+
+ ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} /> + + {metric.better === 'lower' ? '越低越好' : '越高越好'} + {overview.loading && !overview.data ? : null} +
+
+ + + + {overview.data ? ( + matrix.agents.length === 0 ? ( +
+ 还没有线路 +
+ ) : ( + <> + {winners.some((w) => w.cell) ? ( +
+ {winners.map((w) => + w.cell ? ( +
+ 从 {w.probeName} 看出去最好 + + {w.cell.line.name} + + {fmt(metric, w.cell.value)} +
+ ) : null, + )} +
+ ) : null} + +
+ + + + + {matrix.probes.map(([id, name]) => ( + + ))} + + + + {matrix.agents.map(([agentId, agentName]) => ( + + + {matrix.probes.map(([probeId]) => { + const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId) + if (!cell) return + const on = selected.includes(cell.line.id) + const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : '' + return ( + + ) + })} + + ))} + +
Agent / Probe + {name} +
{agentName}— + +
+

+ ★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。 +

+
+ +
+
{metric.label} 的走势
+ {compare.data ? : {selected.length ? '加载中…' : '点上面的格子选择线路'}} +
+ + ) + ) : null} +
+ ) +} diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx new file mode 100644 index 0000000..3d23a8b --- /dev/null +++ b/web/display/src/pages/Line.tsx @@ -0,0 +1,254 @@ +import { ChevronLeft } from 'lucide-react' +import { useMemo } from 'react' +import { Link, useParams, useSearchParams } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { LineDto, SeriesDto, TestDto } from '@shared/api/types' +import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' +import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format' +import { fmtBytes, useHosts } from '../live' +import { useQuery } from '@shared/hooks' +import { Chip, Dot, Spinner, Stat } from '@shared/ui' +import { PageError, ScoreChips } from '../lib' +import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges' + +function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' { + if (value === null || value === undefined) return '' + if (value <= good) return 'ok' + if (value >= poor) return 'bad' + return 'warn' +} + +interface HostSeries { + t: number[] + values: Record +} + +export function Line() { + const { id = '' } = useParams() + const [params, setParams] = useSearchParams() + + const line = useQuery(() => api.get(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000) + const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null + const nowMs = useMemo(() => Date.now(), [line.data]) + const spanMs = sinceMs === null ? null : nowMs - sinceMs + + // 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认 + const wanted = params.get('range') + const available = availablePresets(spanMs) + const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs) + const sliceParam = params.get('slice') + const state: RangeState = { + preset, + month: params.get('month'), + slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all', + page: Math.max(0, Number(params.get('page') ?? 0) || 0), + } + const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs]) + const resolved = resolveRange(state, months, nowMs) + const rangeKey = JSON.stringify(resolved.query) + const setRange = (next: RangeState) => { + const q = new URLSearchParams() + if (next.month) { + q.set('month', next.month) + if (next.slice !== 'all') q.set('slice', next.slice) + if (next.page > 0) q.set('page', String(next.page)) + } else { + q.set('range', next.preset) + } + setParams(q, { replace: true }) + } + + const series = useQuery( + () => (line.data ? api.get(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)), + [id, rangeKey, Boolean(line.data)], + state.month ? undefined : 10_000, + ) + const agentId = line.data?.agent_id + const hostHistory = useQuery( + () => (agentId && !state.month ? api.get(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)), + [agentId, resolved.hostRange, Boolean(state.month)], + 30_000, + ) + const { hosts } = useHosts() + const host = agentId ? hosts.get(agentId)?.host : undefined + const lastTestId = line.data?.last_test?.id + const test = useQuery(() => (lastTestId ? api.get(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId]) + + const s = series.data + const col = (key: string): (number | null)[] => s?.values[key] ?? [] + const m = s?.summary.metrics ?? {} + const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s]) + const t = s?.t ?? [] + + const chart = (title: string, unit: string, list: ChartSeries[], zero = true) => + s && list.some((x) => x.values.some((v) => v !== null)) ? ( +
+

{title}

+ +
+ ) : null + + const result = test.data?.result + const peak = result?.metrics?.['tcp.bulk.peak.max'] + + return ( +
+
+ + + 总览 + +
+ +
+

+ {line.data?.name ?? '线路'} + {line.data ? → {line.data.probe_name} : null} +

+ {line.data ? ( +
+ + {line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'} + {line.data.labels.map((x) => ( + {x} + ))} +
+ ) : null} +
+ + + {resolved.label} + {series.loading && !s ? : null} + +
+
+ + + + {s ? ( +
+
+
+
+ + + + + + +
+ +
+
+
+ + + + + + +
+ {s.summary.coverage < 0.95 ?

这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。

: null} +
+ +
+ {chart('延迟(UDP 往返)', 'ms', [ + { label: 'p50', values: col('rtt.p50'), color: '--chart-1' }, + { label: 'p95', values: col('rtt.p95'), color: '--chart-2' }, + { label: 'p99', values: col('rtt.p99'), color: '--chart-3' }, + ])} + {chart('丢包', '%', [ + { label: '上行', values: col('loss.up'), color: '--chart-2' }, + { label: '下行', values: col('loss.down'), color: '--chart-3' }, + { label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] }, + ])} + {chart('抖动(p95)', 'ms', [ + { label: '上行', values: col('jitter.up.p95'), color: '--chart-2' }, + { label: '下行', values: col('jitter.down.p95'), color: '--chart-1' }, + ])} + {chart('TCP 往返', 'ms', [ + { label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' }, + { label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' }, + { label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' }, + ])} + {chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])} + {chart('综合评分', '', [ + { label: 'REALITY', values: col('score.reality'), color: '--chart-1' }, + { label: 'Hy2', values: col('score.hy2'), color: '--chart-2' }, + { label: 'MC', values: col('score.mc'), color: '--chart-4' }, + { label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] }, + ])} +
+ + {host || hostHistory.data ? ( +
+
主机状态
+ {host ? ( +
+ + + + +
+ ) : null} + {hostHistory.data ? ( +
+ + +
+ ) : null} +
+ ) : null} + + {test.data && result ? ( +
+
最近一次测试 · {test.data.plan}
+
+ {peak !== undefined ? : null} + {result.metrics['connect.success'] !== undefined ? : null} + {result.metrics['connect.tls_ms.p95'] !== undefined ? : null} + {result.metrics['hy2.sustainable_mbps'] !== undefined ? : null} + {result.metrics['hy2.qos_onset_mbps'] !== undefined ? : null} +
+

+ {fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)}) +

+
+ ) : null} + + {s.events.length > 0 ? ( +
+
事件
+
+ {s.events.slice(0, 20).map((e) => ( +
+ + {fmtTime(e.at)} + + {e.kind} + {e.detail ? {e.detail} : null} +
+ ))} +
+
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/web/display/src/pages/Overview.tsx b/web/display/src/pages/Overview.tsx new file mode 100644 index 0000000..405d9ab --- /dev/null +++ b/web/display/src/pages/Overview.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { OverviewDto, OverviewLineDto } from '@shared/api/types' +import { fmtAgo, fmtMs, fmtPct } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui' +import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live' +import { PageError, ScoreChips } from '../lib' + +const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',') + +type SortKey = 'name' | 'rtt' | 'score' + +interface AgentCard { + id: string + name: string + labels: string[] + online: boolean + lines: OverviewLineDto[] +} + +function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' { + if (value === null || value === undefined) return '' + if (value <= good) return 'ok' + if (value >= poor) return 'bad' + return 'warn' +} + +function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) { + const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok' + return ( +
+ {label} + + + + {value === null ? '—' : `${value.toFixed(value >= 10 ? 0 : 1)}%`} + {detail ? {detail} : null} +
+ ) +} + +function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) { + const h = host?.host ?? null + const stale = !agent.online || !host || host.stale + return ( +
+
+ + {agent.name} + {agent.online ? '在线' : '离线'} + {!agent.online && host?.last_seen_at ? {fmtAgo(host.last_seen_at)} : null} +
+ {agent.labels.length > 0 ? ( +
+ {agent.labels.map((x) => ( + {x} + ))} +
+ ) : null} + +
+ {agent.lines.map((l) => { + const m = l.summary.metrics + return ( + + → {l.probe_name} + + {!l.probe_connected && l.online ? Probe 未连接 : null} + {fmtMs(m['rtt.p50'])} + {fmtPct(m['loss.total'])} + + + ) + })} +
+ +
+ {h ? ( + <> + + + +
+
+ 流量 + + ↑ {fmtBytes(h.net_tx_total)} ↓ {fmtBytes(h.net_rx_total)} + +
+
+ 网速 + + ↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)} + +
+
+ 运行时间 + {fmtUptime(h.uptime_seconds)} +
+
+ + ) : ( +
这台 Agent 没有上报主机状态
+ )} +
+
+ ) +} + +export function Overview() { + const [probe, setProbe] = useState('') + const [sort, setSort] = useState('name') + const { hosts, live } = useHosts() + const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000) + + const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data]) + const cards = useMemo(() => { + const byAgent = new Map() + for (const l of data?.lines ?? []) { + if (probe && l.probe_name !== probe) continue + const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] } + card.lines.push(l) + byAgent.set(l.agent_id, card) + } + const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => { + const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined) + return values.length ? values.reduce(pick) : null + } + return [...byAgent.values()].sort((a, b) => { + if (a.online !== b.online) return a.online ? -1 : 1 + if (sort === 'name') return a.name.localeCompare(b.name) + const key = sort === 'rtt' ? 'rtt.p50' : 'score.base' + const x = best(a, key, sort === 'rtt' ? Math.min : Math.max) + const y = best(b, key, sort === 'rtt' ? Math.min : Math.max) + if (x === null && y === null) return a.name.localeCompare(b.name) + if (x === null) return 1 + if (y === null) return -1 + return sort === 'rtt' ? x - y : y - x + }) + }, [data, probe, sort]) + + const online = cards.filter((c) => c.online).length + + return ( +
+
+

线路质量

+

+ {data ? `${online} / ${cards.length} 台在线` : '加载中…'} + +

+
+ {probes.length > 1 ? ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null} + + + {loading && !data ? : null} + +
+
+ + + + {data ? ( + cards.length === 0 ? ( +
+ 还没有线路 +
+ ) : ( +
+ {cards.map((c) => ( + + ))} +
+ ) + ) : null} +
+ ) +} diff --git a/web/display/src/ranges.tsx b/web/display/src/ranges.tsx new file mode 100644 index 0000000..af305dd --- /dev/null +++ b/web/display/src/ranges.tsx @@ -0,0 +1,190 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useMemo } from 'react' +import { Segmented } from '@shared/ui' + +const MIN = 60_000 +const DAY = 24 * 60 * MIN + +/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */ +export const PRESETS = [ + { value: '5m', label: '5 分钟', ms: 5 * MIN }, + { value: '15m', label: '15 分钟', ms: 15 * MIN }, + { value: '30m', label: '30 分钟', ms: 30 * MIN }, + { value: '1d', label: '1 天', ms: DAY }, + { value: '3d', label: '3 天', ms: 3 * DAY }, + { value: '7d', label: '7 天', ms: 7 * DAY }, + { value: '14d', label: '14 天', ms: 14 * DAY }, + { value: '30d', label: '一个月', ms: 30 * DAY }, +] as const + +export type PresetValue = (typeof PRESETS)[number]['value'] + +/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */ +export const SLICES = [ + { value: 'all', label: '整月', ms: 0 }, + { value: '14d', label: '14 天', ms: 14 * DAY }, + { value: '7d', label: '7 天', ms: 7 * DAY }, + { value: '3d', label: '3 天', ms: 3 * DAY }, + { value: '1d', label: '1 天', ms: DAY }, +] as const + +export type SliceValue = (typeof SLICES)[number]['value'] + +/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */ +const TOLERANCE = 0.9 + +export function availablePresets(spanMs: number | null) { + if (spanMs === null) return [PRESETS[0]] + const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE) + return list.length > 0 ? list : [PRESETS[0]] +} + +/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */ +export function defaultPreset(spanMs: number | null): PresetValue { + const list = availablePresets(spanMs).filter((p) => p.ms <= DAY) + return list[list.length - 1]?.value ?? '5m' +} + +export interface MonthInfo { + key: string // YYYY-MM + label: string + start: number + end: number // 不含 +} + +const pad = (n: number) => String(n).padStart(2, '0') + +export function monthOf(ms: number): MonthInfo { + const d = new Date(ms) + const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime() + const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime() + return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end } +} + +/** 从有数据的第一个月到当前月,新的在前。 */ +export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] { + if (sinceMs === null) return [] + const months: MonthInfo[] = [] + let cursor = monthOf(sinceMs) + const last = monthOf(nowMs) + while (cursor.start <= last.start) { + months.push(cursor) + cursor = monthOf(cursor.end) + } + return months.reverse() +} + +export interface RangeState { + /** 没有 month 就是“最近一段”。 */ + preset: PresetValue + month: string | null + slice: SliceValue + page: number +} + +export interface ResolvedRange { + query: { range?: string; from?: string; to?: string; anchor?: string } + /** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */ + hostRange: string + label: string +} + +const fmtDay = (ms: number) => { + const d = new Date(ms) + return `${d.getMonth() + 1}/${d.getDate()}` +} + +export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange { + if (!state.month) { + const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset + return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset } + } + const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs) + const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0] + const end = Math.min(month.end, nowMs) + let from = month.start + let to = end + if (slice.ms > 0) { + from = month.start + state.page * slice.ms + to = Math.min(from + slice.ms, end) + } + const spanDays = Math.max(1, Math.round((to - from) / DAY)) + return { + query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() }, + hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`, + label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label, + } +} + +/** 一个月里按固定长度能翻几页。 */ +function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } { + const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY)) + const end = Math.min(month.end, nowMs) + return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) } +} + +interface Props { + state: RangeState + onChange: (next: RangeState) => void + sinceMs: number | null + nowMs: number +} + +export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) { + const spanMs = sinceMs === null ? null : nowMs - sinceMs + const presets = useMemo(() => availablePresets(spanMs), [spanMs]) + const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs]) + const month = state.month ? months.find((m) => m.key === state.month) : undefined + const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0 + const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE) + const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0] + const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined + + return ( +
+
+ { + const ok = presets.some((x) => x.value === p.value) + return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' } + })} + value={state.month ? ('' as PresetValue) : state.preset} + onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })} + /> + {months.length > 0 ? ( + + ) : null} +
+ {month ? ( +
+ ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} /> + {pages ? ( + + + + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/web/display/stylelint.config.mjs b/web/display/stylelint.config.mjs new file mode 100644 index 0000000..f600987 --- /dev/null +++ b/web/display/stylelint.config.mjs @@ -0,0 +1,9 @@ +// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌 +export default { + rules: { + 'color-no-hex': true, + 'color-named': 'never', + 'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'], + }, + overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }], +} diff --git a/web/display/tsconfig.app.json b/web/display/tsconfig.app.json new file mode 100644 index 0000000..c085999 --- /dev/null +++ b/web/display/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { "@shared/*": ["../shared/*"] } + }, + "include": ["src", "../shared"], + "exclude": ["../shared/api/schema.ts"] +} diff --git a/web/display/tsconfig.json b/web/display/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/web/display/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/display/tsconfig.node.json b/web/display/tsconfig.node.json new file mode 100644 index 0000000..bdace33 --- /dev/null +++ b/web/display/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/display/vite.config.ts b/web/display/vite.config.ts new file mode 100644 index 0000000..8add9de --- /dev/null +++ b/web/display/vite.config.ts @@ -0,0 +1,23 @@ +import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' +import { defineConfig, loadEnv } from 'vite' + +// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example): +// BACKEND_URL=https://mo.oeo.one npm run dev:display +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '') + const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '') + return { + base: '/', + plugins: [react()], + resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } }, + server: { + port: 5173, + fs: { allow: ['..'] }, + proxy: { + '/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, + '/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' }, + }, + }, + } +}) diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3d541e2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2489 @@ +{ + "name": "monitor-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monitor-web", + "version": "0.1.0", + "workspaces": [ + "display", + "admin" + ] + }, + "admin": { + "name": "monitor-admin", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "stylelint": "^16.12.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "display": { + "name": "monitor-display", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.1.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "stylelint": "^16.12.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@dual-bundle/import-meta-resolve": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/JounQin" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/monitor-admin": { + "resolved": "admin", + "link": true + }, + "node_modules/monitor-display": { + "resolved": "display", + "link": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz", + "integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylelint": { + "version": "16.26.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", + "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-syntax-patches-for-csstree": "^1.0.19", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", + "@csstools/selector-specificity": "^5.0.0", + "@dual-bundle/import-meta-resolve": "^4.2.1", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^9.0.0", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.1", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^7.0.5", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", + "mathml-tag-names": "^2.1.3", + "meow": "^13.2.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.6", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "supports-hyperlinks": "^3.2.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..774a719 --- /dev/null +++ b/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "monitor-web", + "private": true, + "version": "0.1.0", + "workspaces": ["display", "admin"], + "scripts": { + "dev": "node dev.mjs", + "dev:display": "npm run dev -w display", + "dev:admin": "npm run dev -w admin", + "build": "npm run build -w display && npm run build -w admin", + "gen:api": "npx -y openapi-typescript ../backend/openapi/v1.json -o shared/api/schema.ts", + "lint:css": "npm run lint:css -w display && npm run lint:css -w admin" + } +} diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts new file mode 100644 index 0000000..a32692a --- /dev/null +++ b/web/shared/api/client.ts @@ -0,0 +1,65 @@ +import { authHeaders, isRemote, resolveUrl } from './source' + +/** 后端 API 客户端:默认同源请求(开发时由 Vite 代理到后端),Cookie 会话,修改类请求带 CSRF 令牌;指到别的后端时用 Bearer 令牌(见 source.ts)。 */ + +export class ApiError extends Error { + readonly status: number + readonly code: string + readonly detail: string + /** 字段级的校验错误:字段名 → 信息。 */ + readonly errors: Record + + constructor(status: number, code: string, detail: string, errors: Record = {}) { + super(detail || code) + this.status = status + this.code = code + this.detail = detail + this.errors = errors + } +} + +let csrfToken: string | null = null + +export function setCsrf(token: string | null) { + csrfToken = token +} + +export type Query = Record + +export function withQuery(path: string, query?: Query): string { + if (!query) return path + const params = new URLSearchParams() + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null && v !== '') params.set(k, String(v)) + } + const text = params.toString() + return text ? `${path}?${text}` : path +} + +async function request(method: string, path: string, body?: unknown, init?: RequestInit): Promise { + const headers: Record = { Accept: 'application/json', ...authHeaders() } + if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' + if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken + const response = await fetch(resolveUrl(path), { + method, + headers, + credentials: isRemote() ? 'omit' : 'include', + body: body === undefined ? undefined : JSON.stringify(body), + ...init, + }) + if (response.status === 204) return undefined as T + const text = await response.text() + const data = text ? (JSON.parse(text) as unknown) : undefined + if (!response.ok) { + const problem = (data ?? {}) as { code?: string; detail?: string; title?: string; errors?: Record | null } + throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText, problem.errors ?? {}) + } + return data as T +} + +export const api = { + get: (path: string, query?: Query, init?: RequestInit) => request('GET', withQuery(path, query), undefined, init), + post: (path: string, body?: unknown) => request('POST', path, body ?? {}), + patch: (path: string, body: unknown) => request('PATCH', path, body), + del: (path: string) => request('DELETE', path), +} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts new file mode 100644 index 0000000..906018e --- /dev/null +++ b/web/shared/api/schema.ts @@ -0,0 +1,4487 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/public/overview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ranking of all lines over a time range */ + get: operations["overview_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/lines/{line_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** One line */ + get: operations["lines_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/lines/{line_id}/series": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Time series of one line (columnar) */ + get: operations["lines_series"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/compare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** One metric across several lines, aligned on one time axis */ + get: operations["lines_compare"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/lines/{line_id}/distribution": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Histogram and CDF of a histogram metric */ + get: operations["lines_distribution"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/lines/{line_id}/heatmap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Time by latency heat map */ + get: operations["lines_heatmap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/tests/{test_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A test with its result */ + get: operations["tests_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Server-sent events: nodes, overview, series and test topics */ + get: operations["live_stream"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/hosts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Latest host resource usage of every visible agent (no hardware model, no addresses) */ + get: operations["hosts_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/agents/{agent_id}/host": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Host resource usage history of one agent (columnar, averaged per step) */ + get: operations["hosts_history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/catalog/kinds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Measurement kinds with their building blocks and parameter specs */ + get: operations["catalog_kinds"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/catalog/plans": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** On-demand test plans */ + get: operations["catalog_plans"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/catalog/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Metric ids with unit, label, direction, thresholds and version */ + get: operations["catalog_metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/catalog/presets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Metric groups the front end can display */ + get: operations["catalog_presets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/catalog/models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Service models and the metrics they need */ + get: operations["catalog_models"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Sign in with a username and password */ + post: operations["auth_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Sign out and clear the session cookie */ + post: operations["auth_logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The current identity */ + get: operations["auth_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** All agents including pending and disabled ones */ + get: operations["agents_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** One agent */ + get: operations["agents_get"]; + put?: never; + post?: never; + /** Delete an agent; history expires with the retention policy */ + delete: operations["agents_delete"]; + options?: never; + head?: never; + /** Update name, labels, public host or visibility (merge-patch, If-Match supported) */ + patch: operations["agents_update"]; + trace?: never; + }; + "/api/admin/agents/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve a pending agent */ + post: operations["agents_approve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents/{id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable an agent and drop its connection */ + post: operations["agents_disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents/{id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable a disabled agent */ + post: operations["agents_enable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents/{id}/takeover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Replace the existing node of the same name with this pending one, keeping history */ + post: operations["agents_takeover"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/join-tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Join tokens */ + get: operations["join_tokens_list"]; + put?: never; + /** Create a join token; the plaintext is returned only once */ + post: operations["join_tokens_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/join-tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke a join token */ + delete: operations["join_tokens_revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/probes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** All probes, with the agents each one is connected to */ + get: operations["probes_list"]; + put?: never; + /** Create a probe identity; the private key is returned only once */ + post: operations["probes_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/probes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** One probe */ + get: operations["probes_get"]; + put?: never; + post?: never; + /** Delete a probe and its lines; stored data expires with the retention policy */ + delete: operations["probes_delete"]; + options?: never; + head?: never; + /** Rename a probe */ + patch: operations["probes_update"]; + trace?: never; + }; + "/api/admin/probes/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once */ + post: operations["probes_rotate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/probes/{id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Revoke a probe: every agent drops its session and refuses it; history stays until it expires */ + post: operations["probes_revoke"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/agents/{id}/probes/{probe_id}/disconnect": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Make an agent drop one probe's session (the probe reconnects unless it is revoked) */ + post: operations["agents_probe_disconnect"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/tasks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tasks: line templates, per-agent overrides and target tasks */ + get: operations["tasks_list"]; + put?: never; + /** Create a task; params are validated against the kind */ + post: operations["tasks_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/tasks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** One task */ + get: operations["tasks_get"]; + put?: never; + post?: never; + /** Delete a task */ + delete: operations["tasks_delete"]; + options?: never; + head?: never; + /** Change params, schedule, target or enabled (merge-patch, If-Match supported) */ + patch: operations["tasks_update"]; + trace?: never; + }; + "/api/admin/lines/{line_id}/tests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Recent tests of a line */ + get: operations["tests_list"]; + put?: never; + /** Start an on-demand test on a line */ + post: operations["tests_start"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/tests/{id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel a running test */ + post: operations["tests_cancel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/raw/chunks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Raw chunk metadata */ + get: operations["raw_chunks_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/raw/chunks/{chunk_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download a raw chunk (application/vnd.monitor.rawchunk) */ + get: operations["raw_chunks_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Agent events */ + get: operations["events_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/analysis/recompute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Progress of the metric backfill */ + get: operations["analysis_recompute_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/api-tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** API tokens */ + get: operations["api_tokens_list"]; + put?: never; + /** Create an API token (scope read or admin); the plaintext is returned only once */ + post: operations["api_tokens_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/api-tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke an API token */ + delete: operations["api_tokens_revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Site settings and retention periods */ + get: operations["settings_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Change settings (merge-patch) */ + patch: operations["settings_update"]; + trace?: never; + }; + "/api/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Panel users */ + get: operations["users_list"]; + put?: never; + /** Create a panel user */ + post: operations["users_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Change a user's password */ + patch: operations["users_update"]; + trace?: never; + }; + "/healthz": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** SQLite and the raw data directory are both writable */ + get: operations["health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Prometheus metrics (admin token) */ + get: operations["metrics_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/install.sh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Installer for Linux nodes */ + get: operations["install_sh"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/install.ps1": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Installer for Windows nodes */ + get: operations["install_ps1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/agent/v1/enroll": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enroll a node with a join token */ + post: operations["fleet_enroll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AgentDto: { + id: string; + name: string; + status: string; + online: boolean; + probe_connected: boolean; + probes: components["schemas"]["ConnectedProbeDto"][]; + host: null | components["schemas"]["HostDto"]; + public_host: string; + /** Format: int32 */ + probe_tcp_port: number | string; + probe_udp_ports: (number | string)[]; + labels: string[]; + public_visible: boolean; + kinds: string[]; + agent_version: null | string; + local_limits: null | components["schemas"]["LocalLimitsDto"]; + /** Format: int64 */ + task_version: number | string; + /** Format: int64 */ + applied_task_version: number | string; + rejected: components["schemas"]["RejectedDto"][]; + heartbeat: null | components["schemas"]["HeartbeatDto"]; + /** Format: int32 */ + endpoint_changes: number | string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + last_seen_at: unknown; + }; + AgentPatchRequest: { + name: null | string; + labels: null | string[]; + public_host: null | string; + public_visible: null | boolean; + }; + ApiTokenDto: { + id: string; + name: string; + scope: string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + last_used_at: unknown; + /** Format: date-time */ + revoked_at: unknown; + }; + BlocksDto: { + transport: string; + pacer: string; + payload: string; + behavior: string; + samplers: string[]; + }; + BootstrapAgentDto: { + agent_id: string; + host: string; + /** Format: int32 */ + tcp_port: number | string; + spki: string; + }; + ChangePasswordRequest: { + password: string; + }; + ColumnDto: { + name: string; + type: string; + encoding: string; + nullable: boolean; + role: null | string; + }; + CompareDto: { + metric: string; + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + step: string; + source: string; + degraded: boolean; + t: (number | string)[]; + values: { + [key: string]: (null | number | string)[]; + }; + summary: { + [key: string]: null | number | string; + }; + }; + ConnectedProbeDto: { + probe_id: string; + version: null | string; + remote: null | string; + /** Format: date-time */ + connected_since: unknown; + }; + CreateApiTokenRequest: { + name: string; + scope: string; + }; + CreateJoinTokenRequest: { + note: null | string; + labels: null | string[]; + auto_approve: null | boolean; + /** Format: int32 */ + max_uses: null | number | string; + /** Format: int32 */ + expires_in_hours: null | number | string; + }; + CreateProbeRequest: { + name: null | string; + }; + CreateTaskRequest: { + scope: string; + kind: string; + target: null | string; + params: null | Record; + schedule: null | string; + enabled: null | boolean; + agent_id: null | string; + base_task_id: null | string; + }; + CreateUserRequest: { + username: string; + password: string; + }; + CreatedApiTokenDto: { + token: components["schemas"]["ApiTokenDto"]; + plaintext: string; + }; + CreatedJoinTokenDto: { + token: components["schemas"]["JoinTokenDto"]; + plaintext: string; + install_command: string; + }; + DimensionDto: { + id: string; + label: string; + /** Format: double */ + weight: number | string; + /** Format: double */ + value: null | number | string; + /** Format: double */ + score: null | number | string; + }; + DistributionBucketDto: { + /** Format: double */ + le: number | string; + /** Format: int64 */ + count: number | string; + }; + DistributionDto: { + metric: string; + unit: string; + buckets: components["schemas"]["DistributionBucketDto"][]; + /** Format: double */ + p50: null | number | string; + /** Format: double */ + p95: null | number | string; + /** Format: double */ + p99: null | number | string; + /** Format: int64 */ + count: number | string; + }; + EdgePartialDto: { + first: boolean; + last: boolean; + }; + EventDto: { + /** Format: int64 */ + id: number | string; + agent_id: null | string; + kind: string; + /** Format: date-time */ + at: unknown; + detail: null | string; + }; + GapDto: { + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + reason: string; + }; + HealthDto: { + status: string; + database: boolean; + raw_directory: boolean; + }; + HeartbeatDto: { + /** Format: int32 */ + sessions: number | string; + /** Format: int64 */ + pps_in: number | string; + /** Format: int64 */ + pps_out: number | string; + /** Format: int64 */ + dropped_invalid: number | string; + /** Format: int64 */ + dropped_rate_limited: number | string; + /** Format: int64 */ + queue_bytes: number | string; + /** Format: date-time */ + at: unknown; + }; + HeatmapDto: { + metric: string; + step: string; + source: string; + degraded: boolean; + t: (number | string)[]; + edges: (number | string)[]; + counts: (number | string)[][]; + }; + HostDto: { + /** Format: double */ + cpu_percent: null | number | string; + /** Format: int64 */ + mem_used: null | number | string; + /** Format: int64 */ + mem_total: null | number | string; + /** Format: int64 */ + disk_used: null | number | string; + /** Format: int64 */ + disk_total: null | number | string; + /** Format: int64 */ + net_rx_total: null | number | string; + /** Format: int64 */ + net_tx_total: null | number | string; + /** Format: int64 */ + net_rx_bps: null | number | string; + /** Format: int64 */ + net_tx_bps: null | number | string; + /** Format: int64 */ + uptime_seconds: null | number | string; + /** Format: date-time */ + at: unknown; + }; + HostItemDto: { + agent_id: string; + name: string; + online: boolean; + stale: boolean; + /** Format: date-time */ + last_seen_at: unknown; + host: null | components["schemas"]["HostDto"]; + }; + HostSeriesDto: { + agent_id: string; + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + step: string; + t: (number | string)[]; + values: { + [key: string]: (null | number | string)[]; + }; + }; + JoinTokenDto: { + id: string; + note: null | string; + labels: string[]; + auto_approve: boolean; + /** Format: int32 */ + max_uses: number | string; + /** Format: int32 */ + uses: number | string; + /** Format: date-time */ + expires_at: unknown; + /** Format: date-time */ + revoked_at: unknown; + /** Format: date-time */ + created_at: unknown; + }; + JsonElement: unknown; + KindDto: { + id: string; + title: string; + description: string; + scopes: string[]; + blocks: components["schemas"]["BlocksDto"]; + params: components["schemas"]["ParamDto"][]; + streams: components["schemas"]["StreamDto"][]; + }; + LineDto: { + id: string; + agent_id: string; + probe_id: string; + probe_name: string; + name: string; + labels: string[]; + online: boolean; + probe_connected: boolean; + /** Format: date-time */ + data_since: unknown; + kinds: string[]; + last_test: null | components["schemas"]["TestSummaryDto"]; + }; + LocalLimitsDto: { + /** Format: double */ + max_total_mbps: number | string; + /** Format: int64 */ + max_total_pps: number | string; + allowed_target_cidrs: string[]; + /** Format: int32 */ + max_concurrent_tests: number | string; + }; + LoginRequest: { + username: string; + password: string; + }; + LoginResponse: { + user_id: string; + username: string; + csrf_token: string; + /** Format: date-time */ + expires_at: unknown; + }; + MeDto: { + kind: string; + scope: string; + user_id: null | string; + username: null | string; + csrf_token: null | string; + }; + MetricDto: { + id: string; + definition_id: string; + accumulator: string; + stat: string; + label: string; + description: string; + unit: string; + better: string; + /** Format: double */ + good: null | number | string; + /** Format: double */ + poor: null | number | string; + version: string; + from: string[]; + }; + ModelDto: { + id: string; + title: string; + /** Format: int32 */ + version: number | string; + needs: string[]; + }; + OverviewDto: { + range: string; + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + step: string; + source: string; + degraded: boolean; + lines: components["schemas"]["OverviewLineDto"][]; + }; + OverviewLineDto: { + id: string; + agent_id: string; + probe_id: string; + probe_name: string; + name: string; + labels: string[]; + online: boolean; + probe_connected: boolean; + /** Format: int32 */ + endpoint_changes: number | string; + summary: components["schemas"]["SummaryDto"]; + last_test: null | components["schemas"]["TestSummaryDto"]; + }; + PageDtoOfAgentDto: { + items: components["schemas"]["AgentDto"][]; + next_cursor: null | string; + }; + PageDtoOfApiTokenDto: { + items: components["schemas"]["ApiTokenDto"][]; + next_cursor: null | string; + }; + PageDtoOfEventDto: { + items: components["schemas"]["EventDto"][]; + next_cursor: null | string; + }; + PageDtoOfHostItemDto: { + items: components["schemas"]["HostItemDto"][]; + next_cursor: null | string; + }; + PageDtoOfJoinTokenDto: { + items: components["schemas"]["JoinTokenDto"][]; + next_cursor: null | string; + }; + PageDtoOfKindDto: { + items: components["schemas"]["KindDto"][]; + next_cursor: null | string; + }; + PageDtoOfMetricDto: { + items: components["schemas"]["MetricDto"][]; + next_cursor: null | string; + }; + PageDtoOfModelDto: { + items: components["schemas"]["ModelDto"][]; + next_cursor: null | string; + }; + PageDtoOfPlanDto: { + items: components["schemas"]["PlanDto"][]; + next_cursor: null | string; + }; + PageDtoOfPresetDto: { + items: components["schemas"]["PresetDto"][]; + next_cursor: null | string; + }; + PageDtoOfProbeDto: { + items: components["schemas"]["ProbeDto"][]; + next_cursor: null | string; + }; + PageDtoOfRawChunkMetaDto: { + items: components["schemas"]["RawChunkMetaDto"][]; + next_cursor: null | string; + }; + PageDtoOfSettingDto: { + items: components["schemas"]["SettingDto"][]; + next_cursor: null | string; + }; + PageDtoOfTaskDto: { + items: components["schemas"]["TaskDto"][]; + next_cursor: null | string; + }; + PageDtoOfTestDto: { + items: components["schemas"]["TestDto"][]; + next_cursor: null | string; + }; + PageDtoOfUserDto: { + items: components["schemas"]["UserDto"][]; + next_cursor: null | string; + }; + ParamDto: { + name: string; + type: string; + default: components["schemas"]["JsonElement"]; + /** Format: double */ + min: null | number | string; + /** Format: double */ + max: null | number | string; + unit: null | string; + values: null | string[]; + description: string; + }; + PatchProbeRequest: { + name: null | string; + }; + PatchTaskRequest: { + params: null | Record; + schedule: null | string; + enabled: null | boolean; + target: null | string; + }; + PhaseDto: { + kind: string; + /** Format: int32 */ + duration_s: number | string; + params: components["schemas"]["JsonElement"]; + }; + PlanDto: { + id: string; + title: string; + description: string; + phases: components["schemas"]["PhaseDto"][]; + }; + PresetDto: { + id: string; + label: string; + metrics: string[]; + }; + ProbeConnectionDto: { + agent_id: string; + agent_name: string; + agent_online: boolean; + version: null | string; + remote: null | string; + /** Format: date-time */ + connected_since: unknown; + }; + ProbeDto: { + id: string; + name: string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + revoked_at: unknown; + revoked: boolean; + connections: components["schemas"]["ProbeConnectionDto"][]; + }; + ProbeIdentityDto: { + probe_id: string; + private_key: string; + bootstrap: components["schemas"]["BootstrapAgentDto"][]; + limits: components["schemas"]["ProbeLimitsDto"]; + }; + ProbeLimitsDto: { + /** Format: double */ + max_test_mbps: number | string; + /** Format: int64 */ + max_total_pps: number | string; + }; + ProblemDto: { + type: string; + title: string; + /** Format: int32 */ + status: number | string; + code: string; + detail: null | string; + trace_id: null | string; + errors: null | { + [key: string]: string[]; + }; + }; + RawChunkMetaDto: { + chunk_id: string; + agent_id: string; + series: string; + kind: string; + task_id: null | string; + test_id: null | string; + /** Format: int32 */ + phase: null | number | string; + /** Format: date-time */ + start: unknown; + /** Format: int32 */ + duration_ms: number | string; + partial: boolean; + /** Format: int32 */ + bytes: number | string; + }; + RecomputeDto: { + state: string; + metrics: string[]; + /** Format: int64 */ + windows_done: number | string; + /** Format: int64 */ + windows_total: number | string; + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + /** Format: int32 */ + queue_length: number | string; + }; + RejectedDto: { + id: string; + reason: string; + }; + ScoreDto: { + /** Format: double */ + score: null | number | string; + /** Format: int32 */ + version: number | string; + estimated: boolean; + dimensions: components["schemas"]["DimensionDto"][]; + caveats: string[]; + }; + SeriesDto: { + line_id: string; + agent_id: string; + probe_id: string; + /** Format: date-time */ + from: unknown; + /** Format: date-time */ + to: unknown; + step: string; + source: string; + live: boolean; + degraded: boolean; + t: (number | string)[]; + values: { + [key: string]: (null | number | string)[]; + }; + summary: components["schemas"]["SummaryDto"]; + edge_partial: components["schemas"]["EdgePartialDto"]; + gaps: components["schemas"]["GapDto"][]; + events: components["schemas"]["EventDto"][]; + versions: { + [key: string]: string; + }; + }; + SettingDto: { + key: string; + type: string; + value: string; + default: string; + description: string; + }; + StartTestRequestDto: { + plan: string; + params: null | Record; + }; + StreamDto: { + name: string; + columns: components["schemas"]["ColumnDto"][]; + }; + SummaryDto: { + metrics: { + [key: string]: null | number | string; + }; + samples: { + [key: string]: number | string; + }; + /** Format: double */ + coverage: number | string; + }; + TaskDto: { + id: string; + scope: string; + agent_id: null | string; + base_task_id: null | string; + kind: string; + target: null | string; + params: Record; + schedule: string; + enabled: boolean; + /** Format: date-time */ + updated_at: unknown; + }; + TestDto: { + id: string; + agent_id: string; + probe_id: string; + plan: string; + phases: string[]; + status: string; + /** Format: int32 */ + phase: null | number | string; + error_code: null | string; + trigger: string; + /** Format: date-time */ + requested_at: unknown; + /** Format: date-time */ + started_at: unknown; + /** Format: date-time */ + finished_at: unknown; + result: null | components["schemas"]["TestResultDto"]; + }; + TestResultDto: { + metrics: { + [key: string]: number | string; + }; + scores: { + [key: string]: components["schemas"]["ScoreDto"]; + }; + series: { + [key: string]: (null | number | string)[]; + }; + steps: components["schemas"]["TestStepDto"][]; + caveats: string[]; + }; + TestStepDto: { + /** Format: int32 */ + index: number | string; + /** Format: double */ + target_mbps: number | string; + /** Format: double */ + loss_pct: number | string; + /** Format: double */ + jitter_p95_ms: null | number | string; + /** Format: double */ + delivered_mbps: number | string; + }; + TestSummaryDto: { + id: string; + plan: string; + status: string; + /** Format: date-time */ + requested_at: unknown; + /** Format: date-time */ + finished_at: unknown; + }; + UserDto: { + id: string; + username: string; + /** Format: date-time */ + created_at: unknown; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + overview_get: { + parameters: { + query?: { + range?: string; + from?: unknown; + to?: unknown; + preset?: string; + metrics?: string; + sort?: string; + order?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OverviewDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + lines_get: { + parameters: { + query?: never; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LineDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + lines_series: { + parameters: { + query?: { + range?: string; + from?: unknown; + to?: unknown; + step?: string; + tz?: string; + preset?: string; + metrics?: string; + anchor?: string; + }; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SeriesDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + lines_compare: { + parameters: { + query: { + lines?: string; + metric: string; + range?: string; + from?: unknown; + to?: unknown; + step?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CompareDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + lines_distribution: { + parameters: { + query: { + metric: string; + range?: string; + from?: unknown; + to?: unknown; + }; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DistributionDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + lines_heatmap: { + parameters: { + query: { + metric: string; + range?: string; + from?: unknown; + to?: unknown; + step?: string; + }; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HeatmapDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tests_get: { + parameters: { + query?: never; + header?: never; + path: { + test_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TestDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + live_stream: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + hosts_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfHostItemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + hosts_history: { + parameters: { + query?: { + range?: string; + }; + header?: never; + path: { + agent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HostSeriesDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + catalog_kinds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfKindDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + catalog_plans: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfPlanDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + catalog_metrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfMetricDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + catalog_presets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfPresetDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + catalog_models: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfModelDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfAgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/merge-patch+json": components["schemas"]["AgentPatchRequest"]; + "application/json": components["schemas"]["AgentPatchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_approve: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_disable: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_enable: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_takeover: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + join_tokens_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfJoinTokenDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + join_tokens_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateJoinTokenRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreatedJoinTokenDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + join_tokens_revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfProbeDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": null | components["schemas"]["CreateProbeRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeIdentityDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PatchProbeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_rotate: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeIdentityDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + probes_revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + agents_probe_disconnect: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + probe_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tasks_list: { + parameters: { + query?: { + agent_id?: string; + scope?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfTaskDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tasks_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTaskRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tasks_get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tasks_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tasks_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/merge-patch+json": components["schemas"]["PatchTaskRequest"]; + "application/json": components["schemas"]["PatchTaskRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tests_list: { + parameters: { + query?: { + limit?: number | string; + }; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfTestDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tests_start: { + parameters: { + query?: never; + header?: never; + path: { + line_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StartTestRequestDto"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TestDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + tests_cancel: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TestDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + raw_chunks_list: { + parameters: { + query?: { + agent_id?: string; + kind?: string; + test_id?: string; + from?: unknown; + to?: unknown; + limit?: number | string; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfRawChunkMetaDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + raw_chunks_get: { + parameters: { + query?: never; + header?: never; + path: { + chunk_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Gone */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + events_list: { + parameters: { + query?: { + agent_id?: string; + kind?: string; + from?: unknown; + to?: unknown; + limit?: number | string; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfEventDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + analysis_recompute_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecomputeDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + api_tokens_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfApiTokenDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + api_tokens_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiTokenRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreatedApiTokenDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + api_tokens_revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + settings_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfSettingDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + settings_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/merge-patch+json": Record; + "application/json": Record; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfSettingDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + users_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfUserDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + users_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateUserRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + users_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthDto"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + metrics_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + install_sh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + install_ps1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + fleet_enroll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/web/shared/api/source.ts b/web/shared/api/source.ts new file mode 100644 index 0000000..15c33ea --- /dev/null +++ b/web/shared/api/source.ts @@ -0,0 +1,132 @@ +/** + * 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`): + * 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。 + * 跨域时不带 Cookie,只用 `Authorization: Bearer `;后端要在 `Monitor__CorsOrigins` 里放行这个前端的来源。 + */ + +const BASE_KEY = 'monitor.api_base' + +let app = 'app' + +/** 每个前端启动时调用一次,令牌按前端分开存(展示端用只读令牌,管理端用管理令牌)。 */ +export function configureSource(name: string) { + app = name +} + +function read(key: string): string { + try { + return localStorage.getItem(key) ?? '' + } catch { + return '' + } +} + +function write(key: string, value: string) { + try { + if (value) localStorage.setItem(key, value) + else localStorage.removeItem(key) + } catch { + // 浏览器禁用存储时只是不记住 + } +} + +export function normalizeBase(input: string): string { + const text = input.trim().replace(/\/+$/, '') + if (!text) return '' + return /^https?:\/\//i.test(text) ? text : `https://${text}` +} + +/** 后端地址(不带末尾斜杠);空串 = 同源。 */ +export function getBase(): string { + return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '') +} + +export function getToken(): string { + return read(`monitor.api_token.${app}`) +} + +export function setSource(base: string, token: string) { + write(BASE_KEY, normalizeBase(base)) + write(`monitor.api_token.${app}`, token.trim()) +} + +export function setToken(token: string) { + write(`monitor.api_token.${app}`, token.trim()) +} + +export const isRemote = () => getBase() !== '' + +/** 拼出请求地址:同源时原样返回。 */ +export function resolveUrl(path: string): string { + return getBase() + path +} + +export function authHeaders(): Record { + const token = getToken() + return isRemote() && token ? { Authorization: `Bearer ${token}` } : {} +} + +export interface LiveSource { + onopen: (() => void) | null + onerror: (() => void) | null + addEventListener: (type: string, listener: (event: MessageEvent) => void) => void + close: () => void +} + +/** + * SSE 订阅。同源用浏览器的 EventSource;跨域时 EventSource 带不了 Authorization 头, + * 所以用 fetch 读流并自己解析,断开后 3 秒重连。 + */ +export function openLive(path: string): LiveSource { + if (!isRemote()) return new EventSource(path) as unknown as LiveSource + + const listeners = new Map) => void)[]>() + const source: LiveSource = { + onopen: null, + onerror: null, + addEventListener: (type, listener) => listeners.set(type, [...(listeners.get(type) ?? []), listener]), + close: () => { + closed = true + controller?.abort() + }, + } + let closed = false + let controller: AbortController | undefined + + const run = async () => { + while (!closed) { + controller = new AbortController() + try { + const response = await fetch(resolveUrl(path), { headers: { Accept: 'text/event-stream', ...authHeaders() }, signal: controller.signal }) + if (!response.ok || !response.body) throw new Error(String(response.status)) + source.onopen?.() + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + let buffer = '' + for (;;) { + const { value, done } = await reader.read() + if (done) break + buffer += value + let at: number + while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) { + const block = buffer.slice(0, at) + buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '') + let type = 'message' + const data: string[] = [] + for (const line of block.split(/\r?\n/)) { + if (line.startsWith('event:')) type = line.slice(6).trim() + else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) + } + if (data.length > 0) for (const l of listeners.get(type) ?? []) l({ data: data.join('\n') } as MessageEvent) + } + } + } catch { + // 下面统一报错并重连 + } + if (closed) return + source.onerror?.() + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + } + void run() + return source +} diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts new file mode 100644 index 0000000..e4ee9ca --- /dev/null +++ b/web/shared/api/types.ts @@ -0,0 +1,47 @@ +import type { components } from './schema' + +type S = components['schemas'] + +/** 生成的类型把整数写成 number | string(后端的 JSON 序列化允许字符串):这里统一成 number。 */ +type NarrowValue = unknown extends V ? string : number extends V ? Exclude : V extends readonly (infer U)[] ? NarrowValue[] : V extends object ? Narrow : V +type Narrow = { [K in keyof T]: NarrowValue } + +export type AgentDto = Narrow +export type ConnectedProbeDto = Narrow +export type ProbeDto = Narrow +export type ProbeConnectionDto = Narrow +export type ProbeIdentityDto = Narrow +export type LineDto = Narrow +export type OverviewDto = Narrow +export type OverviewLineDto = Narrow +export type SeriesDto = Narrow +export type CompareDto = Narrow +export type SummaryDto = Narrow +export type TaskDto = Narrow +export type TestDto = Narrow +export type TestResultDto = Narrow +export type ScoreDto = Narrow +export type PlanDto = Narrow +export type KindDto = Narrow +export type ParamDto = Narrow +export type MetricDto = Narrow +export type JoinTokenDto = Narrow +export type CreatedJoinTokenDto = Narrow +export type ApiTokenDto = Narrow +export type CreatedApiTokenDto = Narrow +export type SettingDto = Narrow +export type UserDto = Narrow +export type EventDto = Narrow +export type GapDto = Narrow +export type RecomputeDto = Narrow +export type RawChunkMetaDto = Narrow +export type LoginResponse = Narrow +export type MeDto = Narrow + +export interface Page { + items: T[] + next_cursor?: string | null +} + +/** 后端数字有时按 integer|string 声明:统一成 number。 */ +export const n = (v: number | string | null | undefined): number => (v === null || v === undefined ? 0 : Number(v)) diff --git a/web/shared/charts/TimeChart.tsx b/web/shared/charts/TimeChart.tsx new file mode 100644 index 0000000..0cbb91c --- /dev/null +++ b/web/shared/charts/TimeChart.tsx @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import uPlot, { type AlignedData, type Options } from 'uplot' +import 'uplot/dist/uPlot.min.css' + +export interface ChartSeries { + label: string + values: (number | null)[] + /** CSS 变量名,例如 --chart-1。 */ + color: string + dash?: number[] + fill?: boolean +} + +interface Props { + /** 时间轴,Unix 毫秒。 */ + t: number[] + series: ChartSeries[] + height?: number + /** Y 轴的单位后缀(ms、%)。 */ + unit?: string + /** 为真时 Y 轴从 0 开始。 */ + zero?: boolean + /** 缺口:这些时间段用底色标出。 */ + gaps?: { from: number; to: number }[] +} + +function cssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() +} + +function formatValue(v: number | null | undefined, unit: string): string { + if (v === null || v === undefined) return '—' + const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2) + return `${text}${unit}` +} + +/** + * uPlot 的封装: + * - 跟随主题(明暗切换时重绘)和容器宽度,空值断线; + * - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间; + * - 鼠标悬停显示这个时刻每条线的值。 + */ +export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) { + const host = useRef(null) + const tip = useRef(null) + const plot = useRef(null) + const zoom = useRef<{ min: number; max: number } | null>(null) + const [zoomed, setZoomed] = useState(false) + + const reset = useCallback(() => { + zoom.current = null + setZoomed(false) + const u = plot.current + if (u && u.data[0].length > 0) u.setScale('x', { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] }) + }, []) + + useEffect(() => { + const el = host.current + if (!el) return + const build = () => { + plot.current?.destroy() + const axis = cssVar('--chart-axis') + const grid = cssVar('--chart-grid') + const font = '12px ' + (cssVar('--font-sans') || 'sans-serif') + const shade = cssVar('--app-warn-container') + const options: Options = { + width: el.clientWidth || 600, + height, + legend: { show: false }, + cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } }, + select: { show: true, left: 0, top: 0, width: 0, height: 0 }, + scales: { + x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) }, + y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] }, + }, + axes: [ + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font }, + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) }, + ], + series: [ + {}, + ...series.map((s) => { + const color = cssVar(s.color) + return { + label: s.label, + stroke: color, + width: 1.8, + dash: s.dash, + fill: s.fill ? color + '22' : undefined, + spanGaps: false, + points: { show: false }, + } + }), + ], + hooks: { + ready: [ + (u) => { + u.over.addEventListener('dblclick', reset) + }, + ], + setSelect: [ + (u) => { + if (u.select.width < 6) return + const min = u.posToVal(u.select.left, 'x') + const max = u.posToVal(u.select.left + u.select.width, 'x') + if (max - min < 5) return + zoom.current = { min, max } + setZoomed(true) + u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false) + u.setScale('x', { min, max }) + }, + ], + setCursor: [ + (u) => { + const box = tip.current + if (!box) return + const idx = u.cursor.idx + if (idx === null || idx === undefined || (u.cursor.left ?? -1) < 0) { + box.style.display = 'none' + return + } + const when = new Date(u.data[0][idx] * 1000) + const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })] + for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`) + box.textContent = lines.join('\n') + box.style.display = 'block' + const width = box.offsetWidth + const left = (u.cursor.left ?? 0) + 14 + box.style.left = `${left + width > u.over.clientWidth ? Math.max(0, (u.cursor.left ?? 0) - width - 14) : left}px` + box.style.top = '8px' + }, + ], + drawClear: [ + (u) => { + const { ctx } = u + ctx.save() + ctx.fillStyle = shade + for (const g of gaps) { + const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true)) + const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true)) + if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height) + } + ctx.restore() + }, + ], + }, + } + const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))] + plot.current = new uPlot(options, data, el) + // 把提示框放进绘图区,这样它的位置和光标是同一个坐标系 + if (tip.current) plot.current.over.appendChild(tip.current) + } + build() + const resize = new ResizeObserver(() => plot.current?.setSize({ width: el.clientWidth, height })) + resize.observe(el) + const theme = new MutationObserver(build) + theme.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => { + resize.disconnect() + theme.disconnect() + plot.current?.destroy() + plot.current = null + } + }, [t, series, height, unit, zero, gaps, reset]) + + return ( +
+
+ {series.map((s) => ( + + + {s.label} + + ))} + + {zoomed ? ( + + ) : ( + '拖动选择一段放大,双击还原' + )} + +
+
+
+
+ ) +} diff --git a/web/shared/format.ts b/web/shared/format.ts new file mode 100644 index 0000000..290c459 --- /dev/null +++ b/web/shared/format.ts @@ -0,0 +1,51 @@ +export function fmtMs(v: number | null | undefined, digits = 1): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—' + if (v >= 1000) return `${(v / 1000).toFixed(2)} s` + return `${v.toFixed(v >= 100 ? 0 : digits)} ms` +} + +export function fmtPct(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—' + return `${v.toFixed(v >= 10 ? 1 : digits)}%` +} + +export function fmtMbps(v: number | null | undefined): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—' + return v >= 100 ? `${v.toFixed(0)} Mbit/s` : `${v.toFixed(1)} Mbit/s` +} + +export function fmtNum(v: number | null | undefined, digits = 0): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—' + return v.toLocaleString('zh-CN', { maximumFractionDigits: digits }) +} + +export function fmtTime(iso: string | null | undefined): string { + if (!iso) return '—' + const d = new Date(iso) + return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +export function fmtAgo(iso: string | null | undefined): string { + if (!iso) return '—' + const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) + if (seconds < 60) return `${Math.floor(seconds)} 秒前` + if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前` + if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前` + return `${Math.floor(seconds / 86400)} 天前` +} + +export function fmtDuration(ms: number): string { + const s = Math.floor(ms / 1000) + if (s < 60) return `${s} 秒` + if (s < 3600) return `${Math.floor(s / 60)} 分钟` + if (s < 86400) return `${Math.floor(s / 3600)} 小时 ${Math.floor((s % 3600) / 60)} 分钟` + return `${Math.floor(s / 86400)} 天 ${Math.floor((s % 86400) / 3600)} 小时` +} + +/** 评分 → 状态色(和后端模型的分档一致:≥85 好,≥60 一般,其余差)。 */ +export function scoreTone(score: number | null | undefined): 'ok' | 'warn' | 'bad' | '' { + if (score === null || score === undefined) return '' + if (score >= 85) return 'ok' + if (score >= 60) return 'warn' + return 'bad' +} diff --git a/web/shared/hooks.ts b/web/shared/hooks.ts new file mode 100644 index 0000000..4f74059 --- /dev/null +++ b/web/shared/hooks.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ApiError } from './api/client' + +export interface QueryState { + data: T | undefined + error: ApiError | Error | undefined + loading: boolean + reload: () => void +} + +/** 取数:依赖变化时重新取;可选轮询。重新取数时保留上一次的数据,避免界面闪烁。 */ +export function useQuery(fetcher: () => Promise, deps: unknown[], intervalMs?: number): QueryState { + const [data, setData] = useState() + const [error, setError] = useState() + const [loading, setLoading] = useState(true) + const [tick, setTick] = useState(0) + const fetcherRef = useRef(fetcher) + fetcherRef.current = fetcher + + useEffect(() => { + let alive = true + setLoading(true) + fetcherRef + .current() + .then((value) => { + if (!alive) return + setData(value) + setError(undefined) + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e : new Error(String(e))) + }) + .finally(() => { + if (alive) setLoading(false) + }) + return () => { + alive = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, tick]) + + useEffect(() => { + if (!intervalMs) return + const id = window.setInterval(() => setTick((t) => t + 1), intervalMs) + return () => window.clearInterval(id) + }, [intervalMs]) + + const reload = useCallback(() => setTick((t) => t + 1), []) + return { data, error, loading, reload } +} + +export type Theme = 'light' | 'dark' + +export function useTheme(): [Theme, () => void] { + const [theme, setTheme] = useState(() => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')) + const toggle = useCallback(() => { + const next: Theme = theme === 'dark' ? 'light' : 'dark' + document.documentElement.dataset.theme = next + try { + localStorage.setItem('monitor-theme', next) + } catch { + // 存储不可用时只在本次会话生效 + } + setTheme(next) + }, [theme]) + return [theme, toggle] +} + +/** 首屏之前调用:按保存的选择或系统偏好设置主题。 */ +export function applyStoredTheme() { + let theme: string | null = null + try { + theme = localStorage.getItem('monitor-theme') + } catch { + // 忽略 + } + const dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches + document.documentElement.dataset.theme = dark ? 'dark' : 'light' +} diff --git a/web/shared/i18n.ts b/web/shared/i18n.ts new file mode 100644 index 0000000..504e1d7 --- /dev/null +++ b/web/shared/i18n.ts @@ -0,0 +1,162 @@ +/** + * 后端的错误信息是英文(API 契约就是英文):前端在这里把常见的错误码、字段名和校验信息翻成中文。 + * 没有收录的信息原样显示,不会丢。 + */ + +const CODES: Record = { + validation_failed: '填写的内容有问题', + budget_exceeded: '超出了这台节点本地设定的带宽或包速率上限', + unauthorized: '用户名或密码不对,或者登录已经失效', + csrf_failed: '登录状态已经过期,请刷新页面重新登录', + forbidden: '没有权限做这件事', + site_private: '站点未公开:请管理员登录,或在管理端把站点设为公开', + not_found: '没有找到', + agent_not_found: '没有找到这个 Agent', + probe_not_found: '没有找到这个 Probe', + probe_revoked: '这个 Probe 已经被吊销,不能再换密钥,请新建一个 Probe', + probe_required: '有多个 Probe,请指定具体的线路', + task_not_found: '没有找到这个任务', + test_not_found: '没有找到这次测试', + chunk_not_found: '没有找到这个原始数据块', + token_not_found: '没有找到这个令牌', + unknown_kind: '不认识这种采集方法', + unknown_plan: '不认识这个测试方案', + name_conflict: '名称已经被使用,换一个', + probe_offline: '这个 Probe 现在没有连着这个 Agent', + test_busy: '这条线路上已经有测试在运行', + agent_not_active: '这个 Agent 还没有启用', + agent_offline: 'Agent 不在线', + raw_expired: '这份原始数据已经超过保留期限', + join_token_expired: '加入令牌已经过期', + precondition_failed: '这条记录刚被别人改过,请刷新后再改', + payload_too_large: '提交的内容太大', + range_too_large: '选的时间范围太大,请缩短或增大步长', + invalid_step: '步长不合法', + invalid_range: '时间范围不合法', + unknown_metric: '不认识这个指标', + unknown_preset: '不认识这个指标分组', + too_many_metrics: '一次选的指标太多', + too_many_requests: '请求太频繁,请稍后再试', + too_many_streams: '实时连接太多', + storage_unavailable: '存储暂时不可用,请稍后再试', + fleet_unsupported: '这个 Agent 不支持这项功能', + fleet_join_token_invalid: '加入令牌无效', + fleet_node_disabled: '这个节点已被停用', + internal_error: '服务器出错了,请稍后再试', + method_not_allowed: '不支持这个操作', + unsupported_media_type: '提交的格式不对', +} + +const FIELDS: Record = { + name: '名称', + username: '用户名', + password: '密码', + note: '备注', + labels: '标签', + public_host: '公开地址', + public_visible: '展示页可见', + max_uses: '可用次数', + expires_in_hours: '有效期', + scope: '类型', + kind: '采集方法', + target: '目标', + schedule: '调度', + agent_id: 'Agent', + base_task_id: '要覆盖的模板', + plan: '方案', + params: '参数', + range: '时间范围', + from: '开始时间', + to: '结束时间', + step: '步长', + lines: '线路', + metric: '指标', + metrics: '指标', + cursor: '分页游标', + anchor: '时间锚点', + tz: '时区', +} + +const MESSAGES: [RegExp, (m: RegExpMatchArray) => string][] = [ + [/^is required$/, () => '不能为空'], + [/^must be 1-64 characters( of letters, digits|: letters \(any language\), digits), '\.', '_' or '-'$/, () => '只能是 1–64 个字、字母、数字、点、下划线或连字符(可以用中文)'], + [/^must be 3-32 characters of letters, digits, '\.', '_' or '-'$/, () => '只能是 3–32 个字母、数字、点、下划线或连字符'], + [/^must be 1-64 characters$/, () => '长度需要 1–64 个字符'], + [/^must be (\d+)-(\d+) characters$/, (m) => `长度需要 ${m[1]}–${m[2]} 个字符`], + [/^must be at most (\d+) characters$/, (m) => `最多 ${m[1]} 个字符`], + [/^at most (\d+) labels of (\d+)-(\d+) characters$/, (m) => `最多 ${m[1]} 个标签,每个 ${m[2]}–${m[3]} 个字符`], + [/^must be a host name or IP address$/, () => '必须是域名或 IP 地址'], + [/^must be an IP address with an optional port$/, () => '必须是 IP 地址,端口可选'], + [/^must be between (-?[\d.]+) and (-?[\d.]+)$/, (m) => `必须在 ${m[1]} 到 ${m[2]} 之间`], + [/^must be an integer$/, () => '必须是整数'], + [/^must be a number$/, () => '必须是数字'], + [/^must be a boolean$/, () => '必须是 是 / 否'], + [/^must be one of: (.*)$/, (m) => `只能是:${m[1]}`], + [/^is not a parameter of this kind$/, () => '这种采集方法没有这个参数'], + [/^must be line or target$/, () => '只能是线路任务或目标任务'], + [/^must be read or admin$/, () => '只能是 read(只读)或 admin(全部权限)'], + [/^must be now or latest$/, () => '只能是 now 或 latest'], + [/^is not a valid cursor$/, () => '不是有效的分页游标'], + [/^is mutually exclusive with from and to$/, () => '不能和开始、结束时间同时使用'], + [/^only UTC is supported at the moment$/, () => '目前只支持 UTC'], + [/^line tasks have no target$/, () => '线路任务没有目标'], + [/^agent_id is required for target tasks$/, () => '目标任务必须指定 Agent'], + [/^per-agent line tasks must be overrides \(base_task_id\)$/, () => '针对某个 Agent 的线路任务必须是覆盖(要选一个模板)'], + [/^must reference a line template and requires agent_id$/, () => '必须引用一个线路模板,并且要指定 Agent'], + [/^an override must use the same kind as its template$/, () => '覆盖必须和它的模板是同一种采集方法'], + [/^once tasks are created through the tests API$/, () => '一次性测试请在「按需测试」里发起'], + [/^unknown kind (.+)$/, (m) => `不认识采集方法 ${m[1]}`], + [/^unknown plan (.+)$/, (m) => `不认识测试方案 ${m[1]}`], + [/^a probe named (.+) already exists$/, (m) => `已经有一个叫 ${m[1]} 的 Probe`], + [/^probe (\S+) has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'], + [/^probe (\S+) is not connected to this agent$/, () => '这个 Probe 现在没有连着这个 Agent'], + [/^this probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'], + [/^the probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'], + [/^another test is already running on this line$/, () => '这条线路上已经有测试在运行'], + [/^agent is offline$/, () => 'Agent 不在线'], + [/^agent is not active$/, () => 'Agent 还没有启用'], + [/^agent not found$/, () => '没有找到这个 Agent'], + [/^probe not found$/, () => '没有找到这个 Probe'], + [/^line not found$/, () => '没有找到这条线路'], + [/^there is no active probe$/, () => '现在没有可用的 Probe'], + [/^several probes exist.*$/, () => '有多个 Probe,请指定具体的线路'], + [/^a revoked probe cannot get a new key.*$/, () => '已吊销的 Probe 不能换密钥,请新建一个 Probe'], + [/^this agent does not support (\S+), which the (\S+) plan needs$/, (m) => `这个 Agent 不支持 ${m[1]},而 ${m[2]} 方案需要它`], + [/^the agent did not accept the test$/, () => 'Agent 没有接受这次测试'], + [/^a valid X-CSRF-Token header is required$/, () => '登录状态已经过期,请刷新页面重新登录'], +] + +export function translateMessage(message: string): string { + const text = message.trim() + for (const [pattern, render] of MESSAGES) { + const match = text.match(pattern) + if (match) return render(match) + } + return text +} + +export const translateField = (field: string): string => FIELDS[field] ?? field + +export function translateCode(code: string): string | undefined { + return CODES[code] +} + +const SETTINGS: Record = { + 'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)', + 'retention.raw_days': '持续任务的原始数据块保留多少天', + 'retention.test_raw_days': '按需测试的原始数据块保留多少天', + 'retention.metric_10s_hours': '10 秒精度的指标保留多少小时', + 'retention.metric_1m_days': '1 分钟精度的指标保留多少天', + 'retention.metric_5m_days': '5 分钟精度的指标保留多少天', + 'retention.events_days': '事件保留多少天', + 'retention.host_days': 'Agent 主机资源占用(每分钟一个样本)保留多少天', +} + +const PLANS: Record = { + hy2_ladder: { title: 'Hysteria2 阶梯测试', description: 'UDP 灌包,依次以 10、25、50、100 Mbit/s 各跑 10 秒,用来判断 Brutal 固定码率拥塞控制能不能跑得起来。' }, + reality_session: { title: 'REALITY 会话测试', description: '建连测试、4 秒 TLS 批量传输(量吞吐峰值)和一个长连接保持。' }, +} + +export const translateSetting = (key: string, fallback: string): string => SETTINGS[key] ?? fallback + +export const translatePlan = (id: string, title: string, description: string): { title: string; description: string } => PLANS[id] ?? { title, description } diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css new file mode 100644 index 0000000..51d267a --- /dev/null +++ b/web/shared/theme/base.css @@ -0,0 +1,637 @@ +@import "./tokens.css"; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: var(--app-bg); + color: var(--app-ink); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +h4, +p { + margin: 0; +} + +a { + color: var(--app-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +.mono { + font-family: var(--font-mono); + font-size: 0.92em; +} + +.muted { + color: var(--app-ink-muted); +} + +.faint { + color: var(--app-ink-faint); +} + +.num { + font-variant-numeric: tabular-nums; +} + +.row { + display: flex; + align-items: center; + gap: 12px; +} + +.row.wrap { + flex-wrap: wrap; +} + +.grow { + flex: 1; + min-width: 0; +} + +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.right { + margin-left: auto; +} + +/* ── 卡片:靠色调分层,没有描边 ── */ +.card { + background: var(--app-card); + border-radius: var(--r-card); + padding: 20px 24px; +} + +.card-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; +} + +.inset { + background: var(--app-card-inset); + border-radius: var(--r-control); + padding: 12px 16px; +} + +.divider { + height: 1px; + background: var(--app-outline-soft); + border: 0; + margin: 16px 0; +} + +/* ── 按钮 ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 36px; + padding: 0 18px; + border: 0; + border-radius: var(--r-pill); + background: var(--app-card-inset); + color: var(--app-ink); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} + +.btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%)); +} + +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +.btn.primary { + background: var(--app-primary); + color: var(--app-on-primary); +} + +.btn.primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--app-primary), var(--app-on-primary) calc(var(--state-hover) * 100%)); +} + +.btn.accent { + background: var(--app-accent); + color: var(--app-card); +} + +.btn.text { + background: transparent; + color: var(--app-accent); + padding: 0 12px; +} + +.btn.text:hover:not(:disabled) { + background: color-mix(in srgb, var(--app-accent), transparent calc((1 - var(--state-hover)) * 100%)); +} + +.btn.danger { + background: var(--app-bad-container); + color: var(--app-bad); +} + +.btn.icon { + width: 36px; + padding: 0; +} + +.btn.small { + height: 30px; + padding: 0 12px; + font-size: 13px; +} + +/* ── 连接式分组(M3 Expressive connected button group):组内小圆角、外角大圆角、选中段变胶囊 ── */ +.group { + display: inline-flex; + gap: 2px; +} + +.group > button { + height: 36px; + padding: 0 16px; + border: 0; + background: var(--app-card-inset); + color: var(--app-ink-muted); + font-weight: 500; + cursor: pointer; + border-radius: var(--r-group-inner); + transition: + border-radius 0.18s, + background 0.15s; +} + +.group > button:first-child { + border-radius: var(--r-group-outer) var(--r-group-inner) var(--r-group-inner) var(--r-group-outer); +} + +.group > button:last-child { + border-radius: var(--r-group-inner) var(--r-group-outer) var(--r-group-outer) var(--r-group-inner); +} + +.group > button:only-child { + border-radius: var(--r-group-outer); +} + +.group > button:hover { + background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%)); +} + +.group > button.on { + background: var(--app-accent-container); + color: var(--app-on-accent-container); + border-radius: var(--r-group-outer); +} + +/* ── 表单 ── */ +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field > label, +.field > .label { + font-size: 12px; + color: var(--app-ink-muted); +} + +.field .hint { + font-size: 12px; + color: var(--app-ink-faint); +} + +.input, +.select, +.textarea { + width: 100%; + min-height: 40px; + padding: 8px 14px; + border: 0; + border-radius: var(--r-control); + background: var(--app-card-inset); + outline: 2px solid transparent; + outline-offset: -2px; + transition: outline-color 0.15s; +} + +.textarea { + resize: vertical; + font-family: var(--font-mono); + font-size: 13px; +} + +.input:focus, +.select:focus, +.textarea:focus { + outline-color: var(--app-accent); +} + +.input::placeholder { + color: var(--app-ink-faint); +} + +.check { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.check input { + accent-color: var(--app-accent); + width: 16px; + height: 16px; +} + +/* ── 小件 ── */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border-radius: var(--r-pill); + background: var(--app-card-inset); + color: var(--app-ink-muted); + font-size: 12px; + white-space: nowrap; +} + +.chip.ok { + background: var(--app-ok-container); + color: var(--app-ok); +} + +.chip.warn { + background: var(--app-warn-container); + color: var(--app-warn); +} + +.chip.bad { + background: var(--app-bad-container); + color: var(--app-bad); +} + +.chip.accent { + background: var(--app-accent-container); + color: var(--app-on-accent-container); +} + +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--app-idle); +} + +.dot.ok { + background: var(--app-ok); +} + +.dot.warn { + background: var(--app-warn); +} + +.dot.bad { + background: var(--app-bad); +} + +/* ── 表格:细分隔线,没有边框 ── */ +.table { + width: 100%; + border-collapse: collapse; +} + +.table th { + text-align: left; + font-weight: 500; + font-size: 12px; + color: var(--app-ink-faint); + padding: 8px 12px; + border-bottom: 1px solid var(--app-outline); + white-space: nowrap; +} + +.table td { + padding: 12px; + border-bottom: 1px solid var(--app-outline-soft); + vertical-align: middle; +} + +.table tr:last-child td { + border-bottom: 0; +} + +.table.hover tbody tr { + cursor: pointer; +} + +.table.hover tbody tr:hover td { + background: var(--app-card-hover); +} + +.table th.r, +.table td.r { + text-align: right; +} + +/* ── 指标块 ── */ +.stat { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stat .k { + font-size: 12px; + color: var(--app-ink-faint); +} + +.stat .v { + font-size: 24px; + font-weight: 600; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.stat .v small { + font-size: 12px; + font-weight: 400; + color: var(--app-ink-muted); + margin-left: 2px; +} + +.stat .v.ok { + color: var(--app-ok); +} + +.stat .v.warn { + color: var(--app-warn); +} + +.stat .v.bad { + color: var(--app-bad); +} + +.grid { + display: grid; + gap: 16px; +} + +.grid.cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid.cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.grid.stats { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +@media (max-width: 900px) { + .grid.cols-2, + .grid.cols-3, + .grid.cols-4 { + grid-template-columns: minmax(0, 1fr); + } +} + +/* ── 对话框与提示 ── */ +.scrim { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--app-scrim), transparent 60%); + display: grid; + place-items: center; + z-index: 50; + padding: 16px; +} + +.dialog { + background: var(--app-card); + border-radius: 28px; + padding: 24px; + width: min(560px, 100%); + max-height: calc(100vh - 32px); + overflow: auto; + box-shadow: 0 8px 32px color-mix(in srgb, var(--app-shadow), transparent 80%); +} + +.dialog h2 { + font-size: 20px; + margin-bottom: 16px; +} + +.dialog .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.toasts { + position: fixed; + right: 24px; + bottom: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 60; +} + +.toast { + background: var(--app-primary); + color: var(--app-on-primary); + border-radius: var(--r-control); + padding: 10px 16px; + max-width: 420px; + box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%); +} + +.toast.bad { + background: var(--app-bad); + color: var(--app-card); +} + +.empty { + padding: 40px 16px; + text-align: center; + color: var(--app-ink-faint); +} + +.notice { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 18px; + border-radius: var(--r-control); + background: var(--app-accent-container); + color: var(--app-on-accent-container); +} + +.notice.warn { + background: var(--app-warn-container); + color: var(--app-warn); +} + +.notice.bad { + background: var(--app-bad-container); + color: var(--app-bad); +} + +pre.code { + margin: 0; + padding: 12px 16px; + border-radius: var(--r-control); + background: var(--app-card-inset); + font-family: var(--font-mono); + font-size: 12.5px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid var(--app-outline); + border-top-color: var(--app-accent); + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ── 图表 ── */ +/* 拖动选区和十字光标:uPlot 自带的颜色在暗色下几乎看不见,换成主题令牌 */ +.chart .u-select { + background: color-mix(in srgb, var(--app-accent), transparent 72%); + border-left: 1px solid var(--app-accent); + border-right: 1px solid var(--app-accent); +} + +.chart .u-cursor-x, +.chart .u-cursor-y { + border-color: var(--app-ink-faint); +} + +.chart-wrap { + position: relative; +} + +.chart-hint { + margin-left: auto; + color: var(--app-ink-faint); +} + +.chart-hint .btn { + height: 22px; + padding: 0 8px; +} + +.chart-tip { + display: none; + position: absolute; + z-index: 5; + pointer-events: none; + padding: 6px 10px; + border-radius: 8px; + background: var(--app-primary); + color: var(--app-on-primary); + font-size: 12px; + line-height: 1.5; + white-space: pre; + font-variant-numeric: tabular-nums; +} + +.group > button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.group > button:disabled:hover { + background: var(--app-card-inset); +} + +.chart { + width: 100%; +} + +.chart .u-legend { + font-family: var(--font-sans); + font-size: 12px; + color: var(--app-ink-muted); +} + +.chart-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + font-size: 12px; + color: var(--app-ink-muted); + margin-bottom: 4px; +} + +.chart-legend i { + display: inline-block; + width: 10px; + height: 3px; + border-radius: 2px; + margin-right: 6px; + vertical-align: middle; +} diff --git a/web/shared/theme/tokens.css b/web/shared/theme/tokens.css new file mode 100644 index 0000000..5a4b190 --- /dev/null +++ b/web/shared/theme/tokens.css @@ -0,0 +1,96 @@ +/* + * 「纸墨」主题令牌:暖中性纸白底、深墨主色、唯一的强调色钢笔墨水蓝、复古低饱和的状态色。 + * 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。 + */ +:root { + color-scheme: light; + + --app-bg: #f4f2ed; + --app-card: #fbfaf7; + --app-card-inset: #ece9e2; + --app-card-hover: #f0ede6; + --app-outline: #d8d4c9; + --app-outline-soft: #e5e1d7; + + --app-ink: #1f1d18; + --app-ink-muted: #5c5950; + --app-ink-faint: #8b877b; + + --app-primary: #2a2720; + --app-on-primary: #f7f6f3; + + --app-accent: #2a5da8; + --app-accent-container: #dce6f4; + --app-on-accent-container: #12305c; + + --app-ok: #566f2c; + --app-ok-container: #e3ead2; + --app-warn: #9c6b0e; + --app-warn-container: #f1e4c4; + --app-bad: #a23e2c; + --app-bad-container: #f2d9d2; + --app-idle: #8b877b; + + --app-scrim: #1f1d18; + --app-shadow: #1f1d18; + + --chart-1: #2a5da8; + --chart-2: #b9770e; + --chart-3: #a23e2c; + --chart-4: #566f2c; + --chart-5: #7a4f7a; + --chart-6: #2f7a76; + --chart-grid: #e1ddd2; + --chart-axis: #7a766b; + + --r-card: 24px; + --r-group-outer: 28px; + --r-group-inner: 8px; + --r-control: 12px; + --r-pill: 999px; + + --state-hover: 0.08; + --state-press: 0.12; + + --font-sans: "Source Han Sans SC", "Noto Sans SC", "Source Han Sans CN", "Microsoft YaHei UI", "PingFang SC", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace; +} + +:root[data-theme="dark"] { + color-scheme: dark; + + --app-bg: #181714; + --app-card: #211f1b; + --app-card-inset: #2b2924; + --app-card-hover: #28261f; + --app-outline: #3b3830; + --app-outline-soft: #2f2d27; + + --app-ink: #ece9e1; + --app-ink-muted: #b0ab9e; + --app-ink-faint: #7f7b70; + + --app-primary: #ece9e1; + --app-on-primary: #1f1d18; + + --app-accent: #8db0e6; + --app-accent-container: #26364f; + --app-on-accent-container: #cfe0f8; + + --app-ok: #a6c06c; + --app-ok-container: #2c3520; + --app-warn: #dcae52; + --app-warn-container: #3a2f16; + --app-bad: #e3917f; + --app-bad-container: #432720; + --app-idle: #7f7b70; + + --chart-1: #8db0e6; + --chart-2: #e0a74d; + --chart-3: #e3917f; + --chart-4: #a6c06c; + --chart-5: #c79ac7; + --chart-6: #72c0bb; + --chart-grid: #32302a; + --chart-axis: #8f8b7f; +} diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx new file mode 100644 index 0000000..0a9637b --- /dev/null +++ b/web/shared/ui/index.tsx @@ -0,0 +1,279 @@ +import { Check, Copy, Moon, Plug, Sun } from 'lucide-react' +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' +import { ApiError } from '../api/client' +import { getBase, getToken, normalizeBase, setSource } from '../api/source' +import { useTheme } from '../hooks' +import { translateCode, translateField, translateMessage } from '../i18n' + +/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */ +export function Segmented({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) { + return {children} +} + +export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) { + return +} + +export function Spinner() { + return +} + +export function Empty({ children }: { children: ReactNode }) { + return
{children}
+} + +export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) { + return ( +
+ {label} + + {value} + {unit ? {unit} : null} + +
+ ) +} + +export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+ {label} + {children} + {hint ? {hint} : null} +
+ ) +} + +export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + return ( +
e.target === e.currentTarget && onClose()}> +
+

{title}

+ {children} + {actions ?
{actions}
: null} +
+
+ ) +} + +/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */ +export function describeError(e: unknown): string { + if (e instanceof ApiError) { + const fields = Object.entries(e.errors).flatMap(([field, messages]) => messages.map((m) => `${translateField(field)}:${translateMessage(m)}`)) + if (fields.length > 0) return fields.join(';') + const byCode = translateCode(e.code) + // 有具体说明的(例如“要覆盖的模板不存在”)优先给说明,翻不出来就用错误码对应的话 + const detail = e.detail ? translateMessage(e.detail) : '' + if (detail && detail !== e.detail) return detail + return byCode ?? (detail || e.code) + } + return e instanceof Error ? e.message : String(e) +} + +export function ErrorNotice({ error }: { error: unknown }) { + if (!error) return null + const privateSite = error instanceof ApiError && error.code === 'site_private' + return
{describeError(error)}
+} + +/* ── 提示 ── */ +interface ToastItem { + id: number + text: string + bad: boolean +} + +const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]) + const nextId = useRef(1) + const push = useCallback((text: string, bad = false) => { + const id = nextId.current++ + setItems((list) => [...list, { id, text, bad }]) + window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000) + }, []) + return ( + + {children} +
+ {items.map((t) => ( +
+ {t.text} +
+ ))} +
+
+ ) +} + +export const useToast = () => useContext(ToastContext) + +/** 执行一个操作,成功提示、失败显示错误。 */ +export function useAction() { + const toast = useToast() + const [busy, setBusy] = useState(false) + const run = useCallback( + async (action: () => Promise, done?: string) => { + setBusy(true) + try { + await action() + if (done) toast(done) + return true + } catch (e) { + toast(describeError(e), true) + return false + } finally { + setBusy(false) + } + }, + [toast], + ) + return { busy, run } +} + +/** 确认对话框。 */ +export function Confirm({ title, children, confirmText = '确定', danger, onConfirm, onClose }: { title: string; children: ReactNode; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise; onClose: () => void }) { + const [busy, setBusy] = useState(false) + return ( + + + + + } + > +
{children}
+
+ ) +} + +export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) { + const [done, setDone] = useState(false) + return ( + + ) +} + +export function ThemeToggle() { + const [theme, toggle] = useTheme() + return ( + + ) +} + +/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */ +export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) { + const [open, setOpen] = useState(false) + const [base, setBase] = useState('') + const [token, setToken] = useState('') + const remote = getBase() !== '' + const show = () => { + setBase(getBase()) + setToken(getToken()) + setOpen(true) + } + const apply = (nextBase: string, nextToken: string) => { + setSource(nextBase, nextToken) + location.reload() + } + return ( + <> + + {open ? ( + setOpen(false)} + actions={ + <> + + + + } + > +
+

默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(Monitor__CorsOrigins)。

+ + setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus /> + + + setToken(e.target.value)} autoComplete="off" /> + +
+
+ ) : null} + + ) +} + +/** 下载文本文件(probe.json 只给一次)。 */ +export function downloadText(filename: string, text: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +}