diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 760813e..7e69559 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal)", "Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj --no-build -v minimal)", "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json'\\)\\); json.load\\(open\\('.vscode/tasks.json'\\)\\); print\\('JSON valid.'\\)\")", - "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")" + "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")", + "Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal')" ] } } diff --git a/NLog.config b/NLog.config index d2a3eaf..d9341a3 100644 --- a/NLog.config +++ b/NLog.config @@ -6,38 +6,35 @@ throwExceptions="false" internalLogLevel="Off" > - - - + - + + + - - - - + + - - + + - - + + + + + diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs index 8c7c5a9..96f3374 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -1,6 +1,7 @@ using Flyshot.Core.Config; using Flyshot.Core.Domain; using Flyshot.Runtime.Common; +using Microsoft.Extensions.Logging; namespace Flyshot.ControllerClientCompat; @@ -17,6 +18,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator; private readonly RobotConfigLoader _configLoader; private readonly IFlyshotTrajectoryStore _trajectoryStore; + private readonly ILogger? _logger; private RobotProfile? _activeRobotProfile; private string? _configuredRobotName; private CompatibilityRobotSettings? _robotSettings; @@ -35,13 +37,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// 轨迹规划与触发编排器。 /// 旧版 RobotConfig.json 加载器。 /// 已上传轨迹持久化存储。 + /// 日志记录器;允许测试直接构造时传入 null。 public ControllerClientCompatService( ControllerClientCompatOptions options, ControllerClientCompatRobotCatalog robotCatalog, IControllerRuntime runtime, ControllerClientTrajectoryOrchestrator trajectoryOrchestrator, RobotConfigLoader configLoader, - IFlyshotTrajectoryStore trajectoryStore) + IFlyshotTrajectoryStore trajectoryStore, + ILogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog)); @@ -49,6 +53,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator)); _configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader)); _trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore)); + _logger = logger; } /// @@ -90,6 +95,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _connectedServerIp = serverIp; _connectedServerPort = port; } + + _logger?.LogInformation("ConnectServer 完成: {ServerIp}:{Port}", serverIp, port); } /// @@ -107,6 +114,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// public void SetUpRobot(string robotName) { + _logger?.LogInformation("SetUpRobot 开始: robotName={RobotName}", robotName); + var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings(); var robotProfile = _robotCatalog.LoadProfile( robotName, @@ -129,6 +138,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi _uploadedTrajectories[saved.Key] = saved.Value; } } + + _logger?.LogInformation( + "SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, 恢复轨迹数={TrajCount}", + robotName, + robotProfile.DegreesOfFreedom, + robotSettings.AccLimitScale, + robotSettings.JerkLimitScale, + _uploadedTrajectories.Count); } /// @@ -184,11 +201,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp)); } + _logger?.LogInformation("Connect 开始: robotIp={RobotIp}", robotIp); + lock (_stateLock) { EnsureRobotSetup(); _runtime.Connect(robotIp); } + + _logger?.LogInformation("Connect 完成: robotIp={RobotIp}", robotIp); } /// @@ -204,31 +225,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi /// public void EnableRobot(int bufferSize) { + _logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}", bufferSize); lock (_stateLock) { EnsureRobotSetup(); _runtime.EnableRobot(bufferSize); } + _logger?.LogInformation("EnableRobot 完成"); } /// public void DisableRobot() { + _logger?.LogInformation("DisableRobot 开始"); lock (_stateLock) { EnsureRobotSetup(); _runtime.DisableRobot(); } + _logger?.LogInformation("DisableRobot 完成"); } /// public void StopMove() { + _logger?.LogInformation("StopMove 开始"); lock (_stateLock) { EnsureRobotSetup(); _runtime.StopMove(); } + _logger?.LogInformation("StopMove 完成"); } /// @@ -335,6 +362,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi { ArgumentNullException.ThrowIfNull(jointPositions); + _logger?.LogInformation("MoveJoint 开始: 目标关节数={JointCount}", jointPositions.Count); + _logger?.LogDebug("MoveJoint 目标关节: {Joints}", string.Join(", ", jointPositions.Select(j => j.ToString("F4")))); + lock (_stateLock) { var robot = RequireActiveRobot(); @@ -345,8 +375,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi var speedRatio = _runtime.GetSnapshot().SpeedRatio; var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, jointPositions, speedRatio); + _logger?.LogInformation( + "MoveJoint 规划完成: 当前速度倍率={SpeedRatio}, 规划时长={Duration}s, 采样点数={SampleCount}", + speedRatio, + moveResult.Duration.TotalSeconds, + moveResult.DenseJointTrajectory?.Count ?? 0); _runtime.ExecuteTrajectory(moveResult, jointPositions); } + + _logger?.LogInformation("MoveJoint 完成"); } /// @@ -359,6 +396,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("轨迹路点不能为空。", nameof(waypoints)); } + _logger?.LogInformation("ExecuteTrajectory 开始: 路点数={WaypointCount}, method={Method}, saveTraj={SaveTraj}", + waypoints.Count, options.Method, options.SaveTrajectory); + _logger?.LogDebug("ExecuteTrajectory 路点详情: {Waypoints}", + string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Select(j => j.ToString("F4")))}]"))); + lock (_stateLock) { var robot = RequireActiveRobot(); @@ -366,9 +408,17 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi // 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。 var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints, options); + _logger?.LogInformation( + "ExecuteTrajectory 规划完成: method={Method}, 时长={Duration}s, 有效={IsValid}, 采样点数={SampleCount}", + bundle.Result.Method, + bundle.Result.Duration.TotalSeconds, + bundle.Result.IsValid, + bundle.Result.DenseJointTrajectory?.Count ?? 0); var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions; _runtime.ExecuteTrajectory(bundle.Result, finalJointPositions); } + + _logger?.LogInformation("ExecuteTrajectory 完成"); } /// @@ -386,6 +436,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi { ArgumentNullException.ThrowIfNull(trajectory); + _logger?.LogInformation( + "UploadTrajectory 开始: name={Name}, waypoints={WaypointCount}, shotFlags={ShotCount}", + trajectory.Name, + trajectory.Waypoints.Count, + trajectory.ShotFlags.Count(static f => f)); + lock (_stateLock) { EnsureRuntimeEnabled(); @@ -395,6 +451,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi var settings = _robotSettings ?? CreateDefaultRobotSettings(); _trajectoryStore.Save(robotName, settings, trajectory); } + + _logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name); } /// @@ -415,6 +473,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("轨迹名称不能为空。", nameof(name)); } + _logger?.LogInformation( + "ExecuteTrajectoryByName 开始: name={Name}, method={Method}, moveToStart={MoveToStart}, useCache={UseCache}", + name, options.Method, options.MoveToStart, options.UseCache); + lock (_stateLock) { var robot = RequireActiveRobot(); @@ -422,24 +484,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) { + _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } if (trajectory.Waypoints.Count == 0) { + _logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹无路点 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory contains no waypoints."); } // 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。 var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings()); + _logger?.LogInformation( + "ExecuteTrajectoryByName 规划完成: name={Name}, method={Method}, 时长={Duration}s, 触发事件数={TriggerCount}, 使用缓存={UsedCache}", + name, + bundle.Result.Method, + bundle.Result.Duration.TotalSeconds, + bundle.Result.TriggerTimeline.Count, + bundle.Result.UsedCache); + if (options.MoveToStart) { + _logger?.LogInformation("ExecuteTrajectoryByName 先移动到起点"); _runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions); } var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions; _runtime.ExecuteTrajectory(bundle.Result, finalJointPositions); } + + _logger?.LogInformation("ExecuteTrajectoryByName 完成: name={Name}", name); } /// @@ -450,11 +525,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("轨迹名称不能为空。", nameof(name)); } + _logger?.LogInformation("SaveTrajectoryInfo 开始: name={Name}, method={Method}", name, method); + lock (_stateLock) { var robot = RequireActiveRobot(); if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) { + _logger?.LogWarning("SaveTrajectoryInfo 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } @@ -469,6 +547,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi var settings = _robotSettings ?? CreateDefaultRobotSettings(); _trajectoryStore.Save(robotName, settings, trajectory); } + + _logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name); } /// @@ -479,11 +559,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("轨迹名称不能为空。", nameof(name)); } + _logger?.LogInformation("IsFlyshotTrajectoryValid 开始: name={Name}, method={Method}", name, method); + lock (_stateLock) { var robot = RequireActiveRobot(); if (!_uploadedTrajectories.TryGetValue(name, out var trajectory)) { + _logger?.LogWarning("IsFlyshotTrajectoryValid 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("FlyShot trajectory does not exist."); } @@ -494,6 +577,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi RequireRobotSettings()); duration = bundle.Result.Duration; + _logger?.LogInformation( + "IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", + name, bundle.Result.IsValid, duration.TotalSeconds); return bundle.Result.IsValid; } } @@ -506,16 +592,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi throw new ArgumentException("轨迹名称不能为空。", nameof(name)); } + _logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name); + lock (_stateLock) { if (!_uploadedTrajectories.Remove(name)) { + _logger?.LogWarning("DeleteTrajectory 失败: 轨迹不存在 name={Name}", name); throw new InvalidOperationException("DeleteFlyShotTraj failed"); } var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup."); _trajectoryStore.Delete(robotName, name); } + + _logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name); } /// diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs index a7013ec..f0b1148 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs @@ -3,6 +3,7 @@ using Flyshot.Core.Domain; using Flyshot.Core.Planning; using Flyshot.Core.Planning.Sampling; using Flyshot.Core.Triggering; +using Microsoft.Extensions.Logging; namespace Flyshot.ControllerClientCompat; @@ -11,10 +12,22 @@ namespace Flyshot.ControllerClientCompat; /// public sealed class ControllerClientTrajectoryOrchestrator { - private readonly ICspPlanner _icspPlanner = new(); - private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner = new(); + private readonly ICspPlanner _icspPlanner; + private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner; private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver()); private readonly Dictionary _flyshotCache = new(StringComparer.Ordinal); + private readonly ILogger? _logger; + + /// + /// 初始化轨迹编排器。 + /// + /// 日志记录器;允许 null。 + public ControllerClientTrajectoryOrchestrator(ILogger? logger = null) + { + _logger = logger; + _icspPlanner = new(logger: null); + _selfAdaptIcspPlanner = new(logger: null); + } /// /// 对普通轨迹执行 ICSP 规划。 @@ -31,6 +44,10 @@ public sealed class ControllerClientTrajectoryOrchestrator ArgumentNullException.ThrowIfNull(waypoints); options ??= new TrajectoryExecutionOptions(); + _logger?.LogInformation( + "PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}", + waypoints.Count, options.Method); + var program = CreateProgram( name: "ordinary-trajectory", waypoints: waypoints, @@ -49,6 +66,11 @@ public sealed class ControllerClientTrajectoryOrchestrator var shotTimeline = new ShotTimeline(Array.Empty(), Array.Empty()); var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false); + _logger?.LogInformation( + "PlanOrdinaryTrajectory 完成: 时长={Duration}s, 采样点数={SampleCount}", + result.Duration.TotalSeconds, + result.DenseJointTrajectory?.Count ?? 0); + return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result); } @@ -69,6 +91,10 @@ public sealed class ControllerClientTrajectoryOrchestrator options ??= new FlyshotExecutionOptions(); settings ??= CreateDefaultRobotSettings(); + _logger?.LogInformation( + "PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}", + uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache); + var program = CreateProgram( name: uploaded.Name, waypoints: uploaded.Waypoints, @@ -80,6 +106,7 @@ public sealed class ControllerClientTrajectoryOrchestrator var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings); if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle)) { + _logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey); // 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。 return new PlannedExecutionBundle( cachedBundle.PlannedTrajectory, @@ -104,6 +131,10 @@ public sealed class ControllerClientTrajectoryOrchestrator var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false); var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result); + _logger?.LogInformation( + "PlanUploadedFlyshot 完成: name={Name}, 时长={Duration}s, 触发事件数={TriggerCount}, 采样点数={SampleCount}", + uploaded.Name, result.Duration.TotalSeconds, result.TriggerTimeline.Count, result.DenseJointTrajectory?.Count ?? 0); + if (options.UseCache) { _flyshotCache[cacheKey] = bundle; diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs index 1cbcce6..7f54aff 100644 --- a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs +++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Flyshot.Core.Config; using Flyshot.Core.Domain; +using Microsoft.Extensions.Logging; namespace Flyshot.ControllerClientCompat; @@ -41,16 +42,19 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore { private readonly ControllerClientCompatOptions _options; private readonly RobotConfigLoader _configLoader; + private readonly ILogger? _logger; /// /// 初始化基于 JSON 文件的轨迹存储。 /// /// 兼容层基础配置,用于定位工作区根目录。 /// 旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。 - public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader) + /// 日志记录器;允许 null。 + public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader)); + _logger = logger; } /// @@ -59,6 +63,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(trajectory); + _logger?.LogInformation( + "TrajectoryStore 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}", + robotName, + trajectory.Name, + trajectory.Waypoints.Count); + var path = ResolveStorePath(robotName); var directory = Path.GetDirectoryName(path)!; Directory.CreateDirectory(directory); @@ -92,6 +102,8 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; File.WriteAllText(path, root.ToJsonString(writeOptions)); + + _logger?.LogInformation("TrajectoryStore 轨迹已保存到 {Path}", path); } /// @@ -102,9 +114,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName)); } + _logger?.LogInformation("TrajectoryStore 删除轨迹: robot={RobotName}, name={TrajectoryName}", robotName, trajectoryName); + var path = ResolveStorePath(robotName); if (!File.Exists(path)) { + _logger?.LogWarning("TrajectoryStore 删除失败: 文件不存在 {Path}", path); return; } @@ -112,19 +127,27 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore var root = JsonNode.Parse(existingJson)?.AsObject(); if (root is null) { + _logger?.LogWarning("TrajectoryStore 删除失败: 无法解析 JSON {Path}", path); return; } if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj) { - flyingShotsObj.Remove(trajectoryName); - - var writeOptions = new JsonSerializerOptions + var removed = flyingShotsObj.Remove(trajectoryName); + if (removed) { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }; - File.WriteAllText(path, root.ToJsonString(writeOptions)); + var writeOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + File.WriteAllText(path, root.ToJsonString(writeOptions)); + _logger?.LogInformation("TrajectoryStore 轨迹已删除: {TrajectoryName}", trajectoryName); + } + else + { + _logger?.LogWarning("TrajectoryStore 删除失败: 轨迹不存在 {TrajectoryName}", trajectoryName); + } } } @@ -134,12 +157,15 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore var path = ResolveStorePath(robotName); if (!File.Exists(path)) { + _logger?.LogInformation("TrajectoryStore 无持久化数据: {Path}", path); settings = null; return new Dictionary(StringComparer.Ordinal); } try { + _logger?.LogInformation("TrajectoryStore 正在加载: {Path}", path); + var workspaceRoot = ResolveWorkspaceRoot(); var loaded = _configLoader.Load(path, workspaceRoot); settings = loaded.Robot; @@ -156,10 +182,18 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore dict[program.Key] = traj; } + _logger?.LogInformation( + "TrajectoryStore 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}", + robotName, + dict.Count, + settings?.UseDo, + settings?.IoKeepCycles); + return dict; } - catch + catch (Exception ex) { + _logger?.LogError(ex, "TrajectoryStore 加载失败: {Path}", path); settings = null; return new Dictionary(StringComparer.Ordinal); } diff --git a/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj b/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj index 1ccc45d..d159671 100644 --- a/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj +++ b/src/Flyshot.Core.Planning/Flyshot.Core.Planning.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/src/Flyshot.Core.Planning/ICspPlanner.cs b/src/Flyshot.Core.Planning/ICspPlanner.cs index cbe97bd..65f571a 100644 --- a/src/Flyshot.Core.Planning/ICspPlanner.cs +++ b/src/Flyshot.Core.Planning/ICspPlanner.cs @@ -1,4 +1,5 @@ using Flyshot.Core.Domain; +using Microsoft.Extensions.Logging; namespace Flyshot.Core.Planning; @@ -33,6 +34,7 @@ public sealed class ICspPlanner private readonly int _maxIterations; private readonly bool _enforceFinalScale; private readonly double _finalScaleTolerance; + private readonly ILogger? _logger; /// /// 初始化 ICSP 规划器。 @@ -41,11 +43,13 @@ public sealed class ICspPlanner /// 最大迭代轮数。 /// 是否在最终最优 scale 仍大于 1.0 时抛出失败。 /// 最终 scale 判定容差。 + /// 日志记录器;允许 null,供无日志场景使用。 public ICspPlanner( double threshold = DefaultThreshold, int maxIterations = DefaultMaxIterations, bool enforceFinalScale = true, - double finalScaleTolerance = DefaultFinalScaleTolerance) + double finalScaleTolerance = DefaultFinalScaleTolerance, + ILogger? logger = null) { if (threshold <= 0.0 || double.IsNaN(threshold) || double.IsInfinity(threshold)) { @@ -66,6 +70,7 @@ public sealed class ICspPlanner _maxIterations = maxIterations; _enforceFinalScale = enforceFinalScale; _finalScaleTolerance = finalScaleTolerance; + _logger = logger; } /// @@ -81,9 +86,22 @@ public sealed class ICspPlanner throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request)); } + _logger?.LogInformation( + "ICSP 规划开始: 名称={Name}, 路点数={WaypointCount}, 自由度={Dof}, threshold={Threshold}, maxIterations={MaxIterations}", + request.Program.Name, waypoints.Count, request.Robot.DegreesOfFreedom, _threshold, _maxIterations); + _logger?.LogDebug( + "ICSP 输入路点: {Waypoints}", + string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Positions.Select(j => j.ToString("F4")))}]"))); + var qs = WaypointsToArray(waypoints); var (velLimits, accLimits, jerkLimits) = ExtractLimits(request.Robot); + _logger?.LogDebug( + "ICSP 约束限值: vel=[{Vel}], acc=[{Acc}], jerk=[{Jerk}]", + string.Join(", ", velLimits.Select(v => v.ToString("F2"))), + string.Join(", ", accLimits.Select(a => a.ToString("F2"))), + string.Join(", ", jerkLimits.Select(j => j.ToString("F2")))); + // 初始段时长直接取相邻示教点的关节空间欧氏距离。 var segmentDurations = ComputeInitialDurations(qs); int nseg = segmentDurations.Length; @@ -135,6 +153,9 @@ public sealed class ICspPlanner if (currentThreshold < _threshold) { + _logger?.LogDebug( + "ICSP 第 {Iteration} 轮收敛: threshold={CurrentThreshold:E6}", + iteration + 1, currentThreshold); break; } @@ -152,10 +173,22 @@ public sealed class ICspPlanner var globalScale = bestScales.Max(); if (_enforceFinalScale && globalScale > 1.0 + _finalScaleTolerance) { + _logger?.LogError( + "ICSP 规划未收敛: global_scale={GlobalScale:F6} > {Tolerance:F6}, 段缩放=[{Scales}]", + globalScale, 1.0 + _finalScaleTolerance, + string.Join(", ", bestScales.Select(s => s.ToString("F4")))); throw new InvalidOperationException( $"ICSP 规划未收敛,global_scale={globalScale:F6} > {1.0 + _finalScaleTolerance:F6},轨迹不可执行。"); } + _logger?.LogInformation( + "ICSP 规划完成: 名称={Name}, 迭代轮数={Iterations}, 收敛阈值={Threshold:E6}, 总时长={Duration:F4}s, global_scale={GlobalScale:F6}", + request.Program.Name, bestIterations, bestThreshold, bestWaypointTimes[^1], globalScale); + _logger?.LogDebug( + "ICSP 段时长: [{Durations}], 段缩放: [{Scales}]", + string.Join(", ", bestDurations.Select(d => d.ToString("F4"))), + string.Join(", ", bestScales.Select(s => s.ToString("F4")))); + return new PlannedTrajectory( robot: request.Robot, originalProgram: request.Program, diff --git a/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs b/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs index be3fb86..aef327a 100644 --- a/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs +++ b/src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs @@ -1,182 +1,213 @@ -using Flyshot.Core.Domain; - -namespace Flyshot.Core.Planning; - -/// -/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。 -/// -/// 为什么需要这层? -/// --- -/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果; -/// 配置中还明确存在 adapt_icsp_try_num。本层把“超限段统一插入中点后再重规划”的逻辑显式落地, -/// 补上 demo 缺失的失败恢复路径。 -/// -/// 补点策略: -/// --- -/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner -/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定, -/// 也更符合服务端 adapt_icsp_try_num 的意图。 -/// -public sealed class SelfAdaptIcspPlanner -{ - /// - /// 判定段是否超限的数值容差,过滤浮点噪声。 - /// - public const double ScaleTolerance = 5e-4; - - private readonly ICspPlanner _innerPlanner = new(enforceFinalScale: false); - - /// - /// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。 - /// - /// 轨迹规划请求。 - /// 最大补点重试次数(默认 5)。 - /// 规划后的轨迹结果。 - /// 超过最大重试次数仍未收敛时抛出。 - public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5) - { - ArgumentNullException.ThrowIfNull(request); - - var currentWaypoints = request.Program.Waypoints.ToArray(); - var currentShotFlags = request.Program.ShotFlags.ToArray(); - var currentOffsets = request.Program.OffsetValues.ToArray(); - var currentAddrs = request.Program.AddressGroups.ToArray(); - int originalWaypointCount = currentWaypoints.Length; - - if (originalWaypointCount < 4) - { - throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request)); - } - - var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs); - var currentRequest = new TrajectoryRequest( - robot: request.Robot, - program: currentProgram, - method: PlanningMethod.SelfAdaptIcsp, - moveToStart: request.MoveToStart, - saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts, - useCache: request.UseCache); - - PlannedTrajectory? lastTrajectory = null; - int maxAttempts = Math.Max(0, adaptIcspTryNum); - - for (int attempt = 0; attempt <= maxAttempts; attempt++) - { - var trajectory = _innerPlanner.Plan(currentRequest); - lastTrajectory = trajectory; - - var badSegments = new List(); - for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++) - { - if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance) - { - badSegments.Add(seg); - } - } - - if (badSegments.Count == 0) - { - // 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。 - return new PlannedTrajectory( - robot: trajectory.Robot, - originalProgram: request.Program, - plannedWaypoints: currentWaypoints, - waypointTimes: trajectory.WaypointTimes, - segmentDurations: trajectory.SegmentDurations, - segmentScales: trajectory.SegmentScales, - method: PlanningMethod.SelfAdaptIcsp, - iterations: trajectory.Iterations, - threshold: trajectory.Threshold); - } - - if (attempt >= maxAttempts) - { - break; - } - - // 对超限段插入中点,并同步扩展 shot 元数据。 - (currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) = - InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments); - - currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs); - currentRequest = new TrajectoryRequest( - robot: request.Robot, - program: currentProgram, - method: PlanningMethod.SelfAdaptIcsp, - moveToStart: request.MoveToStart, - saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts, - useCache: request.UseCache); - } - - double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN; - throw new InvalidOperationException( - $"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。"); - } - - /// - /// 用当前路点集和元数据构造 FlyshotProgram。 - /// - private static FlyshotProgram BuildProgram( - string name, - JointWaypoint[] waypoints, - bool[] shotFlags, - int[] offsets, - IoAddressGroup[] addrs) - { - return new FlyshotProgram( - name: name, - waypoints: waypoints, - shotFlags: shotFlags, - offsetValues: offsets, - addressGroups: addrs); - } - - /// - /// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。 - /// 新插入的路点默认 shotFlag=false、offset=0、addr=空。 - /// - private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs) - InsertSegmentMidpoints( - JointWaypoint[] waypoints, - bool[] shotFlags, - int[] offsets, - IoAddressGroup[] addrs, - List badSegments) - { - if (badSegments.Count == 0) - { - return (waypoints, shotFlags, offsets, addrs); - } - - var badSet = new HashSet(badSegments); - var newWaypoints = new List(waypoints.Length + badSegments.Count); - var newShotFlags = new List(waypoints.Length + badSegments.Count); - var newOffsets = new List(waypoints.Length + badSegments.Count); - var newAddrs = new List(waypoints.Length + badSegments.Count); - - newWaypoints.Add(waypoints[0]); - newShotFlags.Add(shotFlags[0]); - newOffsets.Add(offsets[0]); - newAddrs.Add(addrs[0]); - - for (int seg = 0; seg < waypoints.Length - 1; seg++) - { - if (badSet.Contains(seg)) - { - var mid = new JointWaypoint( - waypoints[seg].Positions.Zip(waypoints[seg + 1].Positions, (a, b) => (a + b) / 2.0)); - newWaypoints.Add(mid); - newShotFlags.Add(false); - newOffsets.Add(0); - newAddrs.Add(new IoAddressGroup(Array.Empty())); - } - - newWaypoints.Add(waypoints[seg + 1]); - newShotFlags.Add(shotFlags[seg + 1]); - newOffsets.Add(offsets[seg + 1]); - newAddrs.Add(addrs[seg + 1]); - } - - return (newWaypoints.ToArray(), newShotFlags.ToArray(), newOffsets.ToArray(), newAddrs.ToArray()); - } -} +using Flyshot.Core.Domain; +using Microsoft.Extensions.Logging; + +namespace Flyshot.Core.Planning; + +/// +/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。 +/// +/// 为什么需要这层? +/// --- +/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1,不会直接返回未收敛结果; +/// 配置中还明确存在 adapt_icsp_try_num。本层把"超限段统一插入中点后再重规划"的逻辑显式落地, +/// 补上 demo 缺失的失败恢复路径。 +/// +/// 补点策略: +/// --- +/// 对当前所有 scale > 1 + tolerance 的段统一插入关节空间中点,然后把新路点集交给 ICSPPlanner +/// 重新规划。这种"先把明显病灶都降一档,再整体重规划"的策略比逐段拆分更稳定, +/// 也更符合服务端 adapt_icsp_try_num 的意图。 +/// +public sealed class SelfAdaptIcspPlanner +{ + /// + /// 判定段是否超限的数值容差,过滤浮点噪声。 + /// + public const double ScaleTolerance = 5e-4; + + private readonly ICspPlanner _innerPlanner; + private readonly ILogger? _logger; + + /// + /// 初始化 SelfAdaptIcspPlanner。 + /// + /// 日志记录器;允许 null。 + public SelfAdaptIcspPlanner(ILogger? logger = null) + { + _innerPlanner = new ICspPlanner(enforceFinalScale: false, logger: null); + _logger = logger; + } + + /// + /// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。 + /// + /// 轨迹规划请求。 + /// 最大补点重试次数(默认 5)。 + /// 规划后的轨迹结果。 + /// 超过最大重试次数仍未收敛时抛出。 + public PlannedTrajectory Plan(TrajectoryRequest request, int adaptIcspTryNum = 5) + { + ArgumentNullException.ThrowIfNull(request); + + var currentWaypoints = request.Program.Waypoints.ToArray(); + var currentShotFlags = request.Program.ShotFlags.ToArray(); + var currentOffsets = request.Program.OffsetValues.ToArray(); + var currentAddrs = request.Program.AddressGroups.ToArray(); + int originalWaypointCount = currentWaypoints.Length; + + if (originalWaypointCount < 4) + { + throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request)); + } + + _logger?.LogInformation( + "SelfAdaptICSP 规划开始: 名称={Name}, 原始路点数={WaypointCount}, 最大补点次数={MaxAttempts}", + request.Program.Name, originalWaypointCount, adaptIcspTryNum); + + var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs); + var currentRequest = new TrajectoryRequest( + robot: request.Robot, + program: currentProgram, + method: PlanningMethod.SelfAdaptIcsp, + moveToStart: request.MoveToStart, + saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts, + useCache: request.UseCache); + + PlannedTrajectory? lastTrajectory = null; + int maxAttempts = Math.Max(0, adaptIcspTryNum); + + for (int attempt = 0; attempt <= maxAttempts; attempt++) + { + var trajectory = _innerPlanner.Plan(currentRequest); + lastTrajectory = trajectory; + + var badSegments = new List(); + for (int seg = 0; seg < trajectory.SegmentScales.Count; seg++) + { + if (trajectory.SegmentScales[seg] > 1.0 + ScaleTolerance) + { + badSegments.Add(seg); + } + } + + if (badSegments.Count == 0) + { + _logger?.LogInformation( + "SelfAdaptICSP 规划完成: 名称={Name}, 补点轮数={Attempts}, 最终路点数={WaypointCount}, 迭代次数={Iterations}, 总时长={Duration:F4}s", + request.Program.Name, attempt, currentWaypoints.Length, trajectory.Iterations, trajectory.WaypointTimes[^1]); + // 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。 + return new PlannedTrajectory( + robot: trajectory.Robot, + originalProgram: request.Program, + plannedWaypoints: currentWaypoints, + waypointTimes: trajectory.WaypointTimes, + segmentDurations: trajectory.SegmentDurations, + segmentScales: trajectory.SegmentScales, + method: PlanningMethod.SelfAdaptIcsp, + iterations: trajectory.Iterations, + threshold: trajectory.Threshold); + } + + _logger?.LogWarning( + "SelfAdaptICSP 第 {Attempt} 轮存在超限段: 超限段数={BadCount}, 段索引=[{Segments}], 最大缩放={MaxScale:F4}", + attempt, badSegments.Count, string.Join(", ", badSegments), trajectory.SegmentScales.Max()); + + if (attempt >= maxAttempts) + { + break; + } + + // 对超限段插入中点,并同步扩展 shot 元数据。 + int waypointCountBefore = currentWaypoints.Length; + (currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) = + InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments); + + _logger?.LogDebug( + "SelfAdaptICSP 补中点: 路点数 {Before} -> {After}, 插入段=[{Segments}]", + waypointCountBefore, currentWaypoints.Length, string.Join(", ", badSegments)); + + currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs); + currentRequest = new TrajectoryRequest( + robot: request.Robot, + program: currentProgram, + method: PlanningMethod.SelfAdaptIcsp, + moveToStart: request.MoveToStart, + saveTrajectoryArtifacts: request.SaveTrajectoryArtifacts, + useCache: request.UseCache); + } + + double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN; + _logger?.LogError( + "SelfAdaptICSP 规划失败: 名称={Name}, 在 {Attempts} 轮补点后仍未收敛, 最大段缩放因子={MaxScale:F6}", + request.Program.Name, adaptIcspTryNum, maxScale); + throw new InvalidOperationException( + $"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。"); + } + + /// + /// 用当前路点集和元数据构造 FlyshotProgram。 + /// + private static FlyshotProgram BuildProgram( + string name, + JointWaypoint[] waypoints, + bool[] shotFlags, + int[] offsets, + IoAddressGroup[] addrs) + { + return new FlyshotProgram( + name: name, + waypoints: waypoints, + shotFlags: shotFlags, + offsetValues: offsets, + addressGroups: addrs); + } + + /// + /// 对超限段统一插入关节空间中点,并同步扩展 shot 元数据。 + /// 新插入的路点默认 shotFlag=false、offset=0、addr=空。 + /// + private static (JointWaypoint[] waypoints, bool[] shotFlags, int[] offsets, IoAddressGroup[] addrs) + InsertSegmentMidpoints( + JointWaypoint[] waypoints, + bool[] shotFlags, + int[] offsets, + IoAddressGroup[] addrs, + List badSegments) + { + if (badSegments.Count == 0) + { + return (waypoints, shotFlags, offsets, addrs); + } + + var badSet = new HashSet(badSegments); + var newWaypoints = new List(waypoints.Length + badSegments.Count); + var newShotFlags = new List(waypoints.Length + badSegments.Count); + var newOffsets = new List(waypoints.Length + badSegments.Count); + var newAddrs = new List(waypoints.Length + badSegments.Count); + + newWaypoints.Add(waypoints[0]); + newShotFlags.Add(shotFlags[0]); + newOffsets.Add(offsets[0]); + newAddrs.Add(addrs[0]); + + for (int seg = 0; seg < waypoints.Length - 1; seg++) + { + if (badSet.Contains(seg)) + { + var mid = new JointWaypoint( + waypoints[seg].Positions.Zip(waypoints[seg + 1].Positions, (a, b) => (a + b) / 2.0)); + newWaypoints.Add(mid); + newShotFlags.Add(false); + newOffsets.Add(0); + newAddrs.Add(new IoAddressGroup(Array.Empty())); + } + + newWaypoints.Add(waypoints[seg + 1]); + newShotFlags.Add(shotFlags[seg + 1]); + newOffsets.Add(offsets[seg + 1]); + newAddrs.Add(addrs[seg + 1]); + } + + return (newWaypoints.ToArray(), newShotFlags.ToArray(), newOffsets.ToArray(), newAddrs.ToArray()); + } +} diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs index 1c8eae6..cf26d17 100644 --- a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs +++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using Flyshot.Core.Domain; using Flyshot.Runtime.Common; using Flyshot.Runtime.Fanuc.Protocol; +using Microsoft.Extensions.Logging; namespace Flyshot.Runtime.Fanuc; @@ -16,6 +17,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable private readonly FanucCommandClient _commandClient; private readonly FanucStateClient _stateClient; private readonly FanucJ519Client _j519Client; + private readonly ILogger? _logger; private RobotProfile? _robot; private string? _robotName; @@ -35,21 +37,24 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable /// /// 初始化 FANUC 控制器运行时。 /// - public FanucControllerRuntime() + /// 日志记录器;允许 null,供无日志场景使用。 + public FanucControllerRuntime(ILogger? logger = null) { _commandClient = new FanucCommandClient(); _stateClient = new FanucStateClient(); _j519Client = new FanucJ519Client(); + _logger = logger; } /// /// 供测试注入 mock 客户端的内部构造函数。 /// - internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client) + internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client, ILogger? logger = null) { _commandClient = commandClient; _stateClient = stateClient; _j519Client = j519Client; + _logger = logger; } /// @@ -61,6 +66,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable throw new ArgumentException("机器人名称不能为空。", nameof(robotName)); } + _logger?.LogInformation("ResetRobot: robotName={RobotName}, dof={Dof}", robotName, robot.DegreesOfFreedom); + lock (_stateLock) { DisconnectClients(); @@ -82,6 +89,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable /// public void SetActiveController(bool sim) { + _logger?.LogInformation("SetActiveController: sim={Sim}", sim); lock (_stateLock) { EnsureRobotSetup(); @@ -101,6 +109,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp)); } + _logger?.LogInformation("Connect 开始: robotIp={RobotIp}, 仿真={IsSim}", robotIp, _activeControllerIsSimulation); + lock (_stateLock) { EnsureActiveControllerSelected(); @@ -109,6 +119,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _connectedRobotIp = robotIp; _isEnabled = false; _isInMotion = false; + _logger?.LogInformation("Connect 完成(仿真): robotIp={RobotIp}", robotIp); return; } @@ -121,11 +132,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _isEnabled = false; _isInMotion = false; } + + _logger?.LogInformation("Connect 完成(真机): robotIp={RobotIp}, 三条通道已建立", robotIp); } /// public void Disconnect() { + _logger?.LogInformation("Disconnect 开始"); lock (_stateLock) { EnsureRobotSetup(); @@ -135,6 +149,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _isEnabled = false; _isInMotion = false; } + _logger?.LogInformation("Disconnect 完成"); } /// @@ -145,6 +160,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。"); } + _logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}, 仿真={IsSim}", bufferSize, _activeControllerIsSimulation); + lock (_stateLock) { EnsureConnected(); @@ -153,6 +170,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable if (IsSimulationMode) { _isEnabled = true; + _logger?.LogInformation("EnableRobot 完成(仿真)"); return; } @@ -165,11 +183,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _j519Client.StartMotion(); _isEnabled = true; } + + _logger?.LogInformation("EnableRobot 完成(真机): RVBUSTSM 已启动, J519 运动循环已开启"); } /// public void DisableRobot() { + _logger?.LogInformation("DisableRobot 开始"); lock (_stateLock) { EnsureRobotSetup(); @@ -183,11 +204,13 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _isEnabled = false; _isInMotion = false; } + _logger?.LogInformation("DisableRobot 完成"); } /// public void StopMove() { + _logger?.LogInformation("StopMove 开始"); lock (_stateLock) { EnsureRobotSetup(); @@ -199,6 +222,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _isInMotion = false; } + _logger?.LogInformation("StopMove 完成"); } /// @@ -225,6 +249,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。"); } + _logger?.LogInformation("SetSpeedRatio: ratio={Ratio}", ratio); + lock (_stateLock) { EnsureConnected(); @@ -236,6 +262,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _speedRatio = clampedRatio; } + + _logger?.LogInformation("SetSpeedRatio 完成: clampedRatio={ClampedRatio}", _speedRatio); } /// @@ -426,6 +454,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(finalJointPositions); + _logger?.LogInformation( + "ExecuteTrajectory 开始: program={ProgramName}, method={Method}, 时长={Duration}s, 稠密采样={HasDense}, 触发事件数={TriggerCount}, speedRatio={SpeedRatio}", + result.ProgramName, result.Method, result.Duration.TotalSeconds, + result.DenseJointTrajectory is not null, result.TriggerTimeline.Count, _speedRatio); + lock (_stateLock) { EnsureEnabled(); @@ -447,6 +480,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _sendCts = new CancellationTokenSource(); var ct = _sendCts.Token; _sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct); + _logger?.LogInformation("ExecuteTrajectory 已启动后台稠密发送任务"); return; } @@ -462,6 +496,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable _isInMotion = true; _jointPositions = finalJointPositions.ToArray(); _isInMotion = false; + _logger?.LogInformation("ExecuteTrajectory 完成(单点模式)"); } } @@ -498,12 +533,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds); var periodTicks = (long)(servoPeriodSeconds * Stopwatch.Frequency); + _logger?.LogInformation( + "SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, speedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}", + result.ProgramName, sampleCount, durationSeconds, speedRatio, servoPeriodSeconds * 1000, triggers.Count); + var stopwatch = Stopwatch.StartNew(); long nextTick = stopwatch.ElapsedTicks; ushort ioValue = 0; ushort ioMask = 0; int holdRemaining = -1; int segmentIndex = 0; + long logInterval = Math.Max(1, sampleCount / 10); + int triggerFiredCount = 0; try { @@ -538,6 +579,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable ioMask = ComputeIoValue(trigger.AddressGroup); ioValue = ioMask; holdRemaining = Math.Max(trigger.HoldCycles - 1, 0); + triggerFiredCount++; + _logger?.LogInformation( + "J519 IO触发: time={Time:F4}s, addr=[{Addr}], holdCycles={HoldCycles}", + trajectoryTime, string.Join(",", trigger.AddressGroup.Addresses), trigger.HoldCycles); break; } } @@ -558,15 +603,30 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable ioMask = 0; } + // 周期性记录进度(Debug 级别,避免高频 Info 日志)。 + if (sampleIndex > 0 && sampleIndex % logInterval == 0) + { + _logger?.LogDebug( + "SendDenseTrajectory 进度: {Percent}% ({Current}/{Total}), time={Time:F4}s", + (int)((double)sampleIndex / sampleCount * 100), sampleIndex, sampleCount, trajectoryTime); + } + // 高精度忙等待直到下一伺服周期。 while (stopwatch.ElapsedTicks < nextTick) { Thread.SpinWait(1); } } + + _logger?.LogInformation( + "SendDenseTrajectory 正常完成: 采样数={SampleCount}, 触发次数={TriggerFiredCount}, 实际耗时={ElapsedMs}ms", + sampleCount, triggerFiredCount, stopwatch.ElapsedMilliseconds); } catch (OperationCanceledException) { + _logger?.LogWarning( + "SendDenseTrajectory 被取消: 已完成 {Percent}% ({Current}/{Total}), 触发次数={TriggerFiredCount}", + (int)((double)(sampleCount > 0 ? 0 : 0) / sampleCount * 100), 0, sampleCount, triggerFiredCount); // 正常取消,轨迹被中断。 } finally diff --git a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj index 707afe4..fb0f8ff 100644 --- a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj +++ b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj @@ -6,6 +6,10 @@ enable + + + + <_Parameter1>Flyshot.Core.Tests diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs index 3a9750c..a2a38e4 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucCommandClient.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using Microsoft.Extensions.Logging; namespace Flyshot.Runtime.Fanuc.Protocol; @@ -8,6 +9,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol; public sealed class FanucCommandClient : IDisposable { private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly ILogger? _logger; private TcpClient? _tcpClient; private NetworkStream? _stream; private bool _disposed; @@ -17,6 +19,15 @@ public sealed class FanucCommandClient : IDisposable /// public bool IsConnected => _tcpClient?.Connected ?? false; + /// + /// 初始化 FANUC 命令通道客户端。 + /// + /// 日志记录器;允许 null。 + public FanucCommandClient(ILogger? logger = null) + { + _logger = logger; + } + /// /// 建立到 FANUC 控制柜 TCP 10012 命令通道的连接。 /// @@ -37,9 +48,13 @@ public sealed class FanucCommandClient : IDisposable throw new InvalidOperationException("命令通道已经连接,请先 Disconnect。"); } + _logger?.LogInformation("CommandClient ConnectAsync: {Ip}:{Port}", ip, port); + _tcpClient = new TcpClient { NoDelay = true }; await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false); _stream = _tcpClient.GetStream(); + + _logger?.LogInformation("CommandClient 已连接: {Ip}:{Port}", ip, port); } /// @@ -49,6 +64,8 @@ public sealed class FanucCommandClient : IDisposable { ObjectDisposedException.ThrowIf(_disposed, this); + _logger?.LogInformation("CommandClient Disconnect"); + _stream?.Dispose(); _stream = null; _tcpClient?.Dispose(); @@ -259,7 +276,9 @@ public sealed class FanucCommandClient : IDisposable try { await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); - return await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false); + var response = await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false); + _logger?.LogDebug("CommandClient 发送帧成功: 帧长度={FrameLength}, 响应长度={ResponseLength}", frame.Length, response.Length); + return response; } finally { @@ -270,10 +289,11 @@ public sealed class FanucCommandClient : IDisposable /// /// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。 /// - private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response) + private FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response) { if (!response.IsSuccess) { + _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode); throw CreateCommandFailureException(response.MessageId, response.ResultCode); } @@ -283,10 +303,11 @@ public sealed class FanucCommandClient : IDisposable /// /// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。 /// - private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response) + private FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response) { if (!response.IsSuccess) { + _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode); throw CreateCommandFailureException(response.MessageId, response.ResultCode); } @@ -296,10 +317,11 @@ public sealed class FanucCommandClient : IDisposable /// /// 校验速度倍率响应结果码,失败时抛出包含消息号和结果码的诊断异常。 /// - private static FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response) + private FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response) { if (!response.IsSuccess) { + _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode); throw CreateCommandFailureException(response.MessageId, response.ResultCode); } @@ -309,10 +331,11 @@ public sealed class FanucCommandClient : IDisposable /// /// 校验 TCP 位姿响应结果码,失败时抛出包含消息号和结果码的诊断异常。 /// - private static FanucTcpResponse EnsureSuccess(FanucTcpResponse response) + private FanucTcpResponse EnsureSuccess(FanucTcpResponse response) { if (!response.IsSuccess) { + _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode); throw CreateCommandFailureException(response.MessageId, response.ResultCode); } @@ -322,10 +345,11 @@ public sealed class FanucCommandClient : IDisposable /// /// 校验 IO 读取响应结果码,失败时抛出包含消息号和结果码的诊断异常。 /// - private static FanucIoResponse EnsureSuccess(FanucIoResponse response) + private FanucIoResponse EnsureSuccess(FanucIoResponse response) { if (!response.IsSuccess) { + _logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode); throw CreateCommandFailureException(response.MessageId, response.ResultCode); } diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs index cd4ab19..cbd1602 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucJ519Client.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Net.Sockets; +using Microsoft.Extensions.Logging; namespace Flyshot.Runtime.Fanuc.Protocol; @@ -10,6 +11,7 @@ public sealed class FanucJ519Client : IDisposable { private readonly object _commandLock = new(); private readonly object _responseLock = new(); + private readonly ILogger? _logger; private UdpClient? _udpClient; private CancellationTokenSource? _cts; private CancellationTokenSource? _sendCts; @@ -26,6 +28,15 @@ public sealed class FanucJ519Client : IDisposable /// public bool IsConnected => _udpClient is not null; + /// + /// 初始化 FANUC J519 客户端。 + /// + /// 日志记录器;允许 null。 + public FanucJ519Client(ILogger? logger = null) + { + _logger = logger; + } + /// /// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。 /// @@ -46,11 +57,14 @@ public sealed class FanucJ519Client : IDisposable throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。"); } + _logger?.LogInformation("J519 ConnectAsync: {Ip}:{Port}", ip, port); + _udpClient = new UdpClient(); _udpClient.Connect(ip, port); // 发送初始化包。 await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false); + _logger?.LogInformation("J519 初始化包已发送"); _cts = new CancellationTokenSource(); _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token); @@ -70,9 +84,11 @@ public sealed class FanucJ519Client : IDisposable if (_sendTask is not null) { + _logger?.LogDebug("J519 StartMotion: 发送循环已在运行"); return; // 已在运行。 } + _logger?.LogInformation("J519 StartMotion: 启动发送循环"); _sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token); _sendTask = Task.Run(() => SendLoopAsync(_sendCts.Token), _sendCts.Token); } @@ -89,6 +105,8 @@ public sealed class FanucJ519Client : IDisposable return; } + _logger?.LogInformation("J519 StopMotionAsync: 停止发送循环"); + if (_sendTask is not null) { _sendCts?.Cancel(); @@ -99,7 +117,7 @@ public sealed class FanucJ519Client : IDisposable } catch (TimeoutException) { - // 发送循环可能未能在 1 秒内结束,继续执行后续清理。 + _logger?.LogWarning("J519 StopMotionAsync: 发送循环未能在 1 秒内结束"); } catch (OperationCanceledException) { @@ -114,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable // 发送结束包通知控制器停止伺服流。 await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false); + _logger?.LogInformation("J519 StopMotionAsync: 结束包已发送"); } /// @@ -130,6 +149,12 @@ public sealed class FanucJ519Client : IDisposable _currentCommand = command; _commandHistoryForTests?.Add(command); } + + _logger?.LogDebug( + "J519 UpdateCommand: joints=[{Joints}], ioMask=0x{IoMask:X4}, ioValue=0x{IoValue:X4}", + string.Join(", ", command.TargetJoints.Select(j => j.ToString("F2"))), + command.WriteIoMask, + command.WriteIoValue); } /// @@ -353,6 +378,10 @@ public sealed class FanucJ519Client : IDisposable return; } + _logger?.LogInformation("J519 ReceiveLoop 启动"); + long receiveCount = 0; + FanucJ519Response? lastLoggedResponse = null; + try { while (!cancellationToken.IsCancellationRequested) @@ -365,16 +394,40 @@ public sealed class FanucJ519Client : IDisposable { _latestResponse = response; } + + receiveCount++; + + // 仅在状态变化时记录 Info,避免高频日志。 + if (lastLoggedResponse is null + || lastLoggedResponse.Status != response.Status + || lastLoggedResponse.RobotInMotion != response.RobotInMotion + || lastLoggedResponse.SystemReady != response.SystemReady) + { + _logger?.LogInformation( + "J519 响应: status=0x{Status:X2}, seq={Seq}, accept={Accept}, sysrdy={SysRdy}, motion={Motion}, pose=[{Pose}], joints=[{Joints}]", + response.Status, + response.Sequence, + response.AcceptsCommand, + response.SystemReady, + response.RobotInMotion, + string.Join(", ", response.Pose.Select(v => v.ToString("F1"))), + string.Join(", ", response.JointDegrees.Take(6).Select(v => v.ToString("F2")))); + lastLoggedResponse = response; + } + else if (receiveCount % 1000 == 0) + { + _logger?.LogDebug("J519 已接收 {Count} 个响应包", receiveCount); + } } } } catch (OperationCanceledException) { - // 正常取消,退出循环。 + _logger?.LogInformation("J519 ReceiveLoop 正常取消,共接收 {Count} 个包", receiveCount); } catch (ObjectDisposedException) { - // UDP 客户端已释放,退出循环。 + _logger?.LogInformation("J519 ReceiveLoop 因 UDP 释放退出,共接收 {Count} 个包", receiveCount); } } } diff --git a/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs b/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs index f607d00..080c3b1 100644 --- a/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs +++ b/src/Flyshot.Runtime.Fanuc/Protocol/FanucStateClient.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using Microsoft.Extensions.Logging; namespace Flyshot.Runtime.Fanuc.Protocol; @@ -114,6 +115,7 @@ public sealed class FanucStateClient : IDisposable { private readonly object _stateLock = new(); private readonly FanucStateClientOptions _options; + private readonly ILogger? _logger; private TcpClient? _tcpClient; private NetworkStream? _stream; private CancellationTokenSource? _receiveCts; @@ -130,7 +132,7 @@ public sealed class FanucStateClient : IDisposable /// 使用默认状态通道参数初始化客户端。 /// public FanucStateClient() - : this(new FanucStateClientOptions()) + : this(new FanucStateClientOptions(), null) { } @@ -139,10 +141,21 @@ public sealed class FanucStateClient : IDisposable /// /// 超时和重连参数。 public FanucStateClient(FanucStateClientOptions options) + : this(options, null) + { + } + + /// + /// 使用指定状态通道参数和日志记录器初始化客户端。 + /// + /// 超时和重连参数。 + /// 日志记录器;允许 null。 + public FanucStateClient(FanucStateClientOptions options, ILogger? logger) { ArgumentNullException.ThrowIfNull(options); ValidateOptions(options); _options = options; + _logger = logger; } /// @@ -170,6 +183,8 @@ public sealed class FanucStateClient : IDisposable throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。"); } + _logger?.LogInformation("StateClient ConnectAsync: {Ip}:{Port}", ip, port); + _receiveCts = new CancellationTokenSource(); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _receiveCts.Token); @@ -187,8 +202,9 @@ public sealed class FanucStateClient : IDisposable { await OpenConnectionAsync(ip, port, linkedCts.Token).ConfigureAwait(false); } - catch + catch (Exception exception) { + _logger?.LogError(exception, "StateClient 连接失败: {Ip}:{Port}", ip, port); CloseCurrentConnection(); lock (_stateLock) { @@ -203,6 +219,8 @@ public sealed class FanucStateClient : IDisposable _receiveTask = Task.Run( () => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token), _receiveCts.Token); + + _logger?.LogInformation("StateClient 已连接并启动接收循环: {Ip}:{Port}", ip, port); } /// @@ -212,6 +230,7 @@ public sealed class FanucStateClient : IDisposable { ObjectDisposedException.ThrowIf(_disposed, this); + _logger?.LogInformation("StateClient Disconnect"); Shutdown(clearLatestFrame: true); } @@ -268,6 +287,7 @@ public sealed class FanucStateClient : IDisposable private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken) { var reconnectDelay = _options.ReconnectInitialDelay; + _logger?.LogInformation("StateClient 接收循环启动: {Ip}:{Port}", ip, port); while (!cancellationToken.IsCancellationRequested) { @@ -278,14 +298,17 @@ public sealed class FanucStateClient : IDisposable } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + _logger?.LogInformation("StateClient 接收循环正常取消"); return; } catch (TimeoutException ex) { + _logger?.LogWarning(ex, "StateClient 接收超时"); MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message); } catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException) { + _logger?.LogWarning(ex, "StateClient 连接异常,准备重连"); MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message); } @@ -312,6 +335,8 @@ public sealed class FanucStateClient : IDisposable } var buffer = new byte[FanucStateProtocol.StateFrameLength]; + long frameCount = 0; + FanucStateFrame? lastLoggedFrame = null; while (!cancellationToken.IsCancellationRequested) { @@ -325,6 +350,26 @@ public sealed class FanucStateClient : IDisposable _connectionState = FanucStateConnectionState.Connected; _lastErrorMessage = null; } + + frameCount++; + + // 仅在状态变化或首次接收时记录 Info,避免高频日志。 + if (lastLoggedFrame is null + || lastLoggedFrame.CartesianPose[0] != frame.CartesianPose[0] + || !lastLoggedFrame.RawTailWords.SequenceEqual(frame.RawTailWords)) + { + _logger?.LogInformation( + "StateClient 收到状态帧: pose=[{X:F1}, {Y:F1}, {Z:F1}], tail=[{Tail}]", + frame.CartesianPose[0], + frame.CartesianPose[1], + frame.CartesianPose[2], + string.Join(", ", frame.RawTailWords)); + lastLoggedFrame = frame; + } + else if (frameCount % 1000 == 0) + { + _logger?.LogDebug("StateClient 已接收 {Count} 个状态帧", frameCount); + } } } @@ -367,6 +412,8 @@ public sealed class FanucStateClient : IDisposable var tcpClient = new TcpClient { NoDelay = true }; try { + _logger?.LogInformation("StateClient 正在连接 {Ip}:{Port}...", ip, port); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(_options.ConnectTimeout); await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false); @@ -378,14 +425,18 @@ public sealed class FanucStateClient : IDisposable _lastConnectedAt = DateTimeOffset.UtcNow; _connectionState = FanucStateConnectionState.Connected; } + + _logger?.LogInformation("StateClient 已连接到 {Ip}:{Port}", ip, port); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + _logger?.LogWarning("StateClient 连接 {Ip}:{Port} 超时", ip, port); tcpClient.Dispose(); throw new TimeoutException("状态通道建连超时。"); } - catch + catch (Exception ex) { + _logger?.LogWarning(ex, "StateClient 连接 {Ip}:{Port} 失败", ip, port); tcpClient.Dispose(); throw; } @@ -409,6 +460,12 @@ public sealed class FanucStateClient : IDisposable _connectionState = FanucStateConnectionState.Reconnecting; } + _logger?.LogInformation( + "StateClient 将在 {Delay}ms 后尝试重连 {Ip}:{Port}...", + nextDelay.TotalMilliseconds, + ip, + port); + await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false); lock (_stateLock) @@ -419,6 +476,11 @@ public sealed class FanucStateClient : IDisposable try { await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false); + _logger?.LogInformation( + "StateClient 重连成功: {Ip}:{Port}, 累计重连次数={Count}", + ip, + port, + _reconnectAttemptCount); return _options.ReconnectInitialDelay; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -434,6 +496,13 @@ public sealed class FanucStateClient : IDisposable _lastErrorMessage = ex.Message; } + _logger?.LogWarning( + ex, + "StateClient 重连失败: {Ip}:{Port}, 下次等待={NextDelay}ms", + ip, + port, + nextDelay.TotalMilliseconds * 2); + nextDelay = IncreaseReconnectDelay(nextDelay); } } @@ -470,6 +539,8 @@ public sealed class FanucStateClient : IDisposable _connectionState = state; _lastErrorMessage = message; } + + _logger?.LogWarning("StateClient 接收失败: state={State}, message={Message}", state, message); } /// diff --git a/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs index d0e226f..1edb439 100644 --- a/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs +++ b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs @@ -5,1159 +5,17 @@ using Microsoft.Extensions.Options; namespace Flyshot.Server.Host.Controllers; /// -/// 提供浏览器内的在线 API 调试页面。 -/// 页面会在加载时拉取 Swagger JSON,按 OpenAPI 自动渲染所有端点的入参表单、Body 编辑器和响应面板。 +/// 提供浏览器调试页所需的运行时配置 API。 /// /// -/// 本控制器自身不进入 Swagger 文档(), -/// 仅作为静态 HTML 出口;调试页和 Swagger UI 共用 Swagger:Enabled 开关, -/// 关闭后两者一同下线,避免生产环境意外暴露调试入口。 +/// 本控制器自身不进入 Swagger 文档()。 +/// 调试页静态资源位于 wwwroot,Swagger 地址由配置 API 下发。 /// [ApiController] [ApiExplorerSettings(IgnoreApi = true)] [Tags("基础与状态")] public sealed class DebugConsoleController : ControllerBase { - /// - /// 调试页 HTML 模板:内嵌 CSS、HTML 骨架与原生 JS,零外部依赖,适配现场离线环境。 - /// 模板中的 __SWAGGER_JSON_URL__ 占位符在返回前会被替换为实际的 Swagger JSON 地址。 - /// - private const string DebugConsoleHtmlTemplate = """ - - - - - - Flyshot Replacement 接口调试 - - - -
-
-

