增加appstate调试页面
This commit is contained in:
@@ -64,3 +64,5 @@ XplorePlane/Tests/
|
||||
ExternalLibraries/Telerik/
|
||||
build_out.txt
|
||||
XplorePlane/data/
|
||||
XplorePlane.Tests/bin_codex/
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
using XplorePlane.ViewModels.Debug;
|
||||
|
||||
namespace XplorePlane.Tests.ViewModels
|
||||
{
|
||||
public class SnapshotManagerViewModelTests
|
||||
{
|
||||
private readonly Mock<IAppStateService> _mockAppStateService;
|
||||
private readonly Mock<ILoggerService> _mockLoggerService;
|
||||
private readonly Mock<ILoggerService> _mockLogger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public SnapshotManagerViewModelTests()
|
||||
{
|
||||
_mockAppStateService = new Mock<IAppStateService>();
|
||||
_mockLoggerService = new Mock<ILoggerService>();
|
||||
_mockLogger = new Mock<ILoggerService>();
|
||||
_mockLoggerService.Setup(x => x.ForModule<SnapshotManagerViewModel>()).Returns(_mockLogger.Object);
|
||||
|
||||
// Create a dispatcher for the current thread
|
||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSnapshotDetails_WhenSnapshotSelected_ShouldPopulateSnapshotDetails()
|
||||
{
|
||||
// Arrange
|
||||
var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher);
|
||||
|
||||
var snapshot = new StateSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Timestamp = DateTime.Now,
|
||||
MotionState = new MotionState(
|
||||
StageX: 100.5,
|
||||
StageY: 200.3,
|
||||
SourceZ: 300.1,
|
||||
DetectorZ: 400.2,
|
||||
DetectorSwing: 45.0,
|
||||
FDD: 1000.0,
|
||||
StageXSpeed: 10.0,
|
||||
StageYSpeed: 20.0,
|
||||
SourceZSpeed: 30.0,
|
||||
DetectorZSpeed: 40.0,
|
||||
DetectorSwingSpeed: 5.0,
|
||||
FDDSpeed: 50.0,
|
||||
StageRotation: 0.0,
|
||||
FixtureRotation: 0.0,
|
||||
FOD: 500.0,
|
||||
Magnification: 2.0,
|
||||
StageRotationSpeed: 0.0,
|
||||
FixtureRotationSpeed: 0.0
|
||||
),
|
||||
RaySourceState = new RaySourceState(IsOn: true, Voltage: 160.0, Power: 8.0),
|
||||
DetectorState = new DetectorState(IsConnected: true, IsAcquiring: false, FrameRate: 30.0, Resolution: "1920x1080"),
|
||||
SystemState = new SystemState(OperationMode: OperationMode.Idle, HasError: false, ErrorMessage: ""),
|
||||
CameraState = new CameraState(IsConnected: true, IsStreaming: false, CurrentFrame: null, Width: 1920, Height: 1080, FrameRate: 30.0),
|
||||
LinkedViewState = new LinkedViewState(TargetPosition: new PhysicalPosition(0, 0, 0), IsExecuting: false, LastRequestTime: DateTime.Now),
|
||||
RecipeExecutionState = new RecipeExecutionState(CurrentStepIndex: 0, TotalSteps: 10, Status: RecipeExecutionStatus.Idle, CurrentRecipeName: "Test Recipe"),
|
||||
CalibrationMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1)
|
||||
};
|
||||
|
||||
var snapshotViewModel = new SnapshotViewModel { Snapshot = snapshot };
|
||||
|
||||
// Act
|
||||
viewModel.SelectedSnapshot = snapshotViewModel;
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(viewModel.SnapshotDetails);
|
||||
Assert.Equal(8, viewModel.SnapshotDetails.Count); // Should have 8 root nodes (one for each state type)
|
||||
|
||||
// Verify MotionState node exists and has children
|
||||
var motionStateNode = viewModel.SnapshotDetails.FirstOrDefault(n => n.Name == "MotionState");
|
||||
Assert.NotNull(motionStateNode);
|
||||
Assert.NotEmpty(motionStateNode.Children);
|
||||
|
||||
// Verify specific field values
|
||||
var stageXNode = motionStateNode.Children.FirstOrDefault(c => c.Name == "StageX");
|
||||
Assert.NotNull(stageXNode);
|
||||
Assert.Equal("100.50", stageXNode.Value);
|
||||
|
||||
// Verify RaySourceState node exists
|
||||
var raySourceNode = viewModel.SnapshotDetails.FirstOrDefault(n => n.Name == "RaySourceState");
|
||||
Assert.NotNull(raySourceNode);
|
||||
Assert.NotEmpty(raySourceNode.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSnapshotDetails_WhenNoSnapshotSelected_ShouldClearSnapshotDetails()
|
||||
{
|
||||
// Arrange
|
||||
var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher);
|
||||
|
||||
// Add a snapshot first
|
||||
var snapshot = new StateSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Timestamp = DateTime.Now,
|
||||
MotionState = new MotionState(100, 200, 300, 400, 45, 1000, 10, 20, 30, 40, 5, 50, 0, 0, 500, 2, 0, 0),
|
||||
RaySourceState = new RaySourceState(true, 160, 8),
|
||||
DetectorState = new DetectorState(true, false, 30, "1920x1080"),
|
||||
SystemState = new SystemState(OperationMode.Idle, false, ""),
|
||||
CameraState = new CameraState(true, false, null, 1920, 1080, 30),
|
||||
LinkedViewState = new LinkedViewState(new PhysicalPosition(0, 0, 0), false, DateTime.Now),
|
||||
RecipeExecutionState = new RecipeExecutionState(0, 10, RecipeExecutionStatus.Idle, "Test"),
|
||||
CalibrationMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1)
|
||||
};
|
||||
|
||||
viewModel.SelectedSnapshot = new SnapshotViewModel { Snapshot = snapshot };
|
||||
Assert.NotEmpty(viewModel.SnapshotDetails);
|
||||
|
||||
// Act - Deselect snapshot
|
||||
viewModel.SelectedSnapshot = null;
|
||||
|
||||
// Assert
|
||||
Assert.Empty(viewModel.SnapshotDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureSnapshot_ShouldCreateSnapshotAndSelectIt()
|
||||
{
|
||||
// Arrange
|
||||
var motionState = new MotionState(100, 200, 300, 400, 45, 1000, 10, 20, 30, 40, 5, 50, 0, 0, 500, 2, 0, 0);
|
||||
var raySourceState = new RaySourceState(true, 160, 8);
|
||||
var detectorState = new DetectorState(true, false, 30, "1920x1080");
|
||||
var systemState = new SystemState(OperationMode.Idle, false, "");
|
||||
var cameraState = new CameraState(true, false, null, 1920, 1080, 30);
|
||||
var linkedViewState = new LinkedViewState(new PhysicalPosition(0, 0, 0), false, DateTime.Now);
|
||||
var recipeState = new RecipeExecutionState(0, 10, RecipeExecutionStatus.Idle, "Test");
|
||||
var calibMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1);
|
||||
|
||||
_mockAppStateService.Setup(x => x.MotionState).Returns(motionState);
|
||||
_mockAppStateService.Setup(x => x.RaySourceState).Returns(raySourceState);
|
||||
_mockAppStateService.Setup(x => x.DetectorState).Returns(detectorState);
|
||||
_mockAppStateService.Setup(x => x.SystemState).Returns(systemState);
|
||||
_mockAppStateService.Setup(x => x.CameraState).Returns(cameraState);
|
||||
_mockAppStateService.Setup(x => x.LinkedViewState).Returns(linkedViewState);
|
||||
_mockAppStateService.Setup(x => x.RecipeExecutionState).Returns(recipeState);
|
||||
_mockAppStateService.Setup(x => x.CalibrationMatrix).Returns(calibMatrix);
|
||||
|
||||
var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher);
|
||||
|
||||
// Act
|
||||
viewModel.CaptureSnapshot();
|
||||
|
||||
// Assert
|
||||
Assert.Single(viewModel.Snapshots);
|
||||
Assert.NotNull(viewModel.SelectedSnapshot);
|
||||
Assert.Equal(viewModel.Snapshots[0], viewModel.SelectedSnapshot);
|
||||
Assert.NotEmpty(viewModel.SnapshotDetails); // Details should be populated for selected snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
# CNC 检测结果 manifest.json 说明文档
|
||||
|
||||
## 概述
|
||||
|
||||
`manifest.json` 是 CNC 程序执行完成后生成的检测结果清单文件,包含了一次完整检测运行的所有元数据和结果信息。该文件采用 JSON 格式存储,便于程序读取和人工查看。
|
||||
|
||||
## 文件位置
|
||||
|
||||
```
|
||||
{XpData根目录}/Data/InspectionResults/Results/{yyyy-MM-dd}/{RunId}/manifest.json
|
||||
```
|
||||
|
||||
**示例路径:**
|
||||
```
|
||||
D:/XPData/Data/InspectionResults/Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1/manifest.json
|
||||
```
|
||||
|
||||
## 生成时机
|
||||
|
||||
`manifest.json` 在以下情况下生成或更新:
|
||||
|
||||
1. **CNC 程序执行完成时** - 调用 `InspectionResultStore.CompleteRunAsync()` 时写入
|
||||
2. **包含完整的运行详情** - 包括所有节点结果、指标、资产和流水线快照
|
||||
|
||||
## JSON 结构
|
||||
|
||||
### 顶层结构
|
||||
|
||||
```json
|
||||
{
|
||||
"Run": { ... }, // 运行记录
|
||||
"Nodes": [ ... ], // 节点结果列表
|
||||
"Metrics": [ ... ], // 检测指标列表
|
||||
"Assets": [ ... ], // 资产文件列表
|
||||
"PipelineSnapshots": [ ... ],// 流水线快照列表
|
||||
"Events": [ ... ] // 运行事件列表
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Run - 运行记录
|
||||
|
||||
记录本次 CNC 检测运行的基本信息。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `RunId` | string (GUID) | 运行唯一标识符 | `"4b6a5cda-33ca-4419-a79c-b87f4e79e8d1"` |
|
||||
| `ProgramName` | string | CNC 程序名称 | `"测试.xp"` |
|
||||
| `WorkpieceId` | string | 工件标识 | `"WP-2024-001"` |
|
||||
| `SerialNumber` | string | 序列号 | `"SN-20240515-001"` |
|
||||
| `StartedAt` | string (ISO 8601) | 开始时间 | `"2026-05-15T14:30:00.0000000Z"` |
|
||||
| `CompletedAt` | string (ISO 8601) | 完成时间 | `"2026-05-15T14:35:00.0000000Z"` |
|
||||
| `OverallPass` | boolean | 整体是否通过 | `true` |
|
||||
| `Status` | string | 运行状态 | `"completed"` / `"stopped"` / `"error"` |
|
||||
| `SourceImagePath` | string | 源图像相对路径 | `"run/source.png"` |
|
||||
| `ResultRootPath` | string | 结果根路径 | `"Results/2026-05-15/4b6a5cda-..."` |
|
||||
| `NodeCount` | number | 节点总数 | `5` |
|
||||
|
||||
### 状态枚举
|
||||
|
||||
- `pending` - 等待执行
|
||||
- `running` - 正在执行
|
||||
- `completed` - 正常完成
|
||||
- `stopped` - 用户停止
|
||||
- `error` - 异常终止
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Run": {
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"ProgramName": "测试.xp",
|
||||
"WorkpieceId": "WP-2024-001",
|
||||
"SerialNumber": "SN-20240515-001",
|
||||
"StartedAt": "2026-05-15T14:30:00.0000000Z",
|
||||
"CompletedAt": "2026-05-15T14:35:00.0000000Z",
|
||||
"OverallPass": true,
|
||||
"Status": "completed",
|
||||
"SourceImagePath": "run/source.png",
|
||||
"ResultRootPath": "Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeCount": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Nodes - 节点结果列表
|
||||
|
||||
记录每个检测节点的执行结果。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` |
|
||||
| `NodeId` | string (GUID) | 节点唯一标识符 | `"a1b2c3d4-..."` |
|
||||
| `NodeIndex` | number | 节点索引(从0开始) | `0` |
|
||||
| `NodeName` | string | 节点名称 | `"检测模块_0"` |
|
||||
| `PipelineId` | string (GUID) | 流水线ID | `"e5f6g7h8-..."` |
|
||||
| `PipelineName` | string | 流水线名称 | `"边缘检测"` |
|
||||
| `PipelineVersionHash` | string | 流水线版本哈希 | `"A1B2C3D4..."` |
|
||||
| `NodePass` | boolean | 节点是否通过 | `true` |
|
||||
| `SourceImagePath` | string | 输入图像相对路径 | `"nodes/0_检测模块_0_input.png"` |
|
||||
| `ResultImagePath` | string | 结果图像相对路径 | `"nodes/0_检测模块_0_result.png"` |
|
||||
| `Status` | string | 节点状态 | `"Succeeded"` |
|
||||
| `DurationMs` | number | 执行耗时(毫秒) | `1250` |
|
||||
|
||||
### 状态枚举
|
||||
|
||||
- `Succeeded` - 执行成功
|
||||
- `Failed` - 执行失败
|
||||
- `PartialSuccess` - 部分成功
|
||||
- `AssetMissing` - 资产缺失
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Nodes": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"NodeIndex": 0,
|
||||
"NodeName": "检测模块_0",
|
||||
"PipelineId": "e5f6g7h8-i9j0-1234-5678-90abcdef1234",
|
||||
"PipelineName": "边缘检测",
|
||||
"PipelineVersionHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6",
|
||||
"NodePass": true,
|
||||
"SourceImagePath": "nodes/0_检测模块_0_input.png",
|
||||
"ResultImagePath": "nodes/0_检测模块_0_result.png",
|
||||
"Status": "Succeeded",
|
||||
"DurationMs": 1250
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Metrics - 检测指标列表
|
||||
|
||||
记录每个节点产生的检测指标和判定结果。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` |
|
||||
| `NodeId` | string (GUID) | 所属节点ID | `"a1b2c3d4-..."` |
|
||||
| `MetricKey` | string | 指标键(唯一标识) | `"edge_count"` |
|
||||
| `MetricName` | string | 指标显示名称 | `"边缘数量"` |
|
||||
| `MetricValue` | number | 指标值 | `42.5` |
|
||||
| `Unit` | string | 单位 | `"个"` / `"mm"` / `"%"` |
|
||||
| `LowerLimit` | number? | 下限(可选) | `30.0` |
|
||||
| `UpperLimit` | number? | 上限(可选) | `50.0` |
|
||||
| `IsPass` | boolean | 是否通过判定 | `true` |
|
||||
| `DisplayOrder` | number | 显示顺序 | `0` |
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Metrics": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"MetricKey": "edge_count",
|
||||
"MetricName": "边缘数量",
|
||||
"MetricValue": 42.5,
|
||||
"Unit": "个",
|
||||
"LowerLimit": 30.0,
|
||||
"UpperLimit": 50.0,
|
||||
"IsPass": true,
|
||||
"DisplayOrder": 0
|
||||
},
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"MetricKey": "defect_area",
|
||||
"MetricName": "缺陷面积",
|
||||
"MetricValue": 2.3,
|
||||
"Unit": "mm²",
|
||||
"LowerLimit": null,
|
||||
"UpperLimit": 5.0,
|
||||
"IsPass": true,
|
||||
"DisplayOrder": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Assets - 资产文件列表
|
||||
|
||||
记录所有保存的图像和文件资产。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` |
|
||||
| `NodeId` | string (GUID)? | 所属节点ID(可选) | `"a1b2c3d4-..."` / `null` |
|
||||
| `AssetType` | string | 资产类型 | `"NodeResultImage"` |
|
||||
| `RelativePath` | string | 相对路径 | `"nodes/0_检测模块_0_result.png"` |
|
||||
| `FileFormat` | string | 文件格式 | `"png"` / `"jpg"` / `"tiff"` |
|
||||
| `Width` | number | 图像宽度(像素) | `1920` |
|
||||
| `Height` | number | 图像高度(像素) | `1080` |
|
||||
|
||||
### 资产类型枚举
|
||||
|
||||
- `RunSourceImage` - 运行源图像(整个运行的输入图像)
|
||||
- `NodeInputImage` - 节点输入图像
|
||||
- `NodeResultImage` - 节点结果图像
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Assets": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"AssetType": "RunSourceImage",
|
||||
"RelativePath": "run/source.png",
|
||||
"FileFormat": "png",
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
},
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"AssetType": "NodeInputImage",
|
||||
"RelativePath": "nodes/0_检测模块_0_input.png",
|
||||
"FileFormat": "png",
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
},
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"AssetType": "NodeResultImage",
|
||||
"RelativePath": "nodes/0_检测模块_0_result.png",
|
||||
"FileFormat": "png",
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. PipelineSnapshots - 流水线快照列表
|
||||
|
||||
记录每个节点执行时使用的图像处理流水线完整定义,用于结果追溯和复现。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` |
|
||||
| `NodeId` | string (GUID) | 所属节点ID | `"a1b2c3d4-..."` |
|
||||
| `PipelineName` | string | 流水线名称 | `"边缘检测"` |
|
||||
| `PipelineDefinitionJson` | string | 流水线完整定义(JSON字符串) | `"{\"Name\":\"边缘检测\",...}"` |
|
||||
| `PipelineHash` | string | 流水线定义的SHA256哈希 | `"A1B2C3D4..."` |
|
||||
|
||||
### 用途
|
||||
|
||||
- **版本追溯** - 记录执行时的流水线配置,即使后续流水线被修改也能追溯
|
||||
- **结果复现** - 可以根据快照重新执行相同的流水线
|
||||
- **版本对比** - 通过哈希值快速判断流水线是否发生变化
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"PipelineSnapshots": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"PipelineName": "边缘检测",
|
||||
"PipelineDefinitionJson": "{\"Name\":\"边缘检测\",\"Processors\":[{\"Type\":\"GaussianBlur\",\"Parameters\":{\"KernelSize\":5}},{\"Type\":\"CannyEdge\",\"Parameters\":{\"Threshold1\":50,\"Threshold2\":150}}]}",
|
||||
"PipelineHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Events - 运行事件列表
|
||||
|
||||
记录运行过程中的所有关键事件,用于完整回放执行时间线。
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `Id` | number | 事件自增ID | `1` |
|
||||
| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` |
|
||||
| `NodeId` | string (GUID)? | 相关节点ID(可选) | `"a1b2c3d4-..."` / `null` |
|
||||
| `EventType` | string | 事件类型 | `"RunStarted"` |
|
||||
| `EventTime` | string (ISO 8601) | 事件时间 | `"2026-05-15T14:30:00.0000000Z"` |
|
||||
| `PayloadJson` | string | 事件负载(JSON字符串) | `"{\"Message\":\"开始执行\"}"` |
|
||||
|
||||
### 事件类型枚举
|
||||
|
||||
- `RunStarted` - 运行开始
|
||||
- `NodeStarted` - 节点开始执行
|
||||
- `NodeCompleted` - 节点执行完成
|
||||
- `NodeFailed` - 节点执行失败
|
||||
- `RunStopped` - 运行被用户停止
|
||||
- `RunCompleted` - 运行正常完成
|
||||
- `RunError` - 运行异常终止
|
||||
|
||||
### 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Events": [
|
||||
{
|
||||
"Id": 1,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"EventType": "RunStarted",
|
||||
"EventTime": "2026-05-15T14:30:00.0000000Z",
|
||||
"PayloadJson": ""
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"EventType": "NodeStarted",
|
||||
"EventTime": "2026-05-15T14:30:05.0000000Z",
|
||||
"PayloadJson": ""
|
||||
},
|
||||
{
|
||||
"Id": 3,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"EventType": "NodeCompleted",
|
||||
"EventTime": "2026-05-15T14:30:06.2500000Z",
|
||||
"PayloadJson": ""
|
||||
},
|
||||
{
|
||||
"Id": 4,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"EventType": "RunCompleted",
|
||||
"EventTime": "2026-05-15T14:35:00.0000000Z",
|
||||
"PayloadJson": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
```json
|
||||
{
|
||||
"Run": {
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"ProgramName": "测试.xp",
|
||||
"WorkpieceId": "WP-2024-001",
|
||||
"SerialNumber": "SN-20240515-001",
|
||||
"StartedAt": "2026-05-15T14:30:00.0000000Z",
|
||||
"CompletedAt": "2026-05-15T14:35:00.0000000Z",
|
||||
"OverallPass": true,
|
||||
"Status": "completed",
|
||||
"SourceImagePath": "run/source.png",
|
||||
"ResultRootPath": "Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeCount": 1
|
||||
},
|
||||
"Nodes": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"NodeIndex": 0,
|
||||
"NodeName": "检测模块_0",
|
||||
"PipelineId": "e5f6g7h8-i9j0-1234-5678-90abcdef1234",
|
||||
"PipelineName": "边缘检测",
|
||||
"PipelineVersionHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6",
|
||||
"NodePass": true,
|
||||
"SourceImagePath": "nodes/0_检测模块_0_input.png",
|
||||
"ResultImagePath": "nodes/0_检测模块_0_result.png",
|
||||
"Status": "Succeeded",
|
||||
"DurationMs": 1250
|
||||
}
|
||||
],
|
||||
"Metrics": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"MetricKey": "edge_count",
|
||||
"MetricName": "边缘数量",
|
||||
"MetricValue": 42.5,
|
||||
"Unit": "个",
|
||||
"LowerLimit": 30.0,
|
||||
"UpperLimit": 50.0,
|
||||
"IsPass": true,
|
||||
"DisplayOrder": 0
|
||||
}
|
||||
],
|
||||
"Assets": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"AssetType": "RunSourceImage",
|
||||
"RelativePath": "run/source.png",
|
||||
"FileFormat": "png",
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
},
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"AssetType": "NodeResultImage",
|
||||
"RelativePath": "nodes/0_检测模块_0_result.png",
|
||||
"FileFormat": "png",
|
||||
"Width": 1920,
|
||||
"Height": 1080
|
||||
}
|
||||
],
|
||||
"PipelineSnapshots": [
|
||||
{
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"PipelineName": "边缘检测",
|
||||
"PipelineDefinitionJson": "{\"Name\":\"边缘检测\",\"Processors\":[]}",
|
||||
"PipelineHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6"
|
||||
}
|
||||
],
|
||||
"Events": [
|
||||
{
|
||||
"Id": 1,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"EventType": "RunStarted",
|
||||
"EventTime": "2026-05-15T14:30:00.0000000Z",
|
||||
"PayloadJson": ""
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1",
|
||||
"NodeId": null,
|
||||
"EventType": "RunCompleted",
|
||||
"EventTime": "2026-05-15T14:35:00.0000000Z",
|
||||
"PayloadJson": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 1. 结果查看与分析
|
||||
- 通过 `InspectionReportViewerViewModel` 读取 manifest.json
|
||||
- 显示运行列表、节点结果、指标数据
|
||||
- 查看保存的图像资产
|
||||
|
||||
### 2. 质量追溯
|
||||
- 根据 `WorkpieceId` 和 `SerialNumber` 追溯特定工件的检测历史
|
||||
- 通过 `PipelineSnapshots` 确认使用的流水线版本
|
||||
- 通过 `Events` 回放执行时间线
|
||||
|
||||
### 3. 数据导出与报告
|
||||
- 解析 manifest.json 生成检测报告
|
||||
- 导出指标数据到 Excel/CSV
|
||||
- 批量分析多次运行的统计数据
|
||||
|
||||
### 4. 结果复现
|
||||
- 根据 `PipelineSnapshots` 重新执行相同的流水线
|
||||
- 使用保存的输入图像重新检测
|
||||
- 对比不同版本流水线的结果差异
|
||||
|
||||
---
|
||||
|
||||
## 相关代码
|
||||
|
||||
### 写入
|
||||
- `InspectionResultStore.WriteManifestAsync()` - 写入 manifest.json
|
||||
- `InspectionResultStore.CompleteRunAsync()` - 完成运行时调用写入
|
||||
|
||||
### 读取
|
||||
- `InspectionResultStore.GetRunDetailAsync()` - 从数据库读取运行详情
|
||||
- 前端可直接解析 manifest.json 文件
|
||||
|
||||
### 数据模型
|
||||
- `XplorePlane.Models.InspectionResultModels.cs` - 所有相关数据模型定义
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **路径格式** - 所有路径使用正斜杠 `/` 作为分隔符,跨平台兼容
|
||||
2. **时间格式** - 使用 ISO 8601 格式(UTC时间),精确到微秒
|
||||
3. **GUID 格式** - 使用标准的 GUID 字符串格式(带连字符)
|
||||
4. **JSON 格式** - 使用缩进格式(WriteIndented = true),便于人工查看
|
||||
5. **版本兼容** - 新增字段应设置默认值,保持向后兼容
|
||||
6. **文件编码** - UTF-8 编码,支持中文字符
|
||||
|
||||
---
|
||||
|
||||
## 完整性确认
|
||||
|
||||
### ✅ manifest.json 包含的完整信息
|
||||
|
||||
`manifest.json` 是一个**完整的检测结果清单**,包含生成完整报告所需的所有信息:
|
||||
|
||||
#### 1. 基本信息
|
||||
- ✅ 运行标识(RunId、ProgramName)
|
||||
- ✅ 工件信息(WorkpieceId、SerialNumber)
|
||||
- ✅ 时间信息(StartedAt、CompletedAt)
|
||||
- ✅ 整体判定结果(OverallPass、Status)
|
||||
|
||||
#### 2. 节点执行结果
|
||||
- ✅ 每个节点的执行状态(Status、NodePass)
|
||||
- ✅ 节点名称和索引(NodeName、NodeIndex)
|
||||
- ✅ 使用的流水线信息(PipelineName、PipelineVersionHash)
|
||||
- ✅ 执行耗时(DurationMs)
|
||||
|
||||
#### 3. 检测指标数据
|
||||
- ✅ 所有检测指标的名称和值(MetricName、MetricValue)
|
||||
- ✅ 指标单位(Unit)
|
||||
- ✅ 判定上下限(LowerLimit、UpperLimit)
|
||||
- ✅ 判定结果(IsPass)
|
||||
|
||||
#### 4. 图像资产路径
|
||||
- ✅ 输入图像路径(SourceImagePath、NodeInputImage)
|
||||
- ✅ 输出图像路径(ResultImagePath、NodeResultImage)
|
||||
- ✅ 图像尺寸信息(Width、Height)
|
||||
- ✅ 图像格式(FileFormat)
|
||||
|
||||
#### 5. 流水线快照
|
||||
- ✅ 完整的流水线定义(PipelineDefinitionJson)
|
||||
- ✅ 流水线版本哈希(PipelineHash)
|
||||
- ✅ 用于结果追溯和复现
|
||||
|
||||
#### 6. 事件时间线
|
||||
- ✅ 完整的执行事件序列(Events)
|
||||
- ✅ 每个事件的时间戳(EventTime)
|
||||
- ✅ 用于回放执行过程
|
||||
|
||||
---
|
||||
|
||||
### 📊 如何使用 manifest.json 生成完整报告
|
||||
|
||||
以下是读取 manifest.json 并生成完整报告的示例代码:
|
||||
|
||||
```csharp
|
||||
// 1. 读取 manifest.json
|
||||
var manifestPath = Path.Combine(resultRootPath, "manifest.json");
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<InspectionManifest>(manifestJson);
|
||||
|
||||
// 2. 获取基本信息
|
||||
var runInfo = manifest.Run;
|
||||
Console.WriteLine($"程序名称: {runInfo.ProgramName}");
|
||||
Console.WriteLine($"工件ID: {runInfo.WorkpieceId}");
|
||||
Console.WriteLine($"序列号: {runInfo.SerialNumber}");
|
||||
Console.WriteLine($"开始时间: {runInfo.StartedAt}");
|
||||
Console.WriteLine($"完成时间: {runInfo.CompletedAt}");
|
||||
Console.WriteLine($"整体判定: {(runInfo.OverallPass ? "通过" : "不通过")}");
|
||||
|
||||
// 3. 遍历所有节点结果
|
||||
foreach (var node in manifest.Nodes)
|
||||
{
|
||||
Console.WriteLine($"\n节点 {node.NodeIndex}: {node.NodeName}");
|
||||
Console.WriteLine($" 流水线: {node.PipelineName}");
|
||||
Console.WriteLine($" 状态: {node.Status}");
|
||||
Console.WriteLine($" 判定: {(node.NodePass ? "通过" : "不通过")}");
|
||||
Console.WriteLine($" 耗时: {node.DurationMs} ms");
|
||||
|
||||
// 4. 读取输入图像
|
||||
var inputImagePath = Path.Combine(resultRootPath, node.SourceImagePath);
|
||||
if (File.Exists(inputImagePath))
|
||||
{
|
||||
var inputImage = Image.FromFile(inputImagePath);
|
||||
Console.WriteLine($" 输入图像: {inputImage.Width}x{inputImage.Height}");
|
||||
// 可以将图像添加到报告中
|
||||
}
|
||||
|
||||
// 5. 读取输出图像
|
||||
var outputImagePath = Path.Combine(resultRootPath, node.ResultImagePath);
|
||||
if (File.Exists(outputImagePath))
|
||||
{
|
||||
var outputImage = Image.FromFile(outputImagePath);
|
||||
Console.WriteLine($" 输出图像: {outputImage.Width}x{outputImage.Height}");
|
||||
// 可以将图像添加到报告中
|
||||
}
|
||||
|
||||
// 6. 获取该节点的所有指标
|
||||
var nodeMetrics = manifest.Metrics.Where(m => m.NodeId == node.NodeId);
|
||||
foreach (var metric in nodeMetrics)
|
||||
{
|
||||
Console.WriteLine($" 指标: {metric.MetricName} = {metric.MetricValue} {metric.Unit}");
|
||||
if (metric.LowerLimit.HasValue || metric.UpperLimit.HasValue)
|
||||
{
|
||||
Console.WriteLine($" 范围: [{metric.LowerLimit ?? double.MinValue}, {metric.UpperLimit ?? double.MaxValue}]");
|
||||
}
|
||||
Console.WriteLine($" 判定: {(metric.IsPass ? "通过" : "不通过")}");
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 生成 PDF 报告(示例)
|
||||
var pdfReport = new PdfDocument();
|
||||
var page = pdfReport.AddPage();
|
||||
var graphics = XGraphics.FromPdfPage(page);
|
||||
|
||||
// 添加标题
|
||||
graphics.DrawString($"检测报告 - {runInfo.ProgramName}",
|
||||
new XFont("Arial", 20), XBrushes.Black, new XPoint(50, 50));
|
||||
|
||||
// 添加基本信息
|
||||
graphics.DrawString($"工件ID: {runInfo.WorkpieceId}",
|
||||
new XFont("Arial", 12), XBrushes.Black, new XPoint(50, 80));
|
||||
|
||||
// 添加图像和指标...
|
||||
// (省略详细的 PDF 生成代码)
|
||||
|
||||
pdfReport.Save("检测报告.pdf");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🎯 实际应用场景
|
||||
|
||||
#### 场景 1: 生成 PDF 检测报告
|
||||
```csharp
|
||||
// 读取 manifest.json
|
||||
var manifest = await LoadManifestAsync(runId);
|
||||
|
||||
// 创建 PDF 报告
|
||||
var report = new PdfReportGenerator();
|
||||
report.AddHeader(manifest.Run);
|
||||
foreach (var node in manifest.Nodes)
|
||||
{
|
||||
report.AddNodeSection(node);
|
||||
report.AddInputImage(GetImagePath(node.SourceImagePath));
|
||||
report.AddOutputImage(GetImagePath(node.ResultImagePath));
|
||||
report.AddMetrics(manifest.Metrics.Where(m => m.NodeId == node.NodeId));
|
||||
}
|
||||
report.Save($"Report_{runId}.pdf");
|
||||
```
|
||||
|
||||
#### 场景 2: 导出 Excel 数据表
|
||||
```csharp
|
||||
// 读取 manifest.json
|
||||
var manifest = await LoadManifestAsync(runId);
|
||||
|
||||
// 创建 Excel 工作簿
|
||||
var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("检测结果");
|
||||
|
||||
// 写入基本信息
|
||||
worksheet.Cell("A1").Value = "程序名称";
|
||||
worksheet.Cell("B1").Value = manifest.Run.ProgramName;
|
||||
worksheet.Cell("A2").Value = "工件ID";
|
||||
worksheet.Cell("B2").Value = manifest.Run.WorkpieceId;
|
||||
|
||||
// 写入指标数据
|
||||
int row = 5;
|
||||
worksheet.Cell(row, 1).Value = "节点名称";
|
||||
worksheet.Cell(row, 2).Value = "指标名称";
|
||||
worksheet.Cell(row, 3).Value = "指标值";
|
||||
worksheet.Cell(row, 4).Value = "单位";
|
||||
worksheet.Cell(row, 5).Value = "判定";
|
||||
|
||||
foreach (var metric in manifest.Metrics)
|
||||
{
|
||||
row++;
|
||||
var node = manifest.Nodes.First(n => n.NodeId == metric.NodeId);
|
||||
worksheet.Cell(row, 1).Value = node.NodeName;
|
||||
worksheet.Cell(row, 2).Value = metric.MetricName;
|
||||
worksheet.Cell(row, 3).Value = metric.MetricValue;
|
||||
worksheet.Cell(row, 4).Value = metric.Unit;
|
||||
worksheet.Cell(row, 5).Value = metric.IsPass ? "通过" : "不通过";
|
||||
}
|
||||
|
||||
workbook.SaveAs($"Data_{runId}.xlsx");
|
||||
```
|
||||
|
||||
#### 场景 3: 结果追溯与对比
|
||||
```csharp
|
||||
// 读取多个运行的 manifest.json
|
||||
var run1 = await LoadManifestAsync(runId1);
|
||||
var run2 = await LoadManifestAsync(runId2);
|
||||
|
||||
// 对比流水线版本
|
||||
foreach (var node1 in run1.Nodes)
|
||||
{
|
||||
var node2 = run2.Nodes.FirstOrDefault(n => n.NodeIndex == node1.NodeIndex);
|
||||
if (node2 != null)
|
||||
{
|
||||
var snapshot1 = run1.PipelineSnapshots.First(s => s.NodeId == node1.NodeId);
|
||||
var snapshot2 = run2.PipelineSnapshots.First(s => s.NodeId == node2.NodeId);
|
||||
|
||||
if (snapshot1.PipelineHash != snapshot2.PipelineHash)
|
||||
{
|
||||
Console.WriteLine($"节点 {node1.NodeIndex} 流水线版本不同:");
|
||||
Console.WriteLine($" 运行1: {snapshot1.PipelineHash}");
|
||||
Console.WriteLine($" 运行2: {snapshot2.PipelineHash}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对比指标值
|
||||
var metric1 = run1.Metrics.First(m => m.MetricKey == "edge_count");
|
||||
var metric2 = run2.Metrics.First(m => m.MetricKey == "edge_count");
|
||||
Console.WriteLine($"边缘数量对比: {metric1.MetricValue} vs {metric2.MetricValue}");
|
||||
```
|
||||
|
||||
#### 场景 4: 质量统计分析
|
||||
```csharp
|
||||
// 读取一批运行的 manifest.json
|
||||
var manifests = await LoadManifestsAsync(startDate, endDate);
|
||||
|
||||
// 统计通过率
|
||||
var totalRuns = manifests.Count;
|
||||
var passedRuns = manifests.Count(m => m.Run.OverallPass);
|
||||
var passRate = (double)passedRuns / totalRuns * 100;
|
||||
Console.WriteLine($"通过率: {passRate:F2}%");
|
||||
|
||||
// 统计各指标的平均值
|
||||
var allMetrics = manifests.SelectMany(m => m.Metrics);
|
||||
var avgEdgeCount = allMetrics
|
||||
.Where(m => m.MetricKey == "edge_count")
|
||||
.Average(m => m.MetricValue);
|
||||
Console.WriteLine($"平均边缘数量: {avgEdgeCount:F2}");
|
||||
|
||||
// 统计执行时间
|
||||
var avgDuration = manifests
|
||||
.SelectMany(m => m.Nodes)
|
||||
.Average(n => n.DurationMs);
|
||||
Console.WriteLine($"平均节点执行时间: {avgDuration:F2} ms");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 使用注意事项
|
||||
|
||||
1. **路径拼接** - manifest.json 中的路径是相对路径,需要与 `ResultRootPath` 拼接:
|
||||
```csharp
|
||||
var fullPath = Path.Combine(manifest.Run.ResultRootPath, asset.RelativePath);
|
||||
```
|
||||
|
||||
2. **图像文件存在性检查** - 读取图像前应检查文件是否存在:
|
||||
```csharp
|
||||
if (File.Exists(imagePath))
|
||||
{
|
||||
var image = Image.FromFile(imagePath);
|
||||
// 处理图像...
|
||||
}
|
||||
```
|
||||
|
||||
3. **JSON 反序列化** - 使用 `System.Text.Json` 或 `Newtonsoft.Json` 反序列化:
|
||||
```csharp
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
var manifest = JsonSerializer.Deserialize<InspectionManifest>(json, options);
|
||||
```
|
||||
|
||||
4. **时区处理** - manifest.json 中的时间是 UTC 时间,显示时需要转换为本地时间:
|
||||
```csharp
|
||||
var localTime = DateTime.Parse(manifest.Run.StartedAt).ToLocalTime();
|
||||
```
|
||||
|
||||
5. **大文件处理** - 对于包含大量节点的运行,manifest.json 可能较大,建议使用流式读取:
|
||||
```csharp
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
var manifest = await JsonSerializer.DeserializeAsync<InspectionManifest>(stream);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更新历史
|
||||
|
||||
| 日期 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| 2026-05-15 | 1.0 | 初始版本,记录当前 manifest.json 结构 |
|
||||
| 2026-05-15 | 1.1 | 添加完整性确认章节,包含使用示例和实际应用场景 |
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.ViewModels.Debug;
|
||||
|
||||
namespace XplorePlane.Services.Debug
|
||||
{
|
||||
public class DebugPanelConfigService : IDebugPanelConfigService
|
||||
{
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private string _configPath;
|
||||
|
||||
public DebugPanelConfigService(ILoggerService logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_logger = logger.ForModule<DebugPanelConfigService>();
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
_configPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"XplorePlane",
|
||||
"DebugPanel.config");
|
||||
}
|
||||
|
||||
public DebugPanelConfig LoadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_configPath))
|
||||
{
|
||||
_logger.Info("调试面板配置不存在,使用默认配置 | Debug panel config missing, using defaults");
|
||||
return CreateDefaultConfig();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_configPath);
|
||||
var config = JsonSerializer.Deserialize<DebugPanelConfig>(json, _jsonOptions);
|
||||
return NormalizeConfig(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "加载调试面板配置失败,回退默认配置 | Failed to load debug panel config, falling back to defaults");
|
||||
return CreateDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveConfig(DebugPanelConfig config)
|
||||
{
|
||||
if (config == null)
|
||||
{
|
||||
_logger.Warn("保存调试面板配置被忽略:配置为空 | Skip saving debug panel config because config is null");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config = NormalizeConfig(config);
|
||||
|
||||
var directory = Path.GetDirectoryName(_configPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _jsonOptions);
|
||||
File.WriteAllText(_configPath, json);
|
||||
|
||||
_logger.Info("调试面板配置已保存到 {Path} | Debug panel config saved to {Path}", _configPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "保存调试面板配置失败 | Failed to save debug panel config");
|
||||
}
|
||||
}
|
||||
|
||||
private DebugPanelConfig NormalizeConfig(DebugPanelConfig config)
|
||||
{
|
||||
var normalized = config ?? CreateDefaultConfig();
|
||||
normalized.Window ??= CreateDefaultWindowConfig();
|
||||
|
||||
if (normalized.Window.Width <= 0)
|
||||
{
|
||||
normalized.Window.Width = 1200;
|
||||
}
|
||||
|
||||
if (normalized.Window.Height <= 0)
|
||||
{
|
||||
normalized.Window.Height = 800;
|
||||
}
|
||||
|
||||
normalized.EventFilters ??= new Dictionary<string, bool>();
|
||||
foreach (var key in DebugPanelStateMetadata.StateCategories)
|
||||
{
|
||||
if (!normalized.EventFilters.ContainsKey(key))
|
||||
{
|
||||
normalized.EventFilters[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static DebugPanelConfig CreateDefaultConfig()
|
||||
{
|
||||
var filters = new Dictionary<string, bool>();
|
||||
foreach (var key in DebugPanelStateMetadata.StateCategories)
|
||||
{
|
||||
filters[key] = true;
|
||||
}
|
||||
|
||||
return new DebugPanelConfig
|
||||
{
|
||||
Window = CreateDefaultWindowConfig(),
|
||||
EventFilters = filters,
|
||||
DockingLayout = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static WindowConfig CreateDefaultWindowConfig()
|
||||
{
|
||||
return new WindowConfig
|
||||
{
|
||||
Left = 100,
|
||||
Top = 100,
|
||||
Width = 1200,
|
||||
Height = 800,
|
||||
State = WindowState.Normal
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using XplorePlane.Models;
|
||||
|
||||
namespace XplorePlane.Services.Debug
|
||||
{
|
||||
public interface IDebugPanelConfigService
|
||||
{
|
||||
DebugPanelConfig LoadConfig();
|
||||
|
||||
void SaveConfig(DebugPanelConfig config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
internal static class DebugPanelStateFormatter
|
||||
{
|
||||
public static string FormatValue(object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
if (type == typeof(bool))
|
||||
{
|
||||
return ((bool)value).ToString();
|
||||
}
|
||||
|
||||
if (IsNumeric(type))
|
||||
{
|
||||
return Convert.ToDouble(value, CultureInfo.InvariantCulture).ToString("F2", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (type.IsEnum)
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
if (type == typeof(DateTime))
|
||||
{
|
||||
return ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (type.Namespace != null && type.Namespace.StartsWith("XplorePlane.Models", StringComparison.Ordinal))
|
||||
{
|
||||
return FormatObjectSummary(value);
|
||||
}
|
||||
|
||||
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
public static bool IsNumeric(Type type)
|
||||
{
|
||||
type = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return type == typeof(byte) ||
|
||||
type == typeof(sbyte) ||
|
||||
type == typeof(short) ||
|
||||
type == typeof(ushort) ||
|
||||
type == typeof(int) ||
|
||||
type == typeof(uint) ||
|
||||
type == typeof(long) ||
|
||||
type == typeof(ulong) ||
|
||||
type == typeof(float) ||
|
||||
type == typeof(double) ||
|
||||
type == typeof(decimal);
|
||||
}
|
||||
|
||||
private static string FormatObjectSummary(object value)
|
||||
{
|
||||
var properties = value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public);
|
||||
if (properties.Length == 0)
|
||||
{
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
var parts = new string[properties.Length];
|
||||
for (int i = 0; i < properties.Length; i++)
|
||||
{
|
||||
var property = properties[i];
|
||||
parts[i] = $"{property.Name}={FormatValue(property.GetValue(value))}";
|
||||
}
|
||||
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
internal static class DebugPanelStateMetadata
|
||||
{
|
||||
public static readonly string[] StateCategories =
|
||||
{
|
||||
"MotionState",
|
||||
"RaySourceState",
|
||||
"DetectorState",
|
||||
"SystemState",
|
||||
"CameraState",
|
||||
"LinkedViewState",
|
||||
"RecipeExecutionState",
|
||||
"CalibrationMatrix"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
using XplorePlane.Services.Debug;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
public class DebugPanelViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly IDebugPanelConfigService _configService;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private DebugPanelConfig _config;
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
public StateDisplayViewModel StateDisplay { get; }
|
||||
public EventLogViewModel EventLog { get; }
|
||||
public SnapshotManagerViewModel SnapshotManager { get; }
|
||||
public PerformanceMonitorViewModel PerformanceMonitor { get; }
|
||||
|
||||
public DelegateCommand SaveLayoutCommand { get; }
|
||||
public DelegateCommand ResetLayoutCommand { get; }
|
||||
public DelegateCommand ExportAllCommand { get; }
|
||||
|
||||
public DebugPanelConfig CurrentConfig
|
||||
{
|
||||
get => _config;
|
||||
private set => SetProperty(ref _config, value);
|
||||
}
|
||||
|
||||
public DebugPanelViewModel(
|
||||
IAppStateService appStateService,
|
||||
ILoggerService loggerService,
|
||||
IDebugPanelConfigService configService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(appStateService);
|
||||
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<DebugPanelViewModel>();
|
||||
_configService = configService ?? throw new ArgumentNullException(nameof(configService));
|
||||
_dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
|
||||
|
||||
StateDisplay = new StateDisplayViewModel(appStateService, loggerService, _dispatcher);
|
||||
EventLog = new EventLogViewModel(appStateService, loggerService, _dispatcher);
|
||||
SnapshotManager = new SnapshotManagerViewModel(appStateService, loggerService, _dispatcher);
|
||||
PerformanceMonitor = new PerformanceMonitorViewModel(appStateService, loggerService, _dispatcher);
|
||||
|
||||
SaveLayoutCommand = new DelegateCommand(SaveLayout);
|
||||
ResetLayoutCommand = new DelegateCommand(ResetLayout);
|
||||
ExportAllCommand = new DelegateCommand(ExportAll);
|
||||
|
||||
CurrentConfig = _configService.LoadConfig();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_initialized || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentConfig = _configService.LoadConfig();
|
||||
StateDisplay.Initialize();
|
||||
EventLog.Initialize();
|
||||
PerformanceMonitor.Initialize();
|
||||
|
||||
ApplyFilterConfig();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void SaveWindowConfig(WindowConfig windowConfig)
|
||||
{
|
||||
CurrentConfig ??= _configService.LoadConfig();
|
||||
CurrentConfig.Window = windowConfig;
|
||||
SaveLayout();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveLayout();
|
||||
StateDisplay.Dispose();
|
||||
EventLog.Dispose();
|
||||
SnapshotManager.Dispose();
|
||||
PerformanceMonitor.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void SaveLayout()
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentConfig ??= _configService.LoadConfig();
|
||||
CurrentConfig.EventFilters = new Dictionary<string, bool>(EventLog.FilterOptions);
|
||||
_configService.SaveConfig(CurrentConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "保存调试面板布局失败 | Failed to save debug panel layout");
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetLayout()
|
||||
{
|
||||
CurrentConfig = new DebugPanelConfig
|
||||
{
|
||||
Window = new WindowConfig
|
||||
{
|
||||
Left = 100,
|
||||
Top = 100,
|
||||
Width = 1200,
|
||||
Height = 800,
|
||||
State = System.Windows.WindowState.Normal
|
||||
},
|
||||
EventFilters = new Dictionary<string, bool>(),
|
||||
DockingLayout = string.Empty
|
||||
};
|
||||
|
||||
foreach (var category in DebugPanelStateMetadata.StateCategories)
|
||||
{
|
||||
CurrentConfig.EventFilters[category] = true;
|
||||
EventLog.FilterOptions[category] = true;
|
||||
}
|
||||
|
||||
EventLog.ApplyFilter();
|
||||
_configService.SaveConfig(CurrentConfig);
|
||||
}
|
||||
|
||||
private void ExportAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
|
||||
$"debug-panel-export-{DateTime.Now:yyyyMMdd-HHmmss}.json");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
ExportedAt = DateTime.Now,
|
||||
EventLog = EventLog.EventLog,
|
||||
Snapshots = SnapshotManager.Snapshots
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "导出调试面板数据失败 | Failed to export debug panel data");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilterConfig()
|
||||
{
|
||||
if (CurrentConfig?.EventFilters == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in CurrentConfig.EventFilters)
|
||||
{
|
||||
if (EventLog.FilterOptions.ContainsKey(pair.Key))
|
||||
{
|
||||
EventLog.FilterOptions[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
EventLog.ApplyFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using Microsoft.Win32;
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter option view model for binding to UI
|
||||
/// 用于绑定到 UI 的过滤器选项视图模型
|
||||
/// </summary>
|
||||
public class FilterOptionViewModel : BindableBase
|
||||
{
|
||||
private bool _isEnabled;
|
||||
|
||||
public string Category { get; set; }
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => SetProperty(ref _isEnabled, value);
|
||||
}
|
||||
}
|
||||
|
||||
public class EventLogViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private const int MaxLogEntries = 1000;
|
||||
|
||||
private readonly IAppStateService _appStateService;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
public ObservableCollection<EventLogEntry> EventLog { get; }
|
||||
public ObservableCollection<EventLogEntry> FilteredEventLog { get; } = new();
|
||||
public Dictionary<string, bool> FilterOptions { get; } = new();
|
||||
public ObservableCollection<FilterOptionViewModel> FilterOptionViewModels { get; } = new();
|
||||
|
||||
public DelegateCommand<string> ToggleFilterCommand { get; }
|
||||
public DelegateCommand SelectAllFiltersCommand { get; }
|
||||
public DelegateCommand ClearAllFiltersCommand { get; }
|
||||
public DelegateCommand ExportToJsonCommand { get; }
|
||||
public DelegateCommand ExportToCsvCommand { get; }
|
||||
|
||||
public EventLogViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher)
|
||||
{
|
||||
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<EventLogViewModel>();
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
EventLog = new ObservableCollection<EventLogEntry>();
|
||||
EventLog.CollectionChanged += OnEventLogCollectionChanged;
|
||||
|
||||
// Initialize filter options with both dictionary and view models
|
||||
foreach (var category in DebugPanelStateMetadata.StateCategories)
|
||||
{
|
||||
FilterOptions[category] = true;
|
||||
var filterVM = new FilterOptionViewModel { Category = category, IsEnabled = true };
|
||||
filterVM.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(FilterOptionViewModel.IsEnabled) && s is FilterOptionViewModel vm)
|
||||
{
|
||||
FilterOptions[vm.Category] = vm.IsEnabled;
|
||||
ApplyFilter();
|
||||
}
|
||||
};
|
||||
FilterOptionViewModels.Add(filterVM);
|
||||
}
|
||||
|
||||
ToggleFilterCommand = new DelegateCommand<string>(ToggleFilter);
|
||||
SelectAllFiltersCommand = new DelegateCommand(SelectAllFilters);
|
||||
ClearAllFiltersCommand = new DelegateCommand(ClearAllFilters);
|
||||
ExportToJsonCommand = new DelegateCommand(ExportToJson);
|
||||
ExportToCsvCommand = new DelegateCommand(ExportToCsv);
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_initialized || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_appStateService.MotionStateChanged += OnMotionStateChanged;
|
||||
_appStateService.RaySourceStateChanged += OnRaySourceStateChanged;
|
||||
_appStateService.DetectorStateChanged += OnDetectorStateChanged;
|
||||
_appStateService.SystemStateChanged += OnSystemStateChanged;
|
||||
_appStateService.CameraStateChanged += OnCameraStateChanged;
|
||||
_appStateService.LinkedViewStateChanged += OnLinkedViewStateChanged;
|
||||
_appStateService.RecipeExecutionStateChanged += OnRecipeExecutionStateChanged;
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void ApplyFilter()
|
||||
{
|
||||
FilteredEventLog.Clear();
|
||||
|
||||
foreach (var entry in EventLog.Where(e => FilterOptions.TryGetValue(e.Category, out var enabled) && enabled))
|
||||
{
|
||||
FilteredEventLog.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized)
|
||||
{
|
||||
_appStateService.MotionStateChanged -= OnMotionStateChanged;
|
||||
_appStateService.RaySourceStateChanged -= OnRaySourceStateChanged;
|
||||
_appStateService.DetectorStateChanged -= OnDetectorStateChanged;
|
||||
_appStateService.SystemStateChanged -= OnSystemStateChanged;
|
||||
_appStateService.CameraStateChanged -= OnCameraStateChanged;
|
||||
_appStateService.LinkedViewStateChanged -= OnLinkedViewStateChanged;
|
||||
_appStateService.RecipeExecutionStateChanged -= OnRecipeExecutionStateChanged;
|
||||
}
|
||||
|
||||
EventLog.CollectionChanged -= OnEventLogCollectionChanged;
|
||||
EventLog.Clear();
|
||||
FilteredEventLog.Clear();
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void OnMotionStateChanged(object sender, StateChangedEventArgs<MotionState> e) => OnStateChanged("MotionStateChanged", "MotionState", e.OldValue, e.NewValue);
|
||||
private void OnRaySourceStateChanged(object sender, StateChangedEventArgs<RaySourceState> e) => OnStateChanged("RaySourceStateChanged", "RaySourceState", e.OldValue, e.NewValue);
|
||||
private void OnDetectorStateChanged(object sender, StateChangedEventArgs<DetectorState> e) => OnStateChanged("DetectorStateChanged", "DetectorState", e.OldValue, e.NewValue);
|
||||
private void OnSystemStateChanged(object sender, StateChangedEventArgs<SystemState> e) => OnStateChanged("SystemStateChanged", "SystemState", e.OldValue, e.NewValue);
|
||||
private void OnCameraStateChanged(object sender, StateChangedEventArgs<CameraState> e) => OnStateChanged("CameraStateChanged", "CameraState", e.OldValue, e.NewValue);
|
||||
private void OnLinkedViewStateChanged(object sender, StateChangedEventArgs<LinkedViewState> e) => OnStateChanged("LinkedViewStateChanged", "LinkedViewState", e.OldValue, e.NewValue);
|
||||
private void OnRecipeExecutionStateChanged(object sender, StateChangedEventArgs<RecipeExecutionState> e) => OnStateChanged("RecipeExecutionStateChanged", "RecipeExecutionState", e.OldValue, e.NewValue);
|
||||
|
||||
private void OnStateChanged<T>(string eventType, string category, T oldState, T newState)
|
||||
{
|
||||
try
|
||||
{
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
foreach (var entry in DetectChanges(eventType, category, oldState, newState))
|
||||
{
|
||||
EventLog.Insert(0, entry);
|
||||
}
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "记录状态事件日志失败 | Failed to record event log for {Category}", category);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<EventLogEntry> DetectChanges<T>(string eventType, string category, T oldState, T newState)
|
||||
{
|
||||
if (newState == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var timestamp = DateTime.Now;
|
||||
var changed = false;
|
||||
|
||||
foreach (var property in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
var oldValue = oldState == null ? null : property.GetValue(oldState);
|
||||
var newValue = property.GetValue(newState);
|
||||
if (Equals(oldValue, newValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
yield return new EventLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
FieldName = property.Name,
|
||||
OldValue = DebugPanelStateFormatter.FormatValue(oldValue),
|
||||
NewValue = DebugPanelStateFormatter.FormatValue(newValue),
|
||||
Category = category
|
||||
};
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
yield return new EventLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
FieldName = "(No changes)",
|
||||
OldValue = string.Empty,
|
||||
NewValue = string.Empty,
|
||||
Category = category
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEventLogCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
while (EventLog.Count > MaxLogEntries)
|
||||
{
|
||||
EventLog.RemoveAt(EventLog.Count - 1);
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ToggleFilter(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || !FilterOptions.ContainsKey(category))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FilterOptions[category] = !FilterOptions[category];
|
||||
|
||||
// Update the corresponding view model
|
||||
var filterVM = FilterOptionViewModels.FirstOrDefault(f => f.Category == category);
|
||||
if (filterVM != null)
|
||||
{
|
||||
filterVM.IsEnabled = FilterOptions[category];
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void SelectAllFilters()
|
||||
{
|
||||
foreach (var key in FilterOptions.Keys.ToList())
|
||||
{
|
||||
FilterOptions[key] = true;
|
||||
}
|
||||
|
||||
// Update all view models
|
||||
foreach (var filterVM in FilterOptionViewModels)
|
||||
{
|
||||
filterVM.IsEnabled = true;
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ClearAllFilters()
|
||||
{
|
||||
foreach (var key in FilterOptions.Keys.ToList())
|
||||
{
|
||||
FilterOptions[key] = false;
|
||||
}
|
||||
|
||||
// Update all view models
|
||||
foreach (var filterVM in FilterOptionViewModels)
|
||||
{
|
||||
filterVM.IsEnabled = false;
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ExportToJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = PromptSavePath("Export event log as JSON", "JSON files (*.json)|*.json");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
ExportedAt = DateTime.Now,
|
||||
ApplicationVersion = GetApplicationVersion(),
|
||||
Entries = EventLog.ToList()
|
||||
};
|
||||
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, _jsonOptions), Encoding.UTF8);
|
||||
MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "导出事件日志 JSON 失败 | Failed to export event log as JSON");
|
||||
MessageBox.Show($"Failed to export JSON: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExportToCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = PromptSavePath("Export event log as CSV", "CSV files (*.csv)|*.csv");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"# ExportedAt,{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}");
|
||||
builder.AppendLine($"# ApplicationVersion,{GetApplicationVersion()}");
|
||||
builder.AppendLine("Timestamp,EventType,Category,FieldName,OldValue,NewValue");
|
||||
|
||||
foreach (var entry in EventLog)
|
||||
{
|
||||
builder.AppendLine(string.Join(",",
|
||||
EscapeCsv(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")),
|
||||
EscapeCsv(entry.EventType),
|
||||
EscapeCsv(entry.Category),
|
||||
EscapeCsv(entry.FieldName),
|
||||
EscapeCsv(entry.OldValue),
|
||||
EscapeCsv(entry.NewValue)));
|
||||
}
|
||||
|
||||
File.WriteAllText(path, builder.ToString(), Encoding.UTF8);
|
||||
MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "导出事件日志 CSV 失败 | Failed to export event log as CSV");
|
||||
MessageBox.Show($"Failed to export CSV: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PromptSavePath(string title, string filter)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
AddExtension = true,
|
||||
OverwritePrompt = true
|
||||
};
|
||||
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
private static string GetApplicationVersion()
|
||||
{
|
||||
return Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ??
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString() ??
|
||||
"unknown";
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r"))
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
public class PerformanceMonitorViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private readonly IAppStateService _appStateService;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly ConcurrentQueue<(string Category, DateTime Timestamp)> _eventQueue = new();
|
||||
private readonly Dictionary<string, Queue<double>> _latencyHistory = new();
|
||||
private double _maxLatency;
|
||||
private bool _isLatencyWarning;
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
public ObservableCollection<PerformanceMetric> Metrics { get; } = new();
|
||||
public ObservableCollection<TrendDataPoint> TrendData { get; } = new();
|
||||
|
||||
public double MaxLatency
|
||||
{
|
||||
get => _maxLatency;
|
||||
private set => SetProperty(ref _maxLatency, value);
|
||||
}
|
||||
|
||||
public bool IsLatencyWarning
|
||||
{
|
||||
get => _isLatencyWarning;
|
||||
private set => SetProperty(ref _isLatencyWarning, value);
|
||||
}
|
||||
|
||||
public PerformanceMonitorViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher)
|
||||
{
|
||||
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<PerformanceMonitorViewModel>();
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
foreach (var category in DebugPanelStateMetadata.StateCategories)
|
||||
{
|
||||
if (category == "CalibrationMatrix")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Metrics.Add(new PerformanceMetric { StateType = category });
|
||||
_latencyHistory[category] = new Queue<double>();
|
||||
}
|
||||
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_timer.Tick += (_, _) =>
|
||||
{
|
||||
UpdateFrequencyMetrics();
|
||||
UpdateTrendData();
|
||||
};
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_initialized || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_appStateService.MotionStateChanged += (_, _) => RecordEvent("MotionState");
|
||||
_appStateService.RaySourceStateChanged += (_, _) => RecordEvent("RaySourceState");
|
||||
_appStateService.DetectorStateChanged += (_, _) => RecordEvent("DetectorState");
|
||||
_appStateService.SystemStateChanged += (_, _) => RecordEvent("SystemState");
|
||||
_appStateService.CameraStateChanged += (_, _) => RecordEvent("CameraState");
|
||||
_appStateService.LinkedViewStateChanged += (_, _) => RecordEvent("LinkedViewState");
|
||||
_appStateService.RecipeExecutionStateChanged += (_, _) => RecordEvent("RecipeExecutionState");
|
||||
|
||||
_timer.Start();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timer.Stop();
|
||||
Metrics.Clear();
|
||||
TrendData.Clear();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void RecordEvent(string category)
|
||||
{
|
||||
var timestamp = DateTime.UtcNow;
|
||||
_eventQueue.Enqueue((category, timestamp));
|
||||
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var latency = (DateTime.UtcNow - timestamp).TotalMilliseconds;
|
||||
UpdateLatencyMetric(category, latency);
|
||||
}));
|
||||
}
|
||||
|
||||
private void UpdateFrequencyMetrics()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-1);
|
||||
var events = _eventQueue.ToArray();
|
||||
var grouped = events.Where(e => e.Timestamp >= cutoff).GroupBy(e => e.Category).ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var metric in Metrics)
|
||||
{
|
||||
metric.EventsPerSecond = grouped.TryGetValue(metric.StateType, out var count) ? count : 0;
|
||||
}
|
||||
|
||||
while (_eventQueue.TryPeek(out var item) && item.Timestamp < cutoff.AddSeconds(-1))
|
||||
{
|
||||
_eventQueue.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLatencyMetric(string category, double latency)
|
||||
{
|
||||
if (!_latencyHistory.TryGetValue(category, out var history))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
history.Enqueue(latency);
|
||||
while (history.Count > 60)
|
||||
{
|
||||
history.Dequeue();
|
||||
}
|
||||
|
||||
var metric = Metrics.FirstOrDefault(m => m.StateType == category);
|
||||
if (metric != null)
|
||||
{
|
||||
metric.AverageLatency = history.Average();
|
||||
}
|
||||
|
||||
MaxLatency = Metrics.Count == 0 ? 0 : Metrics.Max(m => m.AverageLatency);
|
||||
IsLatencyWarning = MaxLatency > 500;
|
||||
}
|
||||
|
||||
private void UpdateTrendData()
|
||||
{
|
||||
TrendData.Add(new TrendDataPoint
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Value = Metrics.Sum(m => m.EventsPerSecond)
|
||||
});
|
||||
|
||||
while (TrendData.Count > 60)
|
||||
{
|
||||
TrendData.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
public class SnapshotDiffViewModel : BindableBase
|
||||
{
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public ObservableCollection<StateDifference> Differences { get; } = new();
|
||||
|
||||
public DelegateCommand ExportDifferencesCommand { get; }
|
||||
|
||||
public SnapshotDiffViewModel(IEnumerable<StateDifference> differences, ILoggerService loggerService, Dispatcher dispatcher)
|
||||
{
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<SnapshotDiffViewModel>();
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
foreach (var difference in differences ?? Array.Empty<StateDifference>())
|
||||
{
|
||||
Differences.Add(difference);
|
||||
}
|
||||
|
||||
ExportDifferencesCommand = new DelegateCommand(ExportDifferences);
|
||||
}
|
||||
|
||||
private void ExportDifferences()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
|
||||
$"snapshot-diff-{DateTime.Now:yyyyMMdd-HHmmss}.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(Differences, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(path, json, Encoding.UTF8);
|
||||
|
||||
MessageBox.Show($"Differences exported to:\n{path}", "Snapshot Diff", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "导出快照差异失败 | Failed to export snapshot differences");
|
||||
MessageBox.Show($"Failed to export differences: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using Microsoft.Win32;
|
||||
using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
using XplorePlane.Views.Debug;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
public class SnapshotViewModel : BindableBase
|
||||
{
|
||||
private bool _isSelected;
|
||||
|
||||
public StateSnapshot Snapshot { get; set; }
|
||||
|
||||
public bool IsSelected
|
||||
{
|
||||
get => _isSelected;
|
||||
set => SetProperty(ref _isSelected, value);
|
||||
}
|
||||
}
|
||||
|
||||
public class SnapshotManagerViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private const int MaxSnapshots = 50;
|
||||
|
||||
private readonly IAppStateService _appStateService;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
private SnapshotViewModel _selectedSnapshot;
|
||||
|
||||
public ObservableCollection<SnapshotViewModel> Snapshots { get; } = new();
|
||||
public ObservableCollection<StateNodeViewModel> SnapshotDetails { get; } = new();
|
||||
|
||||
public SnapshotViewModel SelectedSnapshot
|
||||
{
|
||||
get => _selectedSnapshot;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedSnapshot, value))
|
||||
{
|
||||
UpdateSnapshotDetails();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DelegateCommand CaptureSnapshotCommand { get; }
|
||||
public DelegateCommand CompareSnapshotsCommand { get; }
|
||||
public DelegateCommand ExportSnapshotCommand { get; }
|
||||
public DelegateCommand DeleteSnapshotCommand { get; }
|
||||
|
||||
public SnapshotManagerViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher)
|
||||
{
|
||||
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<SnapshotManagerViewModel>();
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
CaptureSnapshotCommand = new DelegateCommand(CaptureSnapshot);
|
||||
CompareSnapshotsCommand = new DelegateCommand(CompareSnapshots);
|
||||
ExportSnapshotCommand = new DelegateCommand(ExportSnapshot);
|
||||
DeleteSnapshotCommand = new DelegateCommand(DeleteSnapshot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Snapshots.Clear();
|
||||
SnapshotDetails.Clear();
|
||||
SelectedSnapshot = null;
|
||||
}
|
||||
|
||||
public void CaptureSnapshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new StateSnapshot
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Timestamp = DateTime.Now,
|
||||
MotionState = _appStateService.MotionState,
|
||||
RaySourceState = _appStateService.RaySourceState,
|
||||
DetectorState = _appStateService.DetectorState,
|
||||
SystemState = _appStateService.SystemState,
|
||||
CameraState = _appStateService.CameraState,
|
||||
LinkedViewState = _appStateService.LinkedViewState,
|
||||
RecipeExecutionState = _appStateService.RecipeExecutionState,
|
||||
CalibrationMatrix = _appStateService.CalibrationMatrix
|
||||
};
|
||||
|
||||
var item = new SnapshotViewModel { Snapshot = snapshot };
|
||||
Snapshots.Insert(0, item);
|
||||
SelectedSnapshot = item;
|
||||
|
||||
while (Snapshots.Count > MaxSnapshots)
|
||||
{
|
||||
Snapshots.RemoveAt(Snapshots.Count - 1);
|
||||
}
|
||||
|
||||
_logger.Info("快照已捕获 {Id} | Snapshot captured {Id}", snapshot.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "捕获调试快照失败 | Failed to capture debug snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<StateDifference> ComputeDifference(StateSnapshot first, StateSnapshot second)
|
||||
{
|
||||
var differences = new List<StateDifference>();
|
||||
if (first == null || second == null)
|
||||
{
|
||||
return differences;
|
||||
}
|
||||
|
||||
CompareObject("MotionState", first.MotionState, second.MotionState, differences);
|
||||
CompareObject("RaySourceState", first.RaySourceState, second.RaySourceState, differences);
|
||||
CompareObject("DetectorState", first.DetectorState, second.DetectorState, differences);
|
||||
CompareObject("SystemState", first.SystemState, second.SystemState, differences);
|
||||
CompareObject("CameraState", first.CameraState, second.CameraState, differences);
|
||||
CompareObject("LinkedViewState", first.LinkedViewState, second.LinkedViewState, differences);
|
||||
CompareObject("RecipeExecutionState", first.RecipeExecutionState, second.RecipeExecutionState, differences);
|
||||
CompareObject("CalibrationMatrix", first.CalibrationMatrix, second.CalibrationMatrix, differences);
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
private void CompareSnapshots()
|
||||
{
|
||||
try
|
||||
{
|
||||
var selected = Snapshots.Where(s => s.IsSelected).Take(2).ToList();
|
||||
if (selected.Count != 2)
|
||||
{
|
||||
MessageBox.Show("Please select exactly two snapshots to compare.", "Compare Snapshots", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var differences = ComputeDifference(selected[0].Snapshot, selected[1].Snapshot);
|
||||
var viewModel = new SnapshotDiffViewModel(differences, _logger, _dispatcher);
|
||||
var window = new SnapshotDiffWindow
|
||||
{
|
||||
DataContext = viewModel,
|
||||
Owner = Application.Current?.MainWindow
|
||||
};
|
||||
|
||||
window.Show();
|
||||
_logger.Info("快照对比已打开 | Snapshot comparison window opened");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "比较快照失败 | Failed to compare snapshots");
|
||||
MessageBox.Show($"Failed to compare snapshots: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExportSnapshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (SelectedSnapshot?.Snapshot == null)
|
||||
{
|
||||
MessageBox.Show("Please select a snapshot first.", "Export Snapshot", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Export Snapshot",
|
||||
Filter = "JSON files (*.json)|*.json",
|
||||
FileName = $"snapshot-{SelectedSnapshot.Snapshot.Timestamp:yyyyMMdd-HHmmss}.json"
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(dialog.FileName, JsonSerializer.Serialize(SelectedSnapshot.Snapshot, _jsonOptions));
|
||||
_logger.Info("快照已导出到 {Path} | Snapshot exported to {Path}", dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "导出快照失败 | Failed to export snapshot");
|
||||
MessageBox.Show($"Failed to export snapshot: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteSnapshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (SelectedSnapshot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = MessageBox.Show("Delete selected snapshot?", "Delete Snapshot", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (result != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = SelectedSnapshot;
|
||||
var index = Snapshots.IndexOf(snapshot);
|
||||
Snapshots.Remove(snapshot);
|
||||
SelectedSnapshot = index >= 0 && index < Snapshots.Count ? Snapshots[index] : Snapshots.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "删除快照失败 | Failed to delete snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSnapshotDetails()
|
||||
{
|
||||
SnapshotDetails.Clear();
|
||||
if (SelectedSnapshot?.Snapshot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddSnapshotRoot("MotionState", SelectedSnapshot.Snapshot.MotionState);
|
||||
AddSnapshotRoot("RaySourceState", SelectedSnapshot.Snapshot.RaySourceState);
|
||||
AddSnapshotRoot("DetectorState", SelectedSnapshot.Snapshot.DetectorState);
|
||||
AddSnapshotRoot("SystemState", SelectedSnapshot.Snapshot.SystemState);
|
||||
AddSnapshotRoot("CameraState", SelectedSnapshot.Snapshot.CameraState);
|
||||
AddSnapshotRoot("LinkedViewState", SelectedSnapshot.Snapshot.LinkedViewState);
|
||||
AddSnapshotRoot("RecipeExecutionState", SelectedSnapshot.Snapshot.RecipeExecutionState);
|
||||
AddSnapshotRoot("CalibrationMatrix", SelectedSnapshot.Snapshot.CalibrationMatrix);
|
||||
}
|
||||
|
||||
private void AddSnapshotRoot(string category, object value)
|
||||
{
|
||||
var root = new StateNodeViewModel
|
||||
{
|
||||
Name = category,
|
||||
Category = category
|
||||
};
|
||||
|
||||
if (value != null)
|
||||
{
|
||||
foreach (var property in value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
root.Children.Add(new StateNodeViewModel
|
||||
{
|
||||
Name = property.Name,
|
||||
Category = category,
|
||||
Value = DebugPanelStateFormatter.FormatValue(property.GetValue(value))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotDetails.Add(root);
|
||||
}
|
||||
|
||||
private static void CompareObject(string category, object first, object second, ICollection<StateDifference> differences)
|
||||
{
|
||||
var type = first?.GetType() ?? second?.GetType();
|
||||
if (type == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
var firstValue = first == null ? null : property.GetValue(first);
|
||||
var secondValue = second == null ? null : property.GetValue(second);
|
||||
if (Equals(firstValue, secondValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
differences.Add(new StateDifference
|
||||
{
|
||||
Category = category,
|
||||
FieldName = property.Name,
|
||||
Value1 = DebugPanelStateFormatter.FormatValue(firstValue),
|
||||
Value2 = DebugPanelStateFormatter.FormatValue(secondValue)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Threading;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
|
||||
namespace XplorePlane.ViewModels.Debug
|
||||
{
|
||||
public class StateDisplayViewModel : BindableBase, IDisposable
|
||||
{
|
||||
private readonly IAppStateService _appStateService;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
public ObservableCollection<StateNodeViewModel> StateTree { get; } = new();
|
||||
|
||||
public StateDisplayViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher)
|
||||
{
|
||||
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
|
||||
_logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule<StateDisplayViewModel>();
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
||||
InitializeStateTree();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_initialized || _disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_appStateService.MotionStateChanged += OnMotionStateChanged;
|
||||
_appStateService.RaySourceStateChanged += OnRaySourceStateChanged;
|
||||
_appStateService.DetectorStateChanged += OnDetectorStateChanged;
|
||||
_appStateService.SystemStateChanged += OnSystemStateChanged;
|
||||
_appStateService.CameraStateChanged += OnCameraStateChanged;
|
||||
_appStateService.LinkedViewStateChanged += OnLinkedViewStateChanged;
|
||||
_appStateService.RecipeExecutionStateChanged += OnRecipeExecutionStateChanged;
|
||||
|
||||
ApplyStateSnapshot("MotionState", _appStateService.MotionState);
|
||||
ApplyStateSnapshot("RaySourceState", _appStateService.RaySourceState);
|
||||
ApplyStateSnapshot("DetectorState", _appStateService.DetectorState);
|
||||
ApplyStateSnapshot("SystemState", _appStateService.SystemState);
|
||||
ApplyStateSnapshot("CameraState", _appStateService.CameraState);
|
||||
ApplyStateSnapshot("LinkedViewState", _appStateService.LinkedViewState);
|
||||
ApplyStateSnapshot("RecipeExecutionState", _appStateService.RecipeExecutionState);
|
||||
ApplyStateSnapshot("CalibrationMatrix", _appStateService.CalibrationMatrix);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized)
|
||||
{
|
||||
_appStateService.MotionStateChanged -= OnMotionStateChanged;
|
||||
_appStateService.RaySourceStateChanged -= OnRaySourceStateChanged;
|
||||
_appStateService.DetectorStateChanged -= OnDetectorStateChanged;
|
||||
_appStateService.SystemStateChanged -= OnSystemStateChanged;
|
||||
_appStateService.CameraStateChanged -= OnCameraStateChanged;
|
||||
_appStateService.LinkedViewStateChanged -= OnLinkedViewStateChanged;
|
||||
_appStateService.RecipeExecutionStateChanged -= OnRecipeExecutionStateChanged;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void InitializeStateTree()
|
||||
{
|
||||
AddRootNode("MotionState", typeof(MotionState));
|
||||
AddRootNode("RaySourceState", typeof(RaySourceState));
|
||||
AddRootNode("DetectorState", typeof(DetectorState));
|
||||
AddRootNode("SystemState", typeof(SystemState));
|
||||
AddRootNode("CameraState", typeof(CameraState));
|
||||
AddRootNode("LinkedViewState", typeof(LinkedViewState));
|
||||
AddRootNode("RecipeExecutionState", typeof(RecipeExecutionState));
|
||||
AddRootNode("CalibrationMatrix", typeof(CalibrationMatrix));
|
||||
}
|
||||
|
||||
private void AddRootNode(string category, Type stateType)
|
||||
{
|
||||
var root = new StateNodeViewModel
|
||||
{
|
||||
Name = category,
|
||||
Category = category
|
||||
};
|
||||
|
||||
foreach (var property in stateType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
root.Children.Add(new StateNodeViewModel
|
||||
{
|
||||
Name = property.Name,
|
||||
Category = category,
|
||||
Value = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
StateTree.Add(root);
|
||||
}
|
||||
|
||||
private void OnMotionStateChanged(object sender, StateChangedEventArgs<MotionState> e) => OnStateChanged("MotionState", e.OldValue, e.NewValue);
|
||||
private void OnRaySourceStateChanged(object sender, StateChangedEventArgs<RaySourceState> e) => OnStateChanged("RaySourceState", e.OldValue, e.NewValue);
|
||||
private void OnDetectorStateChanged(object sender, StateChangedEventArgs<DetectorState> e) => OnStateChanged("DetectorState", e.OldValue, e.NewValue);
|
||||
private void OnSystemStateChanged(object sender, StateChangedEventArgs<SystemState> e) => OnStateChanged("SystemState", e.OldValue, e.NewValue);
|
||||
private void OnCameraStateChanged(object sender, StateChangedEventArgs<CameraState> e) => OnStateChanged("CameraState", e.OldValue, e.NewValue);
|
||||
private void OnLinkedViewStateChanged(object sender, StateChangedEventArgs<LinkedViewState> e) => OnStateChanged("LinkedViewState", e.OldValue, e.NewValue);
|
||||
private void OnRecipeExecutionStateChanged(object sender, StateChangedEventArgs<RecipeExecutionState> e) => OnStateChanged("RecipeExecutionState", e.OldValue, e.NewValue);
|
||||
|
||||
private void OnStateChanged<T>(string category, T oldState, T newState)
|
||||
{
|
||||
try
|
||||
{
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
UpdateStateNodes(category, oldState, newState);
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "更新状态树失败 | Failed to update state tree for {Category}", category);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStateSnapshot<T>(string category, T state)
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStateNodes(category, default, state);
|
||||
}
|
||||
|
||||
private void UpdateStateNodes<T>(string category, T oldState, T newState)
|
||||
{
|
||||
if (newState == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var property in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
var oldValue = oldState == null ? null : property.GetValue(oldState);
|
||||
var newValue = property.GetValue(newState);
|
||||
UpdateStateNode(category, property.Name, oldValue, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStateNode(string category, string fieldName, object oldValue, object newValue)
|
||||
{
|
||||
var node = FindNode(category, fieldName);
|
||||
if (node == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
node.Value = DebugPanelStateFormatter.FormatValue(newValue);
|
||||
|
||||
if (!Equals(oldValue, newValue))
|
||||
{
|
||||
node.HighlightColor = DetermineHighlightColor(oldValue, newValue);
|
||||
node.IsHighlighted = true;
|
||||
_ = ClearHighlightAsync(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
node.IsHighlighted = false;
|
||||
node.HighlightColor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private StateNodeViewModel FindNode(string category, string fieldName)
|
||||
{
|
||||
foreach (var root in StateTree)
|
||||
{
|
||||
if (!string.Equals(root.Name, category, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in root.Children)
|
||||
{
|
||||
if (string.Equals(child.Name, fieldName, StringComparison.Ordinal))
|
||||
{
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string DetermineHighlightColor(object oldValue, object newValue)
|
||||
{
|
||||
if (newValue is bool boolValue)
|
||||
{
|
||||
return boolValue ? "Green" : "Red";
|
||||
}
|
||||
|
||||
var newType = newValue?.GetType();
|
||||
if (newType != null && DebugPanelStateFormatter.IsNumeric(newType))
|
||||
{
|
||||
var oldNumber = oldValue == null ? 0d : Convert.ToDouble(oldValue);
|
||||
var newNumber = Convert.ToDouble(newValue);
|
||||
|
||||
if (newNumber > oldNumber)
|
||||
{
|
||||
return "Green";
|
||||
}
|
||||
|
||||
if (newNumber < oldNumber)
|
||||
{
|
||||
return "Red";
|
||||
}
|
||||
|
||||
return "Yellow";
|
||||
}
|
||||
|
||||
return "Yellow";
|
||||
}
|
||||
|
||||
private async Task ClearHighlightAsync(StateNodeViewModel node)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
node.IsHighlighted = false;
|
||||
node.HighlightColor = null;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<Window x:Class="XplorePlane.Views.Debug.DebugPanelWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
xmlns:views="clr-namespace:XplorePlane.Views.Debug"
|
||||
mc:Ignorable="d"
|
||||
Title="[调试模式] AppState 可视化调试面板"
|
||||
Width="1200"
|
||||
Height="800"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ToolBar Grid.Row="0">
|
||||
<Button Content="Save Layout" Command="{Binding SaveLayoutCommand}" />
|
||||
<Button Content="Reset Layout" Command="{Binding ResetLayoutCommand}" Margin="8,0,0,0" />
|
||||
<Button Content="Export All" Command="{Binding ExportAllCommand}" Margin="8,0,0,0" />
|
||||
</ToolBar>
|
||||
|
||||
<telerik:RadDocking x:Name="DockingRoot" Grid.Row="1">
|
||||
<telerik:RadSplitContainer InitialPosition="DockedLeft">
|
||||
<telerik:RadPaneGroup>
|
||||
<telerik:RadPane Header="State Display" CanUserClose="False">
|
||||
<views:StateDisplayView DataContext="{Binding StateDisplay}" />
|
||||
</telerik:RadPane>
|
||||
</telerik:RadPaneGroup>
|
||||
<telerik:RadSplitContainer Orientation="Horizontal">
|
||||
<telerik:RadPaneGroup>
|
||||
<telerik:RadPane Header="Event Log" CanUserClose="False">
|
||||
<views:EventLogView DataContext="{Binding EventLog}" />
|
||||
</telerik:RadPane>
|
||||
</telerik:RadPaneGroup>
|
||||
<telerik:RadPaneGroup>
|
||||
<telerik:RadPane Header="Snapshots" CanUserClose="False">
|
||||
<views:SnapshotManagerView DataContext="{Binding SnapshotManager}" />
|
||||
</telerik:RadPane>
|
||||
<telerik:RadPane Header="Performance" CanUserClose="False">
|
||||
<views:PerformanceMonitorView DataContext="{Binding PerformanceMonitor}" />
|
||||
</telerik:RadPane>
|
||||
</telerik:RadPaneGroup>
|
||||
</telerik:RadSplitContainer>
|
||||
</telerik:RadSplitContainer>
|
||||
</telerik:RadDocking>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.ViewModels.Debug;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class DebugPanelWindow : Window
|
||||
{
|
||||
public DebugPanelWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += OnLoaded;
|
||||
Closing += OnClosing;
|
||||
}
|
||||
|
||||
private DebugPanelViewModel ViewModel => DataContext as DebugPanelViewModel;
|
||||
|
||||
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.Initialize();
|
||||
ApplyWindowConfig(ViewModel?.CurrentConfig?.Window);
|
||||
}
|
||||
|
||||
private void OnClosing(object sender, CancelEventArgs e)
|
||||
{
|
||||
ViewModel?.SaveWindowConfig(CaptureWindowConfig());
|
||||
ViewModel?.Dispose();
|
||||
}
|
||||
|
||||
private void ApplyWindowConfig(WindowConfig config)
|
||||
{
|
||||
if (config == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Width = config.Width > 0 ? config.Width : Width;
|
||||
Height = config.Height > 0 ? config.Height : Height;
|
||||
Left = config.Left;
|
||||
Top = config.Top;
|
||||
WindowState = config.State;
|
||||
}
|
||||
|
||||
private WindowConfig CaptureWindowConfig()
|
||||
{
|
||||
return new WindowConfig
|
||||
{
|
||||
Left = RestoreBounds.Left,
|
||||
Top = RestoreBounds.Top,
|
||||
Width = RestoreBounds.Width,
|
||||
Height = RestoreBounds.Height,
|
||||
State = WindowState
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<UserControl x:Class="XplorePlane.Views.Debug.EventLogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
xmlns:conv="clr-namespace:XplorePlane.Converters"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<conv:TimestampFormatConverter x:Key="TimestampFormatConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<WrapPanel Margin="0,0,0,8">
|
||||
<CheckBox Content="Motion" IsChecked="{Binding FilterOptionViewModels[0].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Ray" IsChecked="{Binding FilterOptionViewModels[1].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Detector" IsChecked="{Binding FilterOptionViewModels[2].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="System" IsChecked="{Binding FilterOptionViewModels[3].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Camera" IsChecked="{Binding FilterOptionViewModels[4].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Linked" IsChecked="{Binding FilterOptionViewModels[5].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Recipe" IsChecked="{Binding FilterOptionViewModels[6].IsEnabled}" Margin="0,0,8,4" />
|
||||
<CheckBox Content="Calibration" IsChecked="{Binding FilterOptionViewModels[7].IsEnabled}" Margin="0,0,8,4" />
|
||||
</WrapPanel>
|
||||
|
||||
<telerik:RadGridView Grid.Row="1"
|
||||
ItemsSource="{Binding FilteredEventLog}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True">
|
||||
<telerik:RadGridView.Columns>
|
||||
<telerik:GridViewDataColumn Header="Time" DataMemberBinding="{Binding Timestamp, Converter={StaticResource TimestampFormatConverter}}" Width="120" />
|
||||
<telerik:GridViewDataColumn Header="Event" DataMemberBinding="{Binding EventType}" Width="180" />
|
||||
<telerik:GridViewDataColumn Header="Category" DataMemberBinding="{Binding Category}" Width="140" />
|
||||
<telerik:GridViewDataColumn Header="Field" DataMemberBinding="{Binding FieldName}" Width="150" />
|
||||
<telerik:GridViewDataColumn Header="Old" DataMemberBinding="{Binding OldValue}" Width="*" />
|
||||
<telerik:GridViewDataColumn Header="New" DataMemberBinding="{Binding NewValue}" Width="*" />
|
||||
</telerik:RadGridView.Columns>
|
||||
</telerik:RadGridView>
|
||||
|
||||
<DockPanel Grid.Row="2" Margin="0,8,0,0">
|
||||
<TextBlock Text="{Binding FilteredEventLog.Count, StringFormat=Records: {0}}" VerticalAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||
<Button Content="All" Command="{Binding SelectAllFiltersCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="None" Command="{Binding ClearAllFiltersCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="Export JSON" Command="{Binding ExportToJsonCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="Export CSV" Command="{Binding ExportToCsvCommand}" Padding="12,4" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class EventLogView : UserControl
|
||||
{
|
||||
public EventLogView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using XplorePlane.Models;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public class LatencyWarningStyleSelector : StyleSelector
|
||||
{
|
||||
public Style NormalStyle { get; set; }
|
||||
public Style WarningStyle { get; set; }
|
||||
|
||||
public override Style SelectStyle(object item, DependencyObject container)
|
||||
{
|
||||
if (item is PerformanceMetric metric && metric.AverageLatency > 500)
|
||||
{
|
||||
return WarningStyle ?? NormalStyle;
|
||||
}
|
||||
|
||||
return NormalStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<UserControl x:Class="XplorePlane.Views.Debug.PerformanceMonitorView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
xmlns:local="clr-namespace:XplorePlane.Views.Debug"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<Style x:Key="NormalLatencyStyle" TargetType="TextBlock">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style x:Key="WarningLatencyStyle" TargetType="TextBlock">
|
||||
<Setter Property="Background" Value="#FFF7C7C7" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<local:LatencyWarningStyleSelector x:Key="LatencyWarningStyleSelector"
|
||||
NormalStyle="{StaticResource NormalLatencyStyle}"
|
||||
WarningStyle="{StaticResource WarningLatencyStyle}" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="2*" />
|
||||
<RowDefinition Height="3*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="{Binding MaxLatency, StringFormat=Max Latency: {0:F2} ms}" FontWeight="SemiBold" />
|
||||
|
||||
<telerik:RadGridView Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
ItemsSource="{Binding Metrics}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True">
|
||||
<telerik:RadGridView.Columns>
|
||||
<telerik:GridViewDataColumn Header="State" DataMemberBinding="{Binding StateType}" Width="*" />
|
||||
<telerik:GridViewDataColumn Header="Hz" DataMemberBinding="{Binding EventsPerSecond}" Width="120" />
|
||||
<telerik:GridViewDataColumn Header="Latency(ms)" DataMemberBinding="{Binding AverageLatency}" Width="140" />
|
||||
</telerik:RadGridView.Columns>
|
||||
</telerik:RadGridView>
|
||||
</Grid>
|
||||
|
||||
<telerik:RadChart Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding TrendData}">
|
||||
<telerik:RadChart.DefaultView>
|
||||
<telerik:ChartDefaultView>
|
||||
<telerik:ChartDefaultView.ChartArea>
|
||||
<telerik:ChartArea />
|
||||
</telerik:ChartDefaultView.ChartArea>
|
||||
<telerik:ChartDefaultView.ChartLegend>
|
||||
<telerik:ChartLegend />
|
||||
</telerik:ChartDefaultView.ChartLegend>
|
||||
</telerik:ChartDefaultView>
|
||||
</telerik:RadChart.DefaultView>
|
||||
<telerik:RadChart.SeriesMappings>
|
||||
<telerik:SeriesMapping CollectionIndex="0">
|
||||
<telerik:SeriesMapping.SeriesDefinition>
|
||||
<telerik:LineSeriesDefinition />
|
||||
</telerik:SeriesMapping.SeriesDefinition>
|
||||
<telerik:ItemMapping DataPointMember="XValue" FieldName="Timestamp" />
|
||||
<telerik:ItemMapping DataPointMember="YValue" FieldName="Value" />
|
||||
</telerik:SeriesMapping>
|
||||
</telerik:RadChart.SeriesMappings>
|
||||
</telerik:RadChart>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class PerformanceMonitorView : UserControl
|
||||
{
|
||||
public PerformanceMonitorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Window x:Class="XplorePlane.Views.Debug.SnapshotDiffWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
mc:Ignorable="d"
|
||||
Title="Snapshot Differences"
|
||||
Width="900"
|
||||
Height="600"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<telerik:RadGridView ItemsSource="{Binding Differences}" AutoGenerateColumns="False" IsReadOnly="True">
|
||||
<telerik:RadGridView.Columns>
|
||||
<telerik:GridViewDataColumn Header="Category" DataMemberBinding="{Binding Category}" Width="180" />
|
||||
<telerik:GridViewDataColumn Header="Field" DataMemberBinding="{Binding FieldName}" Width="180" />
|
||||
<telerik:GridViewDataColumn Header="Snapshot 1" DataMemberBinding="{Binding Value1}" Width="*" />
|
||||
<telerik:GridViewDataColumn Header="Snapshot 2" DataMemberBinding="{Binding Value2}" Width="*" />
|
||||
</telerik:RadGridView.Columns>
|
||||
</telerik:RadGridView>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Export" Command="{Binding ExportDifferencesCommand}" Padding="12,4" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class SnapshotDiffWindow : Window
|
||||
{
|
||||
public SnapshotDiffWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<UserControl x:Class="XplorePlane.Views.Debug.SnapshotManagerView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
mc:Ignorable="d">
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="2*" />
|
||||
<RowDefinition Height="3*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<telerik:RadListBox Grid.Row="0"
|
||||
ItemsSource="{Binding Snapshots}"
|
||||
SelectedItem="{Binding SelectedSnapshot, Mode=TwoWay}">
|
||||
<telerik:RadListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="2">
|
||||
<CheckBox IsChecked="{Binding IsSelected}" Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Snapshot.Timestamp, StringFormat={}{0:yyyy-MM-dd HH:mm:ss}}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</telerik:RadListBox.ItemTemplate>
|
||||
</telerik:RadListBox>
|
||||
|
||||
<telerik:RadTreeView Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding SnapshotDetails}">
|
||||
<telerik:RadTreeView.ItemTemplate>
|
||||
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Value}" Margin="8,0,0,0" Foreground="#505050" />
|
||||
</DockPanel>
|
||||
</HierarchicalDataTemplate>
|
||||
</telerik:RadTreeView.ItemTemplate>
|
||||
</telerik:RadTreeView>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Capture" Command="{Binding CaptureSnapshotCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="Compare" Command="{Binding CompareSnapshotsCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="Export" Command="{Binding ExportSnapshotCommand}" Margin="0,0,8,0" Padding="12,4" />
|
||||
<Button Content="Delete" Command="{Binding DeleteSnapshotCommand}" Padding="12,4" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class SnapshotManagerView : UserControl
|
||||
{
|
||||
public SnapshotManagerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<UserControl x:Class="XplorePlane.Views.Debug.StateDisplayView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
|
||||
xmlns:conv="clr-namespace:XplorePlane.Converters"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<conv:HighlightColorBrushConverter x:Key="HighlightColorBrushConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="8">
|
||||
<telerik:RadTreeView ItemsSource="{Binding StateTree}">
|
||||
<telerik:RadTreeView.ItemTemplate>
|
||||
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
|
||||
<Border Padding="4"
|
||||
Margin="0,1"
|
||||
Background="{Binding HighlightColor, Converter={StaticResource HighlightColorBrushConverter}}">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Value}" Margin="8,0,0,0" Foreground="#505050" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</HierarchicalDataTemplate>
|
||||
</telerik:RadTreeView.ItemTemplate>
|
||||
</telerik:RadTreeView>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace XplorePlane.Views.Debug
|
||||
{
|
||||
public partial class StateDisplayView : UserControl
|
||||
{
|
||||
public StateDisplayView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user