增加appstate调试页面

This commit is contained in:
zhengxuan.zhang
2026-05-18 09:38:29 +08:00
parent 09ac6db6ab
commit bd0ed6fd9a
26 changed files with 2948 additions and 0 deletions
+2
View File
@@ -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
};
}
}
}
+55
View File
@@ -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();
}
}
}