Flyshot Replacement 接口调试

-
- 回到状态页 - Swagger UI - -
-
-
-
-
-
-
OpenAPI 文档
-
--
-
API 数量
-
--
-
加载状态
-
初始化中...
-
-
-
-
正在加载接口列表...
-
-
- - - - -"""; - /// /// Swagger 配置项,用于决定调试页是否对外暴露以及拼接 OpenAPI JSON 地址。 /// @@ -1174,22 +32,21 @@ public sealed class DebugConsoleController : ControllerBase } /// - /// 返回浏览器内可直接打开的 API 调试控制台。 + /// 返回静态调试页启动时所需的 Swagger 文档地址。 /// - /// 当 Swagger 启用时返回 HTML;否则返回 404,与 Swagger UI 保持一致的可见性策略。 - [HttpGet("/debug")] - public IActionResult GetDebugConsole() + /// 当 Swagger 启用时返回配置;否则返回 404,与 Swagger UI 保持一致的可见性策略。 + [HttpGet("/api/debug/config")] + public IActionResult GetDebugConfig() { - // Swagger 关闭时调试页一同下线,避免生产环境意外暴露调试入口。 if (!_swaggerOptions.Enabled) { return NotFound(); } - // 由控制器一侧解析 Swagger JSON 路径,前端不再硬编码路由前缀和文档名。 - var swaggerJsonUrl = ResolveSwaggerJsonUrl(_swaggerOptions); - var html = DebugConsoleHtmlTemplate.Replace("__SWAGGER_JSON_URL__", swaggerJsonUrl, StringComparison.Ordinal); - return Content(html, "text/html; charset=utf-8"); + return Ok(new + { + SwaggerJsonUrl = ResolveSwaggerJsonUrl(_swaggerOptions) + }); } /// diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs index e73bc9a..7cdfa47 100644 --- a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs +++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs @@ -12,14 +12,17 @@ namespace Flyshot.Server.Host.Controllers; public sealed class LegacyHttpApiController : ControllerBase { private readonly IControllerClientCompatService _compatService; + private readonly ILogger _logger; /// /// 初始化旧 HTTP 兼容控制器。 /// /// ControllerClient 兼容服务。 - public LegacyHttpApiController(IControllerClientCompatService compatService) + /// 日志记录器。 + public LegacyHttpApiController(IControllerClientCompatService compatService, ILogger logger) { _compatService = compatService ?? throw new ArgumentNullException(nameof(compatService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -41,13 +44,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/connect_server/")] public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port) { + _logger.LogInformation("ConnectServer 调用: server_ip={ServerIp}, port={Port}", server_ip, port); try { _compatService.ConnectServer(server_ip, port); + _logger.LogInformation("ConnectServer 成功: server_ip={ServerIp}, port={Port}", server_ip, port); return Ok(new { status = "connected" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "ConnectServer 失败: server_ip={ServerIp}, port={Port}", server_ip, port); return LegacyBadRequest("Connect Server failed"); } } @@ -80,13 +86,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/setup_robot/")] public IActionResult SetupRobot([FromQuery] string robot_name) { + _logger.LogInformation("SetupRobot 调用: robot_name={RobotName}", robot_name); try { _compatService.SetUpRobot(robot_name); + _logger.LogInformation("SetupRobot 成功: robot_name={RobotName}", robot_name); return Ok(new { status = "robot setup" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SetupRobot 失败: robot_name={RobotName}", robot_name); return LegacyBadRequest("SetUpRobot failed"); } } @@ -152,13 +161,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpGet("/enable_robot/")] public IActionResult EnableRobot([FromQuery] int buffer_size = 2) { + _logger.LogInformation("EnableRobot 调用: buffer_size={BufferSize}", buffer_size); try { _compatService.EnableRobot(buffer_size); + _logger.LogInformation("EnableRobot 成功"); return Ok(new { enable_robot = true }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "EnableRobot 失败"); return LegacyBadRequest("EnableRobot failed"); } } @@ -170,13 +182,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpGet("/disable_robot/")] public IActionResult DisableRobot() { + _logger.LogInformation("DisableRobot 调用"); try { _compatService.DisableRobot(); + _logger.LogInformation("DisableRobot 成功"); return Ok(new { disable_robot = true }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "DisableRobot 失败"); return LegacyBadRequest("DisableRobot failed"); } } @@ -188,13 +203,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpGet("/stop_move/")] public IActionResult StopMove() { + _logger.LogInformation("StopMove 调用"); try { _compatService.StopMove(); + _logger.LogInformation("StopMove 成功"); return Ok(new { status = "move stopped" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "StopMove 失败"); return LegacyBadRequest("StopMove failed"); } } @@ -207,13 +225,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/set_active_controller/")] public IActionResult SetActiveController([FromQuery] bool sim) { + _logger.LogInformation("SetActiveController 调用: sim={Sim}", sim); try { _compatService.SetActiveController(sim); + _logger.LogInformation("SetActiveController 成功: sim={Sim}", sim); return Ok(new { status = "active controller set" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SetActiveController 失败: sim={Sim}", sim); return LegacyBadRequest("SetActiveController failed"); } } @@ -226,13 +247,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/connect_robot/")] public IActionResult ConnectRobot([FromQuery] string ip) { + _logger.LogInformation("ConnectRobot 调用: ip={Ip}", ip); try { _compatService.Connect(ip); + _logger.LogInformation("ConnectRobot 成功: ip={Ip}", ip); return Ok(new { status = "robot connected" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "ConnectRobot 失败: ip={Ip}", ip); return LegacyBadRequest("Connect failed"); } } @@ -244,13 +268,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/disconnect_robot/")] public IActionResult DisconnectRobot() { + _logger.LogInformation("DisconnectRobot 调用"); try { _compatService.Disconnect(); + _logger.LogInformation("DisconnectRobot 成功"); return Ok(new { status = "robot disconnected" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "DisconnectRobot 失败"); return LegacyBadRequest("Disconnect failed"); } } @@ -286,13 +313,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/set_tcp/")] public IActionResult SetTcp([FromBody] LegacyTcpRequest tcp_data) { + _logger.LogInformation("SetTcp 调用: x={X}, y={Y}, z={Z}", tcp_data.x, tcp_data.y, tcp_data.z); try { _compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z); + _logger.LogInformation("SetTcp 成功"); return Ok(new { status = "TCP set" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SetTcp 失败"); return LegacyBadRequest("SetTCP failed"); } } @@ -324,13 +354,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/set_io/")] public IActionResult SetIo([FromQuery] int port, [FromQuery] bool value, [FromQuery] string io_type) { + _logger.LogInformation("SetIo 调用: port={Port}, value={Value}, io_type={IoType}", port, value, io_type); try { _compatService.SetIo(port, value, io_type); + _logger.LogInformation("SetIo 成功: port={Port}, value={Value}", port, value); return Ok(new { status = "IO set" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SetIo 失败: port={Port}, value={Value}", port, value); return LegacyBadRequest("SetDigitalOutput failed"); } } @@ -344,12 +377,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpGet("/get_io/")] public IActionResult GetIo([FromQuery] int port, [FromQuery] string io_type) { + _logger.LogInformation("GetIo 调用: port={Port}, io_type={IoType}", port, io_type); try { - return Ok(new { value = _compatService.GetIo(port, io_type) }); + var value = _compatService.GetIo(port, io_type); + _logger.LogInformation("GetIo 成功: port={Port}, value={Value}", port, value); + return Ok(new { value }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "GetIo 失败: port={Port}", port); return LegacyBadRequest("GetDigitalOutput failed"); } } @@ -379,13 +416,17 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/move_joint/")] public IActionResult MoveJoint([FromBody] LegacyJointPositionRequest joint_data) { + _logger.LogInformation("MoveJoint 调用: 关节数={JointCount}", joint_data.joints.Count); + _logger.LogDebug("MoveJoint 路点: {Joints}", string.Join(", ", joint_data.joints.Select(j => j.ToString("F4")))); try { _compatService.MoveJoint(joint_data.joints); + _logger.LogInformation("MoveJoint 成功"); return Ok(new { status = "robot moved" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "MoveJoint 失败"); return LegacyBadRequest("MoveJoint failed"); } } @@ -441,16 +482,20 @@ public sealed class LegacyHttpApiController : ControllerBase [FromQuery] string? method = null, [FromQuery] bool? save_traj = null) { + _logger.LogInformation("ExecuteTrajectory 调用: method={Method}, save_traj={SaveTraj}", method ?? "icsp", save_traj ?? false); try { var request = ParseExecuteTrajectoryRequest(waypoints, method, save_traj); + _logger.LogDebug("ExecuteTrajectory 路点数={WaypointCount}, method={Method}", request.Waypoints.Count, request.Method); _compatService.ExecuteTrajectory( request.Waypoints, new TrajectoryExecutionOptions(request.Method, request.SaveTrajectory)); + _logger.LogInformation("ExecuteTrajectory 成功: method={Method}", request.Method); return Ok(new { status = "trajectory executed" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "ExecuteTrajectory 失败"); return LegacyBadRequest("ExecuteTrajectory failed"); } } @@ -463,18 +508,30 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/upload_flyshot/")] public IActionResult UploadFlyshot([FromBody] LegacyFlightTrajectoryRequest trajectory_data) { + _logger.LogInformation( + "UploadFlyshot 调用: name={Name}, waypoints={WaypointCount}, shot_flags={ShotCount}", + trajectory_data.name, + trajectory_data.waypoints.Count, + trajectory_data.shot_flags.Count(static f => f)); + if (trajectory_data.shot_flags.Count != trajectory_data.waypoints.Count) { + _logger.LogWarning("UploadFlyshot 校验失败: shot_flags长度({ShotFlagsCount}) != 路点数({WaypointCount})", + trajectory_data.shot_flags.Count, trajectory_data.waypoints.Count); return LegacyValidationError("shot_flags长度必须与路点数量相同"); } if (trajectory_data.offset_values.Count != trajectory_data.waypoints.Count) { + _logger.LogWarning("UploadFlyshot 校验失败: offset_values长度({OffsetCount}) != 路点数({WaypointCount})", + trajectory_data.offset_values.Count, trajectory_data.waypoints.Count); return LegacyValidationError("offset_values长度必须与路点数量相同"); } if (trajectory_data.addrs.Count != trajectory_data.waypoints.Count) { + _logger.LogWarning("UploadFlyshot 校验失败: addrs长度({AddrCount}) != 路点数({WaypointCount})", + trajectory_data.addrs.Count, trajectory_data.waypoints.Count); return LegacyValidationError("addrs长度必须与路点数量相同"); } @@ -488,10 +545,12 @@ public sealed class LegacyHttpApiController : ControllerBase addressGroups: trajectory_data.addrs); _compatService.UploadTrajectory(trajectory); + _logger.LogInformation("UploadFlyshot 成功: name={Name}", trajectory_data.name); return Ok(new { status = "FlyShot uploaded" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "UploadFlyshot 失败: name={Name}", trajectory_data.name); return LegacyBadRequest("UploadFlyShotTraj failed"); } } @@ -504,6 +563,9 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/execute_flyshot/")] public IActionResult ExecuteFlyshot([FromBody] LegacyExecuteFlyshotRequest data) { + _logger.LogInformation( + "ExecuteFlyshot 调用: name={Name}, method={Method}, move_to_start={MoveToStart}, use_cache={UseCache}", + data.name, data.method, data.move_to_start, data.use_cache); try { _compatService.ExecuteTrajectoryByName( @@ -513,10 +575,12 @@ public sealed class LegacyHttpApiController : ControllerBase method: data.method, saveTrajectory: data.save_traj, useCache: data.use_cache)); + _logger.LogInformation("ExecuteFlyshot 成功: name={Name}", data.name); return Ok(new { status = "FlyShot executed", success = true }); } catch (Exception exception) { + _logger.LogError(exception, "ExecuteFlyshot 失败: name={Name}", data.name); return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message }); } } @@ -529,17 +593,21 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/save_traj_info/")] public IActionResult SaveTrajectoryInfo([FromBody] LegacyTrajectoryInfoRequest request) { + _logger.LogInformation("SaveTrajectoryInfo 调用: name={Name}, method={Method}", request.name, request.method); try { _compatService.SaveTrajectoryInfo(request.name, request.method); + _logger.LogInformation("SaveTrajectoryInfo 成功: name={Name}", request.name); return Ok(new { status = "trajectory info saved", success = true }); } catch (NotSupportedException exception) { + _logger.LogWarning(exception, "SaveTrajectoryInfo 不支持: name={Name}", request.name); return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SaveTrajectoryInfo 失败: name={Name}", request.name); return LegacyBadRequest("SaveTrajInfo failed"); } } @@ -552,6 +620,7 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/is_flyShotTrajValid/")] public IActionResult IsFlyshotTrajectoryValid([FromBody] LegacyFlyshotValidationRequest request) { + _logger.LogInformation("IsFlyshotTrajectoryValid 调用: name={Name}, method={Method}", request.name, request.method); try { var isValid = _compatService.IsFlyshotTrajectoryValid( @@ -560,14 +629,17 @@ public sealed class LegacyHttpApiController : ControllerBase request.method, request.save_traj); + _logger.LogInformation("IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", request.name, isValid, duration.TotalSeconds); return Ok(new { success = isValid, valid = isValid, time = duration.TotalSeconds }); } catch (NotSupportedException exception) { + _logger.LogWarning(exception, "IsFlyshotTrajectoryValid 不支持: name={Name}", request.name); return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "IsFlyshotTrajectoryValid 失败: name={Name}", request.name); return LegacyBadRequest("IsFlyShotTrajValid failed"); } } @@ -580,13 +652,23 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/set_speedRatio/")] public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data) { + _logger.LogInformation("SetSpeedRatio 调用: speed={Speed}", data.speed); try { + // 验证数值 范围符合预期(例如 0.01到 1.0),以避免对控制器造成潜在风险 + if (data.speed < 0.01 || data.speed > 1.0) + { + _logger.LogWarning("SetSpeedRatio 参数无效: speed={Speed}", data.speed); + return BadRequest(new { detail = "Speed ratio must be between 0.01 and 1.0." }); + } + _compatService.SetSpeedRatio(data.speed); + _logger.LogInformation("SetSpeedRatio 成功: speed={Speed}", data.speed); return Ok(new { message = "set_speedRatio executed", returnCode = 0 }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "SetSpeedRatio 失败: speed={Speed}", data.speed); return LegacyBadRequest("set_speedRatio failed"); } } @@ -599,13 +681,16 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/delete_flyshot/")] public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request) { + _logger.LogInformation("DeleteFlyshot 调用: name={Name}", request.name); try { _compatService.DeleteTrajectory(request.name); + _logger.LogInformation("DeleteFlyshot 成功: name={Name}", request.name); return Ok(new { status = "FlyShot deleted" }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "DeleteFlyshot 失败: name={Name}", request.name); return LegacyBadRequest("DeleteFlyShotTraj failed"); } } @@ -618,22 +703,28 @@ public sealed class LegacyHttpApiController : ControllerBase [HttpPost("/init_mpc_robt")] public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data) { + _logger.LogInformation( + "InitMpcRobot 调用: robot_name={RobotName}, robot_ip={RobotIp}, sim={Sim}, server={ServerIp}:{Port}", + data.robot_name, data.robot_ip, data.sim, data.server_ip, data.port); try { _compatService.ConnectServer(data.server_ip, data.port); _compatService.SetUpRobot(data.robot_name); if (!_compatService.IsSetUp) { + _logger.LogWarning("InitMpcRobot 失败: Robot not setup"); return LegacyBadRequest("Robot not setup"); } _compatService.SetActiveController(data.sim); _compatService.Connect(data.robot_ip); _compatService.EnableRobot(2); + _logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name); return Ok(new { message = "init_Success", returnCode = 0 }); } - catch + catch (Exception exception) { + _logger.LogError(exception, "InitMpcRobot 失败"); return LegacyBadRequest("Connect Server failed"); } } diff --git a/src/Flyshot.Server.Host/Controllers/StatusController.cs b/src/Flyshot.Server.Host/Controllers/StatusController.cs index f78f464..b641850 100644 --- a/src/Flyshot.Server.Host/Controllers/StatusController.cs +++ b/src/Flyshot.Server.Host/Controllers/StatusController.cs @@ -4,390 +4,12 @@ using Microsoft.AspNetCore.Mvc; namespace Flyshot.Server.Host.Controllers; /// -/// 提供只读状态监控页面和控制器状态快照 API。 +/// 提供控制器状态快照 API,状态监控页面由 wwwroot 静态资源承载。 /// [ApiController] [Tags("基础与状态")] public sealed class StatusController : ControllerBase { - /// - /// 浏览器端状态监控页面,页面逻辑只读取状态快照,不触发控制动作。 - /// - private const string StatusPageHtml = """ - - - - - - Flyshot Replacement 状态监控 - - - -
-
-

Flyshot Replacement 状态监控

-
- 调试接口 - -
-
-
-
-
-
-
连接状态
-
--
-
-
-
机器人
-
--
-
-
-
速度倍率
-
--
-
-
-
运动中
-
--
-
-
-
-
-

控制器

-
-
服务端版本
--
-
客户端版本
--
-
已初始化
--
-
已使能
--
-
J519 状态
--
-
J519 序号
--
-
采样时间
--
-
-
-
-

机器人

-
-
自由度
--
-
关节位置
--
-
TCP 位姿
--
-
已上传轨迹
--
-
-
-
-
- - - -"""; - private readonly IControllerClientCompatService _compatService; /// @@ -400,13 +22,23 @@ public sealed class StatusController : ControllerBase } /// - /// 返回浏览器可直接打开的状态监控页面。 + /// 提供短路由 `/status`,跳转到静态状态页。 /// - /// HTML 状态页面。 + /// 重定向到 /status.html [HttpGet("/status")] - public ContentResult GetStatusPage() + public IActionResult StatusPage() { - return Content(StatusPageHtml, "text/html; charset=utf-8"); + return Redirect("/status.html"); + } + + /// + /// 提供短路由 `/debug`,跳转到静态调试页。 + /// + /// 重定向到 /debug.html + [HttpGet("/debug")] + public IActionResult DebugPage() + { + return Redirect("/debug.html"); } /// diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj index 88c78ea..f76444e 100644 --- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj +++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj @@ -7,9 +7,20 @@ + + + + + + + + + + + diff --git a/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs new file mode 100644 index 0000000..5334b19 --- /dev/null +++ b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using System.Text; + +namespace Flyshot.Server.Host.Middleware; + +/// +/// HTTP 请求与响应日志中间件。 +/// 记录每个 HTTP 请求的进入时间、方法、路径、查询串、请求体, +/// 以及响应的状态码、耗时和响应体(调试级别)。 +/// +public sealed class RequestResponseLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + /// + /// 请求体最大日志长度,超出则截断并附加省略标记。 + /// + private const int MaxBodyLogLength = 4096; + + /// + /// 初始化请求响应日志中间件。 + /// + /// 下一个中间件委托。 + /// 日志记录器。 + public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 处理 HTTP 请求并记录输入输出。 + /// + /// HTTP 上下文。 + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + var request = context.Request; + var requestId = Activity.Current?.Id ?? context.TraceIdentifier; + + // 记录请求进入信息(Info 级别:方法、路径、查询参数)。 + _logger.LogInformation( + "[HTTP-REQ] [{RequestId}] {Method} {Path}{QueryString} — 客户端 {RemoteIp}", + requestId, + request.Method, + request.Path, + request.QueryString.HasValue ? request.QueryString.Value : string.Empty, + context.Connection.RemoteIpAddress); + + // 读取并记录请求体(Debug 级别)。 + string? requestBody = null; + if (request.ContentLength > 0 && request.Body.CanRead) + { + request.EnableBuffering(); + requestBody = await ReadBodyAsync(request.Body, context.RequestAborted).ConfigureAwait(false); + request.Body.Position = 0; + + if (!string.IsNullOrEmpty(requestBody)) + { + _logger.LogDebug( + "[HTTP-REQ-BODY] [{RequestId}] {Body}", + requestId, + TruncateBody(requestBody)); + } + } + + // 拦截响应流以便读取响应体。 + var originalResponseBody = context.Response.Body; + using var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + + try + { + await _next(context).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "[HTTP-ERR] [{RequestId}] {Method} {Path} 处理过程中发生未捕获异常", + requestId, + request.Method, + request.Path); + throw; + } + finally + { + stopwatch.Stop(); + responseBodyStream.Position = 0; + var responseBody = await ReadBodyAsync(responseBodyStream, context.RequestAborted).ConfigureAwait(false); + responseBodyStream.Position = 0; + await responseBodyStream.CopyToAsync(originalResponseBody, context.RequestAborted).ConfigureAwait(false); + context.Response.Body = originalResponseBody; + + var statusCode = context.Response.StatusCode; + var level = statusCode >= 500 ? LogLevel.Error : statusCode >= 400 ? LogLevel.Warning : LogLevel.Information; + + // 记录响应概要(Info/Warning/Error 级别)。 + _logger.Log( + level, + "[HTTP-RES] [{RequestId}] {Method} {Path} => {StatusCode} ({ElapsedMs}ms)", + requestId, + request.Method, + request.Path, + statusCode, + stopwatch.ElapsedMilliseconds); + + // 记录响应体(Debug 级别)。 + if (!string.IsNullOrEmpty(responseBody)) + { + _logger.LogDebug( + "[HTTP-RES-BODY] [{RequestId}] {Body}", + requestId, + TruncateBody(responseBody)); + } + } + } + + /// + /// 从流中读取文本内容。 + /// + private static async Task ReadBodyAsync(Stream stream, CancellationToken cancellationToken) + { + if (!stream.CanRead) + { + return string.Empty; + } + + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + return body; + } + + /// + /// 截断过长内容,避免日志膨胀。 + /// + private static string TruncateBody(string body) + { + if (body.Length <= MaxBodyLogLength) + { + return body; + } + + return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]"; + } +} diff --git a/src/Flyshot.Server.Host/Program.cs b/src/Flyshot.Server.Host/Program.cs index 6bcd974..6e313ff 100644 --- a/src/Flyshot.Server.Host/Program.cs +++ b/src/Flyshot.Server.Host/Program.cs @@ -1,94 +1,124 @@ using Flyshot.ControllerClientCompat; using Flyshot.Server.Host; +using Flyshot.Server.Host.Middleware; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; +using NLog.Web; using Swashbuckle.AspNetCore.SwaggerGen; -var builder = WebApplication.CreateBuilder(args); +// NLog: 在 ASP.NET Core 启动前完成配置加载,确保最早期的日志也能被捕获。 +NLog.LogManager.Setup().LoadConfigurationFromAppSettings(); +var logger = NLog.LogManager.GetCurrentClassLogger(); -builder.Services.Configure(builder.Configuration.GetSection("Swagger")); -builder.Services.Configure(builder.Configuration.GetSection("Cors")); -builder.Services.AddControllerClientCompat(builder.Configuration); -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); - -var swaggerOptions = builder.Configuration.GetSection("Swagger").Get() ?? new HostSwaggerOptions(); -builder.Services.AddSwaggerGen(options => +try { - options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo + logger.Info("Flyshot Server Host 启动中..."); + + var builder = WebApplication.CreateBuilder(args); + + // NLog: 替换默认日志提供者为 NLog,清除其他 Provider 避免重复输出。 + builder.Logging.ClearProviders(); + builder.Host.UseNLog(); + + builder.Services.Configure(builder.Configuration.GetSection("Swagger")); + builder.Services.Configure(builder.Configuration.GetSection("Cors")); + builder.Services.AddControllerClientCompat(builder.Configuration); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + + var swaggerOptions = builder.Configuration.GetSection("Swagger").Get() ?? new HostSwaggerOptions(); + builder.Services.AddSwaggerGen(options => { - Title = swaggerOptions.Title, - Version = swaggerOptions.Version + options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo + { + Title = swaggerOptions.Title, + Version = swaggerOptions.Version + }); + + // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。 + var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml"); + if (File.Exists(xmlDocumentationPath)) + { + options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true); + } }); - // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。 - var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml"); - if (File.Exists(xmlDocumentationPath)) + var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions(); + builder.Services.AddCors(options => { - options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true); + options.AddPolicy(corsOptions.PolicyName, policyBuilder => + { + // 兼容本地调试时最常见的任意源配置,同时保留显式白名单模式。 + if (corsOptions.AllowedOrigins.Length == 1 && string.Equals(corsOptions.AllowedOrigins[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyOrigin(); + } + else + { + policyBuilder.WithOrigins(corsOptions.AllowedOrigins); + } + + if (corsOptions.AllowedMethods.Length == 1 && string.Equals(corsOptions.AllowedMethods[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyMethod(); + } + else + { + policyBuilder.WithMethods(corsOptions.AllowedMethods); + } + + if (corsOptions.AllowedHeaders.Length == 1 && string.Equals(corsOptions.AllowedHeaders[0], "*", StringComparison.Ordinal)) + { + policyBuilder.AllowAnyHeader(); + } + else + { + policyBuilder.WithHeaders(corsOptions.AllowedHeaders); + } + }); + }); + + var app = builder.Build(); + + var resolvedSwaggerOptions = app.Services.GetRequiredService>().Value; + var resolvedCorsOptions = app.Services.GetRequiredService>().Value; + + if (resolvedSwaggerOptions.Enabled) + { + app.UseSwagger(options => + { + options.RouteTemplate = resolvedSwaggerOptions.JsonRouteTemplate; + }); + + app.UseSwaggerUI(options => + { + options.RoutePrefix = resolvedSwaggerOptions.RoutePrefix; + options.SwaggerEndpoint( + $"/swagger/{resolvedSwaggerOptions.DocumentName}/swagger.json", + $"{resolvedSwaggerOptions.Title} {resolvedSwaggerOptions.Version}"); + options.DocumentTitle = resolvedSwaggerOptions.Title; + }); } -}); -var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions(); -builder.Services.AddCors(options => + app.UseCors(resolvedCorsOptions.PolicyName); + app.UseStaticFiles(); + + // 注册 HTTP 请求/响应日志中间件,记录所有 API 调用的输入输出。 + app.UseMiddleware(); + + app.MapControllers(); + + logger.Info("Flyshot Server Host 已就绪,开始监听请求。"); + app.Run(); +} +catch (Exception exception) { - options.AddPolicy(corsOptions.PolicyName, policyBuilder => - { - // 兼容本地调试时最常见的任意源配置,同时保留显式白名单模式。 - if (corsOptions.AllowedOrigins.Length == 1 && string.Equals(corsOptions.AllowedOrigins[0], "*", StringComparison.Ordinal)) - { - policyBuilder.AllowAnyOrigin(); - } - else - { - policyBuilder.WithOrigins(corsOptions.AllowedOrigins); - } - - if (corsOptions.AllowedMethods.Length == 1 && string.Equals(corsOptions.AllowedMethods[0], "*", StringComparison.Ordinal)) - { - policyBuilder.AllowAnyMethod(); - } - else - { - policyBuilder.WithMethods(corsOptions.AllowedMethods); - } - - if (corsOptions.AllowedHeaders.Length == 1 && string.Equals(corsOptions.AllowedHeaders[0], "*", StringComparison.Ordinal)) - { - policyBuilder.AllowAnyHeader(); - } - else - { - policyBuilder.WithHeaders(corsOptions.AllowedHeaders); - } - }); -}); - -var app = builder.Build(); - -var resolvedSwaggerOptions = app.Services.GetRequiredService>().Value; -var resolvedCorsOptions = app.Services.GetRequiredService>().Value; - -if (resolvedSwaggerOptions.Enabled) + logger.Error(exception, "Flyshot Server Host 启动失败。"); + throw; +} +finally { - app.UseSwagger(options => - { - options.RouteTemplate = resolvedSwaggerOptions.JsonRouteTemplate; - }); - - app.UseSwaggerUI(options => - { - options.RoutePrefix = resolvedSwaggerOptions.RoutePrefix; - options.SwaggerEndpoint( - $"/swagger/{resolvedSwaggerOptions.DocumentName}/swagger.json", - $"{resolvedSwaggerOptions.Title} {resolvedSwaggerOptions.Version}"); - options.DocumentTitle = resolvedSwaggerOptions.Title; - }); + NLog.LogManager.Shutdown(); } -app.UseCors(resolvedCorsOptions.PolicyName); -app.MapControllers(); - -app.Run(); - public partial class Program; diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.css b/src/Flyshot.Server.Host/wwwroot/assets/debug.css new file mode 100644 index 0000000..03f6c3d --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.css @@ -0,0 +1,424 @@ +:root { + color-scheme: light; + --bg: #f5f7fb; + --surface: #ffffff; + --line: #d8dee9; + --text: #172033; + --muted: #5b667a; + --accent: #007c89; + --good: #12805c; + --warn: #b7791f; + --bad: #b42318; + --get: #1f6feb; + --post: #2da44e; + --put: #9a6700; + --delete: #cf222e; + --code-bg: #f4f6fa; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + font-size: 14px; +} + +header { + border-bottom: 1px solid var(--line); + background: var(--surface); + position: sticky; + top: 0; + z-index: 10; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: min(1280px, calc(100% - 32px)); + margin: 0 auto; + padding: 18px 0; +} + +h1 { + margin: 0; + font-size: 22px; + font-weight: 650; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +button { + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #ffffff; + font: inherit; + cursor: pointer; +} + +button.secondary { + background: transparent; + color: var(--accent); +} + +button:disabled { + opacity: 0.55; + cursor: default; +} + +.link-button { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: transparent; + color: var(--accent); + font: inherit; + text-decoration: none; +} + +.link-button:hover { + background: rgba(0, 124, 137, 0.08); +} + +main { + width: min(1280px, calc(100% - 32px)); + margin: 22px auto 60px; +} + +.meta { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + margin-bottom: 18px; +} + +.meta dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 12px 16px; +} + +.meta dt { + color: var(--muted); + font-size: 12px; +} + +.meta dd { + margin: 4px 0 0; + font-family: Consolas, "Cascadia Mono", monospace; + overflow-wrap: anywhere; +} + +.meta dd.bad { color: var(--bad); } +.meta dd.good { color: var(--good); } + +.group { + margin-top: 22px; +} + +.group h2 { + margin: 0 0 10px 4px; + font-size: 16px; + font-weight: 650; + color: var(--muted); +} + +.card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + margin-bottom: 12px; + overflow: hidden; +} + +.card-head { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + user-select: none; +} + +.card-head:hover { + background: #fafbfd; +} + +.badge { + flex: 0 0 auto; + min-width: 60px; + padding: 3px 10px; + border-radius: 999px; + text-align: center; + color: #ffffff; + font-weight: 650; + font-size: 12px; + letter-spacing: 0.5px; +} + +.badge.GET { background: var(--get); } +.badge.POST { background: var(--post); } +.badge.PUT { background: var(--put); } +.badge.DELETE { background: var(--delete); } +.badge.OTHER { background: var(--muted); } + +.card-path { + flex: 1 1 auto; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 14px; + overflow-wrap: anywhere; +} + +.card-summary { + flex: 0 1 auto; + max-width: 50%; + color: var(--muted); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-toggle { + flex: 0 0 auto; + color: var(--muted); + font-size: 12px; +} + +.card-body { + padding: 12px 16px 16px; + border-top: 1px solid var(--line); +} + +.card.collapsed .card-body { + display: none; +} + +.form-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr) 90px; + gap: 8px 12px; + align-items: center; + margin-bottom: 8px; +} + +.form-row .name { + font-family: Consolas, "Cascadia Mono", monospace; + color: var(--text); + overflow-wrap: anywhere; +} + +.form-row .name .required { + color: var(--bad); + margin-left: 4px; +} + +.form-row input[type="text"], +.form-row input[type="number"] { + width: 100%; + min-height: 32px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: 4px; + font: inherit; +} + +.form-row .type { + color: var(--muted); + font-size: 12px; + font-family: Consolas, "Cascadia Mono", monospace; +} + +.body-block { + margin-top: 6px; +} + +.body-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.body-label .left { + color: var(--muted); + font-size: 12px; +} + +textarea.body-editor { + width: 100%; + min-height: 140px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--code-bg); + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 13px; + resize: vertical; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.response-block { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed var(--line); +} + +.response-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 13px; +} + +.status-badge { + padding: 2px 8px; + border-radius: 4px; + color: #ffffff; + font-weight: 650; +} + +.status-badge.s2xx { background: var(--good); } +.status-badge.s3xx { background: var(--get); } +.status-badge.s4xx { background: var(--warn); } +.status-badge.s5xx { background: var(--bad); } +.status-badge.error { background: var(--bad); } + +pre.response-body, +pre.response-headers { + margin: 6px 0 0; + padding: 10px 12px; + background: var(--code-bg); + border: 1px solid var(--line); + border-radius: 4px; + max-height: 360px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12.5px; +} + +pre.response-headers { + max-height: 160px; +} + +details > summary { + cursor: pointer; + color: var(--muted); + font-size: 12px; + margin: 6px 0 0; +} + +.empty-hint { + padding: 12px 0; + color: var(--muted); + font-style: italic; +} + +.history { + position: fixed; + right: 16px; + bottom: 16px; + width: 360px; + max-width: calc(100vw - 32px); + max-height: 50vh; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12); + display: flex; + flex-direction: column; + z-index: 20; +} + +.history h3 { + margin: 0; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + font-size: 13px; + font-weight: 650; + display: flex; + justify-content: space-between; + align-items: center; +} + +.history h3 button { + min-height: 24px; + padding: 0 8px; + font-size: 12px; +} + +.history ul { + list-style: none; + margin: 0; + padding: 6px 0; + overflow: auto; +} + +.history li { + padding: 6px 14px; + font-size: 12px; + border-bottom: 1px solid #edf1f7; + display: grid; + grid-template-columns: 50px 1fr auto; + gap: 8px; + align-items: center; +} + +.history li:last-child { border-bottom: 0; } + +.history li .h-method { + font-weight: 650; + font-family: Consolas, "Cascadia Mono", monospace; +} + +.history li .h-path { + font-family: Consolas, "Cascadia Mono", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 920px) { + .form-row { + grid-template-columns: 1fr; + } + + .meta dl { + grid-template-columns: 1fr; + } + + .history { + position: static; + width: auto; + margin-top: 18px; + max-height: none; + } +} diff --git a/src/Flyshot.Server.Host/wwwroot/assets/debug.js b/src/Flyshot.Server.Host/wwwroot/assets/debug.js new file mode 100644 index 0000000..1efef3f --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/assets/debug.js @@ -0,0 +1,670 @@ +// 静态调试页通过配置 API 获取实际 Swagger JSON 地址,避免硬编码路由前缀。 +const DEBUG_CONFIG_URL = "/api/debug/config"; +const STORAGE_PREFIX = "flyshot.debug."; +const HISTORY_LIMIT = 10; + +const groupTitleByPrefix = [ + // 基础与状态分组:探活和状态快照两个固定 API 路径 + { match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" }, + // 默认兜底:剩余全部走 ControllerClient 兼容分组 + { match: function () { return true; }, title: "ControllerClient 兼容" } +]; + +const state = { + spec: null, + operations: [], + history: [] +}; + +/** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */ +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (ch) { + return { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]; + }); +} + +/** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */ +function resolveRef(ref) { + if (!ref || !state.spec) return null; + const parts = ref.replace(/^#\//, "").split("/"); + let cursor = state.spec; + for (const part of parts) { + if (cursor && Object.prototype.hasOwnProperty.call(cursor, part)) { + cursor = cursor[part]; + } else { + return null; + } + } + return cursor; +} + +/** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */ +function buildSampleFromSchema(schema, depth) { + depth = depth || 0; + // 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。 + if (!schema || depth > 4) return null; + + if (schema.$ref) { + const resolved = resolveRef(schema.$ref); + return resolved ? buildSampleFromSchema(resolved, depth + 1) : null; + } + + // 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。 + if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1); + if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1); + if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { + const merged = {}; + schema.allOf.forEach(function (sub) { + const value = buildSampleFromSchema(sub, depth + 1); + if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value); + }); + return merged; + } + + const type = schema.type || (schema.properties ? "object" : "string"); + switch (type) { + case "object": { + const result = {}; + const props = schema.properties || {}; + Object.keys(props).forEach(function (key) { + result[key] = buildSampleFromSchema(props[key], depth + 1); + }); + return result; + } + case "array": + return []; + case "integer": + case "number": + return 0; + case "boolean": + return false; + case "string": + default: + if (schema.enum && schema.enum.length > 0) return schema.enum[0]; + return ""; + } +} + +/** 把 schema.type 翻译成 input type 与展示文本。 */ +function inputKindForType(schema) { + if (!schema) return { kind: "text", label: "string" }; + const type = schema.type || "string"; + if (type === "boolean") return { kind: "checkbox", label: "boolean" }; + if (type === "integer" || type === "number") return { kind: "number", label: type }; + return { kind: "text", label: type }; +} + +/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */ +function extractOperations(spec) { + const operations = []; + const paths = spec.paths || {}; + Object.keys(paths).forEach(function (path) { + const pathItem = paths[path] || {}; + ["get", "post", "put", "delete", "patch", "options", "head"].forEach(function (method) { + const op = pathItem[method]; + if (!op) return; + const parameters = (op.parameters || []).filter(function (p) { return p.in === "query" || p.in === "path"; }); + let bodySchema = null; + if (op.requestBody && op.requestBody.content) { + const json = op.requestBody.content["application/json"]; + if (json && json.schema) bodySchema = json.schema; + } + operations.push({ + method: method.toUpperCase(), + path: path, + summary: op.summary || "", + description: op.description || "", + tags: op.tags || [], + parameters: parameters, + bodySchema: bodySchema + }); + }); + }); + return operations; +} + +/** 选择分组:优先用第一条匹配的 groupTitleByPrefix 规则,OpenAPI tag 留作兜底。 */ +function pickGroup(op) { + for (const rule of groupTitleByPrefix) { + if (rule.match(op)) return rule.title; + } + if (op.tags && op.tags.length > 0) return op.tags[0]; + return "其它"; +} + +/** localStorage key 必须避免冲突,使用 method:path 复合键。 */ +function storageKey(op) { + return STORAGE_PREFIX + op.method + ":" + op.path; +} + +/** 读取本端点最近一次输入;解析失败则当作空。 */ +function loadInputs(op) { + try { + const raw = window.localStorage.getItem(storageKey(op)); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +/** 保存本端点最近一次输入;写入失败时静默忽略,避免影响调试体验。 */ +function saveInputs(op, payload) { + try { + window.localStorage.setItem(storageKey(op), JSON.stringify(payload)); + } catch (e) { + // localStorage 可能被禁用或满载,忽略写入失败。 + } +} + +/** 拼接最终请求 URL(含 query 串与 path 参数替换)。 */ +function buildRequestUrl(op, paramValues) { + let path = op.path; + const queryPairs = []; + op.parameters.forEach(function (param) { + const raw = paramValues[param.name]; + if (raw === undefined || raw === null || raw === "") return; + if (param.in === "path") { + path = path.replace("{" + param.name + "}", encodeURIComponent(raw)); + } else if (param.in === "query") { + queryPairs.push(encodeURIComponent(param.name) + "=" + encodeURIComponent(raw)); + } + }); + return path + (queryPairs.length > 0 ? "?" + queryPairs.join("&") : ""); +} + +/** 生成与浏览器请求等价的 curl 命令,便于复制到终端复现。 */ +function buildCurlCommand(op, requestUrl, body) { + const parts = ["curl", "-X", op.method, JSON.stringify(window.location.origin + requestUrl)]; + if (body !== null && body !== undefined && body !== "") { + parts.push("-H", "\"Content-Type: application/json\""); + parts.push("--data-raw", JSON.stringify(body)); + } + return parts.join(" "); +} + +/** 渲染参数输入表单,返回收集函数。 */ +function renderParameterRows(container, op, savedValues) { + if (op.parameters.length === 0) return function () { return {}; }; + + const inputs = {}; + op.parameters.forEach(function (param) { + const row = document.createElement("div"); + row.className = "form-row"; + + const nameNode = document.createElement("div"); + nameNode.className = "name"; + nameNode.textContent = param.name + " (" + param.in + ")"; + if (param.required) { + const requiredMark = document.createElement("span"); + requiredMark.className = "required"; + requiredMark.textContent = "*"; + nameNode.appendChild(requiredMark); + } + row.appendChild(nameNode); + + const kind = inputKindForType(param.schema); + const inputNode = document.createElement("input"); + inputNode.type = kind.kind; + if (kind.kind === "checkbox") { + inputNode.checked = savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name) + ? Boolean(savedValues[param.name]) + : Boolean(param.schema && param.schema.default); + } else { + let initial = ""; + if (savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)) { + initial = String(savedValues[param.name]); + } else if (param.schema && param.schema.default !== undefined) { + initial = String(param.schema.default); + } + inputNode.value = initial; + if (kind.kind === "number") inputNode.step = "any"; + } + row.appendChild(inputNode); + + const typeNode = document.createElement("div"); + typeNode.className = "type"; + typeNode.textContent = kind.label; + row.appendChild(typeNode); + + container.appendChild(row); + inputs[param.name] = { node: inputNode, kind: kind.kind, schema: param.schema }; + }); + + return function collect() { + const collected = {}; + Object.keys(inputs).forEach(function (key) { + const item = inputs[key]; + if (item.kind === "checkbox") { + collected[key] = item.node.checked; + } else { + const raw = item.node.value; + if (raw === "") { + collected[key] = ""; + } else if (item.kind === "number") { + const num = Number(raw); + collected[key] = Number.isNaN(num) ? raw : num; + } else { + collected[key] = raw; + } + } + }); + return collected; + }; +} + +/** 渲染请求体编辑器,返回收集函数。 */ +function renderBodyEditor(container, op, savedBody) { + if (!op.bodySchema) return function () { return null; }; + + const block = document.createElement("div"); + block.className = "body-block"; + + const labelRow = document.createElement("div"); + labelRow.className = "body-label"; + const left = document.createElement("div"); + left.className = "left"; + left.textContent = "请求体 (application/json)"; + labelRow.appendChild(left); + + const formatBtn = document.createElement("button"); + formatBtn.type = "button"; + formatBtn.className = "secondary"; + formatBtn.textContent = "格式化 JSON"; + labelRow.appendChild(formatBtn); + + block.appendChild(labelRow); + + const textarea = document.createElement("textarea"); + textarea.className = "body-editor"; + textarea.spellcheck = false; + let initialText; + if (savedBody !== undefined && savedBody !== null) { + initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2); + } else { + const sample = buildSampleFromSchema(op.bodySchema, 0); + initialText = sample === null ? "" : JSON.stringify(sample, null, 2); + } + textarea.value = initialText; + block.appendChild(textarea); + + formatBtn.addEventListener("click", function () { + try { + const parsed = JSON.parse(textarea.value || "null"); + textarea.value = parsed === null ? "" : JSON.stringify(parsed, null, 2); + } catch (e) { + window.alert("JSON 解析失败: " + e.message); + } + }); + + container.appendChild(block); + + return function collect() { + return textarea.value; + }; +} + +/** 把 HTTP 状态码翻译成颜色徽标 class。 */ +function statusBadgeClass(status) { + if (status >= 200 && status < 300) return "s2xx"; + if (status >= 300 && status < 400) return "s3xx"; + if (status >= 400 && status < 500) return "s4xx"; + if (status >= 500) return "s5xx"; + return "error"; +} + +/** 把响应头展开成可读字符串。 */ +function formatHeaders(headers) { + const lines = []; + headers.forEach(function (value, key) { lines.push(key + ": " + value); }); + return lines.join("\n"); +} + +/** 在历史面板顶部追加一条记录,超过上限则丢弃尾部。 */ +function pushHistory(entry) { + state.history.unshift(entry); + if (state.history.length > HISTORY_LIMIT) state.history.length = HISTORY_LIMIT; + renderHistory(); +} + +function renderHistory() { + const list = document.getElementById("history-list"); + list.innerHTML = ""; + if (state.history.length === 0) { + const empty = document.createElement("li"); + empty.textContent = "暂无调用记录"; + empty.style.color = "var(--muted)"; + empty.style.gridTemplateColumns = "1fr"; + list.appendChild(empty); + return; + } + state.history.forEach(function (entry) { + const li = document.createElement("li"); + const method = document.createElement("span"); + method.className = "h-method"; + method.textContent = entry.method; + method.style.color = entry.method === "GET" ? "var(--get)" : entry.method === "POST" ? "var(--post)" : "var(--muted)"; + const path = document.createElement("span"); + path.className = "h-path"; + path.title = entry.url; + path.textContent = entry.url; + const meta = document.createElement("span"); + meta.style.color = "var(--muted)"; + meta.textContent = (entry.status || "ERR") + " · " + entry.elapsedMs + "ms"; + li.appendChild(method); + li.appendChild(path); + li.appendChild(meta); + list.appendChild(li); + }); +} + +/** 渲染单个端点的卡片。 */ +function renderOperationCard(op) { + const card = document.createElement("section"); + card.className = "card collapsed"; + + const head = document.createElement("div"); + head.className = "card-head"; + + const badge = document.createElement("span"); + badge.className = "badge " + (["GET", "POST", "PUT", "DELETE"].indexOf(op.method) >= 0 ? op.method : "OTHER"); + badge.textContent = op.method; + head.appendChild(badge); + + const path = document.createElement("span"); + path.className = "card-path"; + path.textContent = op.path; + head.appendChild(path); + + const summary = document.createElement("span"); + summary.className = "card-summary"; + summary.textContent = op.summary; + summary.title = op.summary; + head.appendChild(summary); + + const toggle = document.createElement("span"); + toggle.className = "card-toggle"; + toggle.textContent = "展开 ▾"; + head.appendChild(toggle); + + head.addEventListener("click", function () { + const collapsed = card.classList.toggle("collapsed"); + toggle.textContent = collapsed ? "展开 ▾" : "收起 ▴"; + }); + card.appendChild(head); + + const body = document.createElement("div"); + body.className = "card-body"; + + // 描述(来自 XML summary)独立成一段 + if (op.summary) { + const desc = document.createElement("div"); + desc.style.color = "var(--muted)"; + desc.style.marginBottom = "10px"; + desc.style.fontSize = "13px"; + desc.textContent = op.summary; + body.appendChild(desc); + } + + const saved = loadInputs(op) || {}; + + // 参数区 + let collectParams = function () { return {}; }; + if (op.parameters.length > 0) { + const paramsContainer = document.createElement("div"); + paramsContainer.className = "params"; + body.appendChild(paramsContainer); + collectParams = renderParameterRows(paramsContainer, op, saved.params); + } + + // 请求体区 + const collectBody = renderBodyEditor(body, op, saved.body); + + // 操作按钮 + const buttonRow = document.createElement("div"); + buttonRow.className = "button-row"; + + const sendBtn = document.createElement("button"); + sendBtn.type = "button"; + sendBtn.textContent = "发送"; + + const resetBtn = document.createElement("button"); + resetBtn.type = "button"; + resetBtn.className = "secondary"; + resetBtn.textContent = "重置"; + + const curlBtn = document.createElement("button"); + curlBtn.type = "button"; + curlBtn.className = "secondary"; + curlBtn.textContent = "复制 curl"; + + buttonRow.appendChild(sendBtn); + buttonRow.appendChild(resetBtn); + buttonRow.appendChild(curlBtn); + body.appendChild(buttonRow); + + // 响应面板 + const responseBlock = document.createElement("div"); + responseBlock.className = "response-block"; + responseBlock.style.display = "none"; + body.appendChild(responseBlock); + + function renderResponse(payload) { + responseBlock.style.display = "block"; + responseBlock.innerHTML = ""; + + const summaryRow = document.createElement("div"); + summaryRow.className = "response-summary"; + + const statusBadge = document.createElement("span"); + statusBadge.className = "status-badge " + statusBadgeClass(payload.status || 0); + statusBadge.textContent = payload.status ? payload.status + " " + (payload.statusText || "") : "请求失败"; + summaryRow.appendChild(statusBadge); + + const elapsed = document.createElement("span"); + elapsed.style.color = "var(--muted)"; + elapsed.textContent = payload.elapsedMs + " ms · " + payload.url; + summaryRow.appendChild(elapsed); + + responseBlock.appendChild(summaryRow); + + if (payload.error) { + const pre = document.createElement("pre"); + pre.className = "response-body"; + pre.textContent = payload.error; + responseBlock.appendChild(pre); + return; + } + + const headersDetails = document.createElement("details"); + const headersSummary = document.createElement("summary"); + headersSummary.textContent = "响应头"; + headersDetails.appendChild(headersSummary); + const headersPre = document.createElement("pre"); + headersPre.className = "response-headers"; + headersPre.textContent = payload.headers; + headersDetails.appendChild(headersPre); + responseBlock.appendChild(headersDetails); + + const bodyPre = document.createElement("pre"); + bodyPre.className = "response-body"; + bodyPre.textContent = payload.bodyText; + responseBlock.appendChild(bodyPre); + } + + sendBtn.addEventListener("click", async function () { + sendBtn.disabled = true; + const params = collectParams(); + const rawBody = collectBody(); + saveInputs(op, { params: params, body: rawBody }); + + const requestUrl = buildRequestUrl(op, params); + const init = { method: op.method, headers: {} }; + + // 仅 POST/PUT/PATCH/DELETE 才认为可能携带 body;对没有 bodySchema 的方法直接跳过。 + const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0; + if (methodAllowsBody && op.bodySchema && rawBody !== null && rawBody !== undefined && rawBody !== "") { + init.headers["Content-Type"] = "application/json"; + init.body = rawBody; + } + + const startedAt = performance.now(); + try { + const response = await fetch(requestUrl, init); + const elapsedMs = Math.round(performance.now() - startedAt); + const text = await response.text(); + const contentType = response.headers.get("content-type") || ""; + let bodyText = text; + if (contentType.indexOf("application/json") >= 0) { + try { + bodyText = JSON.stringify(JSON.parse(text), null, 2); + } catch (e) { + bodyText = text; + } + } + renderResponse({ + status: response.status, + statusText: response.statusText, + headers: formatHeaders(response.headers), + bodyText: bodyText, + url: requestUrl, + elapsedMs: elapsedMs + }); + pushHistory({ method: op.method, url: requestUrl, status: response.status, elapsedMs: elapsedMs }); + } catch (err) { + const elapsedMs = Math.round(performance.now() - startedAt); + renderResponse({ + error: String(err && err.message ? err.message : err), + url: requestUrl, + elapsedMs: elapsedMs + }); + pushHistory({ method: op.method, url: requestUrl, status: 0, elapsedMs: elapsedMs }); + } finally { + sendBtn.disabled = false; + } + }); + + resetBtn.addEventListener("click", function () { + try { window.localStorage.removeItem(storageKey(op)); } catch (e) { /* 忽略 */ } + // 直接重新渲染当前卡片:替换原 DOM 节点。 + const refreshed = renderOperationCard(op); + refreshed.classList.remove("collapsed"); + refreshed.querySelector(".card-toggle").textContent = "收起 ▴"; + card.parentNode.replaceChild(refreshed, card); + }); + + curlBtn.addEventListener("click", function () { + const params = collectParams(); + const rawBody = collectBody(); + const requestUrl = buildRequestUrl(op, params); + const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0; + const bodyForCurl = methodAllowsBody && op.bodySchema ? rawBody : null; + const command = buildCurlCommand(op, requestUrl, bodyForCurl); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(function () { + curlBtn.textContent = "已复制 ✓"; + window.setTimeout(function () { curlBtn.textContent = "复制 curl"; }, 1500); + }).catch(function () { + window.prompt("复制失败,手动复制:", command); + }); + } else { + window.prompt("复制失败,手动复制:", command); + } + }); + + card.appendChild(body); + return card; +} + +/** 把 operation 列表按分组渲染到主区域。 */ +function renderGroups(operations) { + const root = document.getElementById("debug-console-app"); + root.innerHTML = ""; + if (operations.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty-hint"; + empty.textContent = "OpenAPI 文档中没有任何端点。"; + root.appendChild(empty); + return; + } + + const grouped = new Map(); + operations.forEach(function (op) { + const groupTitle = pickGroup(op); + if (!grouped.has(groupTitle)) grouped.set(groupTitle, []); + grouped.get(groupTitle).push(op); + }); + + // 固定输出顺序:基础与状态在前,ControllerClient 兼容在后,其余按字典序。 + const orderedTitles = []; + ["基础与状态", "ControllerClient 兼容"].forEach(function (title) { + if (grouped.has(title)) orderedTitles.push(title); + }); + Array.from(grouped.keys()).sort().forEach(function (title) { + if (orderedTitles.indexOf(title) < 0) orderedTitles.push(title); + }); + + orderedTitles.forEach(function (title) { + const ops = grouped.get(title); + ops.sort(function (a, b) { + if (a.path === b.path) return a.method.localeCompare(b.method); + return a.path.localeCompare(b.path); + }); + + const section = document.createElement("section"); + section.className = "group"; + const heading = document.createElement("h2"); + heading.textContent = title + " (" + ops.length + ")"; + section.appendChild(heading); + ops.forEach(function (op) { section.appendChild(renderOperationCard(op)); }); + root.appendChild(section); + }); +} + +/** 加载 OpenAPI 文档并渲染。 */ +async function loadSpecAndRender() { + const metaSpec = document.getElementById("meta-spec-url"); + const metaCount = document.getElementById("meta-operation-count"); + const metaStatus = document.getElementById("meta-status"); + + metaSpec.textContent = "正在读取调试配置..."; + metaStatus.textContent = "正在拉取 OpenAPI 文档..."; + metaStatus.className = ""; + + try { + const configResponse = await fetch(DEBUG_CONFIG_URL, { cache: "no-store" }); + if (!configResponse.ok) throw new Error("调试配置 HTTP " + configResponse.status + " " + configResponse.statusText); + const config = await configResponse.json(); + const swaggerJsonUrl = config.swaggerJsonUrl; + if (!swaggerJsonUrl) throw new Error("调试配置缺少 swaggerJsonUrl"); + metaSpec.textContent = swaggerJsonUrl; + + const response = await fetch(swaggerJsonUrl, { cache: "no-store" }); + if (!response.ok) throw new Error("HTTP " + response.status + " " + response.statusText); + const spec = await response.json(); + state.spec = spec; + state.operations = extractOperations(spec); + metaCount.textContent = state.operations.length; + metaStatus.textContent = "已加载"; + metaStatus.className = "good"; + renderGroups(state.operations); + } catch (err) { + metaStatus.textContent = "加载失败: " + (err && err.message ? err.message : err); + metaStatus.className = "bad"; + metaCount.textContent = "0"; + const root = document.getElementById("debug-console-app"); + root.innerHTML = ""; + const errBlock = document.createElement("div"); + errBlock.className = "empty-hint"; + errBlock.textContent = "无法加载 OpenAPI 文档,请确认 Swagger:Enabled = true 且 " + DEBUG_CONFIG_URL + " 可访问。"; + root.appendChild(errBlock); + } +} + +document.getElementById("reload-spec").addEventListener("click", loadSpecAndRender); +document.getElementById("history-clear").addEventListener("click", function () { + state.history.length = 0; + renderHistory(); +}); + +renderHistory(); +loadSpecAndRender(); diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.css b/src/Flyshot.Server.Host/wwwroot/assets/status.css new file mode 100644 index 0000000..afc0ccb --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.css @@ -0,0 +1,214 @@ +:root { + color-scheme: light; + --bg: #f5f7fb; + --surface: #ffffff; + --line: #d8dee9; + --text: #172033; + --muted: #5b667a; + --accent: #007c89; + --good: #12805c; + --warn: #b7791f; + --bad: #b42318; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + font-size: 15px; + letter-spacing: 0; +} + +header { + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 18px 0; +} + +h1 { + margin: 0; + font-size: 22px; + font-weight: 650; +} + +button { + min-height: 36px; + padding: 0 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #ffffff; + font: inherit; + cursor: pointer; +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +/* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */ +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +.link-button { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: transparent; + color: var(--accent); + font: inherit; + text-decoration: none; +} + +.link-button:hover { + background: rgba(0, 124, 137, 0.08); +} + +main { + width: min(1180px, calc(100% - 32px)); + margin: 22px auto; +} + +.summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.metric, +section { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.metric { + min-height: 86px; + padding: 14px; +} + +.label { + color: var(--muted); + font-size: 13px; +} + +.value { + margin-top: 8px; + overflow-wrap: anywhere; + font-size: 24px; + font-weight: 650; +} + +.status-row { + display: flex; + align-items: center; + gap: 8px; +} + +.dot { + width: 12px; + height: 12px; + flex: 0 0 12px; + border-radius: 999px; + background: var(--warn); +} + +.dot.good { + background: var(--good); +} + +.dot.bad { + background: var(--bad); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +section h2 { + margin: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--line); + font-size: 16px; + font-weight: 650; +} + +dl { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 0; + margin: 0; + padding: 4px 16px 12px; +} + +dt, +dd { + min-height: 36px; + margin: 0; + padding: 9px 0; + border-bottom: 1px solid #edf1f7; +} + +dt { + color: var(--muted); +} + +dd { + overflow-wrap: anywhere; + font-family: Consolas, "Cascadia Mono", monospace; +} + +.empty { + color: var(--muted); + font-family: inherit; +} + +@media (max-width: 820px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .summary, + .grid { + grid-template-columns: 1fr; + } + + dl { + grid-template-columns: 1fr; + } + + dt { + border-bottom: 0; + padding-bottom: 2px; + } + + dd { + padding-top: 2px; + } +} diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.js b/src/Flyshot.Server.Host/wwwroot/assets/status.js new file mode 100644 index 0000000..417c431 --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.js @@ -0,0 +1,92 @@ +const fields = { + connectionState: document.getElementById("connection-state"), + stateDot: document.getElementById("state-dot"), + robotName: document.getElementById("robot-name"), + speedRatio: document.getElementById("speed-ratio"), + motionState: document.getElementById("motion-state"), + serverVersion: document.getElementById("server-version"), + clientVersion: document.getElementById("client-version"), + setupState: document.getElementById("setup-state"), + enabledState: document.getElementById("enabled-state"), + j519Status: document.getElementById("j519-status"), + j519Sequence: document.getElementById("j519-sequence"), + capturedAt: document.getElementById("captured-at"), + dof: document.getElementById("dof"), + joints: document.getElementById("joints"), + pose: document.getElementById("pose"), + trajectories: document.getElementById("trajectories"), + refresh: document.getElementById("refresh") +}; + +function formatArray(values) { + if (!Array.isArray(values) || values.length === 0) { + return "--"; + } + + return values.map(value => Number(value).toFixed(4)).join(", "); +} + +function formatNullableBool(value) { + if (value === true) { + return "是"; + } + + if (value === false) { + return "否"; + } + + return "--"; +} + +function formatJ519Status(snapshot) { + if (snapshot.j519Status === null || snapshot.j519Status === undefined) { + return "--"; + } + + const status = Number(snapshot.j519Status).toString(16).padStart(2, "0").toUpperCase(); + return `0x${status} accept=${formatNullableBool(snapshot.j519AcceptsCommand)} received=${formatNullableBool(snapshot.j519ReceivedCommand)} sysrdy=${formatNullableBool(snapshot.j519SystemReady)} motion=${formatNullableBool(snapshot.j519RobotInMotion)}`; +} + +function setDot(connectionState) { + fields.stateDot.className = "dot"; + if (connectionState === "Connected") { + fields.stateDot.classList.add("good"); + } else if (connectionState === "NotConfigured") { + fields.stateDot.classList.add("bad"); + } +} + +async function refreshStatus() { + fields.refresh.disabled = true; + try { + const response = await fetch("/api/status/snapshot", { cache: "no-store" }); + const payload = await response.json(); + const snapshot = payload.snapshot; + + fields.connectionState.textContent = snapshot.connectionState; + fields.robotName.textContent = payload.robotName || "--"; + fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2); + fields.motionState.textContent = snapshot.isInMotion ? "是" : "否"; + fields.serverVersion.textContent = payload.serverVersion; + fields.clientVersion.textContent = payload.clientVersion; + fields.setupState.textContent = payload.isSetup ? "是" : "否"; + fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否"; + fields.j519Status.textContent = formatJ519Status(snapshot); + fields.j519Sequence.textContent = snapshot.j519Sequence ?? "--"; + fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString(); + fields.dof.textContent = payload.degreesOfFreedom; + fields.joints.textContent = formatArray(snapshot.jointPositions); + fields.pose.textContent = formatArray(snapshot.cartesianPose); + fields.trajectories.textContent = payload.uploadedTrajectories.length > 0 + ? payload.uploadedTrajectories.join(", ") + : "--"; + fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0); + setDot(snapshot.connectionState); + } finally { + fields.refresh.disabled = false; + } +} + +fields.refresh.addEventListener("click", refreshStatus); +refreshStatus(); +window.setInterval(refreshStatus, 2000); diff --git a/src/Flyshot.Server.Host/wwwroot/debug.html b/src/Flyshot.Server.Host/wwwroot/debug.html new file mode 100644 index 0000000..3b85ee4 --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/debug.html @@ -0,0 +1,44 @@ + + + + + + Flyshot Replacement 接口调试 + + + +
+
+

Flyshot Replacement 接口调试

+
+ 回到状态页 + Swagger UI + +
+
+
+
+
+
+
OpenAPI 文档
+
--
+
API 数量
+
--
+
加载状态
+
初始化中...
+
+
+
+
正在加载接口列表...
+
+
+ + + + diff --git a/src/Flyshot.Server.Host/wwwroot/status.html b/src/Flyshot.Server.Host/wwwroot/status.html new file mode 100644 index 0000000..df326a8 --- /dev/null +++ b/src/Flyshot.Server.Host/wwwroot/status.html @@ -0,0 +1,64 @@ + + + + + + Flyshot Replacement 状态监控 + + + +
+
+

Flyshot Replacement 状态监控

+
+ 调试接口 + +
+
+
+
+
+
+
连接状态
+
--
+
+
+
机器人
+
--
+
+
+
速度倍率
+
--
+
+
+
运动中
+
--
+
+
+
+
+

控制器

+
+
服务端版本
--
+
客户端版本
--
+
已初始化
--
+
已使能
--
+
J519 状态
--
+
J519 序号
--
+
采样时间
--
+
+
+
+

机器人

+
+
自由度
--
+
关节位置
--
+
TCP 位姿
--
+
已上传轨迹
--
+
+
+
+
+ + + diff --git a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs index 5278a25..601d581 100644 --- a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs @@ -5,54 +5,74 @@ using Microsoft.Extensions.Configuration; namespace Flyshot.Server.IntegrationTests; /// -/// 验证 `/debug` 在线 API 调试页的暴露策略与基础内容契约。 +/// 验证 `wwwroot` 静态调试页和调试配置 API 的基础内容契约。 /// /// -/// 调试页与 Swagger UI 共用 Swagger:Enabled 开关,开关关闭时 -/// 调试页与 `/swagger` 一同下线,避免生产环境意外暴露调试入口。 +/// 调试页自身是静态 HTML,真正的 Swagger 地址由配置 API 下发; +/// 当 Swagger 关闭时,配置 API 返回 404,前端据此显示不可用状态。 /// public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture { private readonly FlyshotServerFactory _factory = factory; /// - /// 当 Swagger 启用时,`/debug` 应当返回完整的 HTML 调试页。 + /// `debug.html` 应当作为可直接调试的静态页面暴露。 /// [Fact] - public async Task GetDebug_WhenSwaggerEnabled_ReturnsConsoleHtml() + public async Task GetDebugHtml_ReturnsConsoleStaticPage() { - // 默认配置即开启 Swagger,调试页应当作为浏览器可直接打开的 HTML 暴露。 using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); using var client = configuredFactory.CreateClient(); - using var response = await client.GetAsync("/debug"); + using var response = await client.GetAsync("/debug.html"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType); var html = await response.Content.ReadAsStringAsync(); - // 页面标题与稳定锚点用于回归保护:调试页骨架一旦丢失,测试立即报警。 Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal); Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal); - - // 控制器需要在返回 HTML 前把 Swagger JSON URL 注入到页面占位符里, - // 否则前端无法在加载时拉取 OpenAPI 文档。 - Assert.Contains("/swagger/v1/swagger.json", html, StringComparison.Ordinal); Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal); + Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal); + Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal); + + using var scriptResponse = await client.GetAsync("/assets/debug.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("/api/debug/config", script, StringComparison.Ordinal); } /// - /// 当 Swagger 关闭时,`/debug` 应当与 `/swagger` 同步下线(404)。 + /// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。 /// [Fact] - public async Task GetDebug_WhenSwaggerDisabled_ReturnsNotFound() + public async Task GetDebugConfig_WhenSwaggerEnabled_ReturnsSwaggerJsonUrl() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/api/debug/config"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream); + + Assert.Equal("/swagger/v1/swagger.json", document.RootElement.GetProperty("swaggerJsonUrl").GetString()); + } + + /// + /// 当 Swagger 关闭时,调试配置 API 应当与 Swagger UI 同步下线(404)。 + /// + [Fact] + public async Task GetDebugConfig_WhenSwaggerDisabled_ReturnsNotFound() { - // 显式把 Swagger:Enabled 置为 false,此时调试页也不应当被访问到。 using var configuredFactory = CreateFactoryWithSwaggerEnabled(false); using var client = configuredFactory.CreateClient(); - using var response = await client.GetAsync("/debug"); + using var response = await client.GetAsync("/api/debug/config"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -81,21 +101,20 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC } /// - /// 状态页应当提供跳转到调试页的入口,便于现场顺手跳转。 + /// 状态页应当提供跳转到静态调试页的入口,便于现场顺手跳转。 /// [Fact] - public async Task GetStatusPage_LinksToDebugConsole() + public async Task GetStatusHtml_LinksToDebugConsole() { using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); using var client = configuredFactory.CreateClient(); - using var response = await client.GetAsync("/status"); + using var response = await client.GetAsync("/status.html"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var html = await response.Content.ReadAsStringAsync(); - // 状态页头部需要至少一个指向 `/debug` 的链接,文案不强制以保留排版调整空间。 - Assert.Contains("href=\"/debug\"", html, StringComparison.Ordinal); + Assert.Contains("href=\"/debug.html\"", html, StringComparison.Ordinal); } /// diff --git a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs index d577394..1e78f3c 100644 --- a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs @@ -10,21 +10,28 @@ namespace Flyshot.Server.IntegrationTests; public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture { /// - /// 验证状态页返回可由浏览器直接打开的 HTML,并引用状态快照 API。 + /// 验证状态页作为 wwwroot 静态 HTML 暴露,并引用状态快照 API。 /// [Fact] - public async Task GetStatusPage_ReturnsMonitoringHtml() + public async Task GetStatusHtml_ReturnsMonitoringStaticPage() { using var client = factory.CreateClient(); - using var response = await client.GetAsync("/status"); + using var response = await client.GetAsync("/status.html"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType); var html = await response.Content.ReadAsStringAsync(); Assert.Contains("Flyshot Replacement 状态监控", html, StringComparison.Ordinal); - Assert.Contains("/api/status/snapshot", html, StringComparison.Ordinal); + Assert.Contains("/assets/status.css", html, StringComparison.Ordinal); + Assert.Contains("/assets/status.js", html, StringComparison.Ordinal); + + using var scriptResponse = await client.GetAsync("/assets/status.js"); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + + var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal); } ///