feat: add CLI debug snapshot command for one-shot instance state inspection

Adds `debug snapshot --id <int>` to query a running instance's current
attribute values and alarm states without the subscribe/stream overhead
of the debug view. Routes through ManagementActor → CommunicationService
→ site DeploymentManager → InstanceActor using the existing remote query
pattern.
This commit is contained in:
Joseph Doherty
2026-03-18 07:16:22 -04:00
parent 6ee820b0f0
commit 9c6e3c2e56
14 changed files with 144 additions and 4 deletions

View File

@@ -0,0 +1,31 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI.Commands;
public static class DebugCommands
{
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
{
var command = new Command("debug") { Description = "Runtime debugging" };
command.Add(BuildSnapshot(contactPointsOption, formatOption));
return command;
}
private static Command BuildSnapshot(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("snapshot") { Description = "Get a point-in-time snapshot of instance attribute values and alarm states" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new DebugSnapshotCommand(result.GetValue(idOption)));
});
return cmd;
}
}

View File

@@ -26,6 +26,7 @@ rootCommand.Add(NotificationCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(SecurityCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(AuditLogCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(HealthCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(DebugCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(SharedScriptCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(DbConnectionCommands.Build(contactPointsOption, formatOption));
rootCommand.Add(ApiMethodCommands.Build(contactPointsOption, formatOption));

View File

@@ -0,0 +1,5 @@
namespace ScadaLink.Commons.Messages.DebugView;
public record DebugSnapshotRequest(
string InstanceUniqueName,
string CorrelationId);

View File

@@ -0,0 +1,3 @@
namespace ScadaLink.Commons.Messages.Management;
public record DebugSnapshotCommand(int InstanceId);

View File

@@ -104,6 +104,9 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
Receive<SubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
Receive<UnsubscribeDebugViewRequest>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager
Receive<DebugSnapshotRequest>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 7: Remote Queries
Receive<EventLogQueryRequest>(msg =>
{

View File

@@ -130,7 +130,17 @@ public class CommunicationService
GetActor().Tell(new SiteEnvelope(siteId, request));
}
// ── Pattern 6: Health Reporting (site→central, Tell) ──
// ── Pattern 6a: Debug Snapshot (one-shot, request/response) ──
public async Task<DebugViewSnapshot> RequestDebugSnapshotAsync(
string siteId, DebugSnapshotRequest request, CancellationToken cancellationToken = default)
{
var envelope = new SiteEnvelope(siteId, request);
return await GetActor().Ask<DebugViewSnapshot>(
envelope, _options.QueryTimeout, cancellationToken);
}
// ── Pattern 6b: Health Reporting (site→central, Tell) ──
// Health reports are received by central, not sent. No method needed here.
// ── Pattern 7: Remote Queries ──

View File

@@ -12,6 +12,7 @@ using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.DeploymentManager;
@@ -109,7 +110,8 @@ public class ManagementActor : ReceiveActor
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand
or MgmtDeployArtifactsCommand => "Deployment",
or MgmtDeployArtifactsCommand
or DebugSnapshotCommand => "Deployment",
// Read-only queries -- any authenticated user
_ => null
@@ -234,6 +236,7 @@ public class ManagementActor : ReceiveActor
// Remote Queries
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd),
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd),
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd),
_ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}")
};
@@ -1105,4 +1108,19 @@ public class ManagementActor : ReceiveActor
DateTimeOffset.UtcNow);
return await commService.QueryParkedMessagesAsync(cmd.SiteIdentifier, request);
}
private static async Task<object?> HandleDebugSnapshot(IServiceProvider sp, DebugSnapshotCommand cmd)
{
var instanceRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var instance = await instanceRepo.GetInstanceByIdAsync(cmd.InstanceId)
?? throw new InvalidOperationException($"Instance {cmd.InstanceId} not found.");
var siteRepo = sp.GetRequiredService<ISiteRepository>();
var site = await siteRepo.GetSiteByIdAsync(instance.SiteId)
?? throw new InvalidOperationException($"Site {instance.SiteId} not found.");
var commService = sp.GetRequiredService<CommunicationService>();
var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N"));
return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request);
}
}

View File

@@ -71,6 +71,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Debug View — route to Instance Actors
Receive<SubscribeDebugViewRequest>(RouteDebugViewSubscribe);
Receive<UnsubscribeDebugViewRequest>(RouteDebugViewUnsubscribe);
Receive<DebugSnapshotRequest>(RouteDebugSnapshot);
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
@@ -453,6 +454,22 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
}
private void RouteDebugSnapshot(DebugSnapshotRequest request)
{
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
{
instanceActor.Forward(request);
}
else
{
_logger.LogWarning(
"Debug snapshot for unknown instance {Instance}", request.InstanceUniqueName);
Sender.Tell(new DebugViewSnapshot(
request.InstanceUniqueName, Array.Empty<Commons.Messages.Streaming.AttributeValueChanged>(),
Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow));
}
}
/// <summary>
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Persists artifacts to SiteStorageService and recompiles shared scripts.

View File

@@ -133,6 +133,9 @@ public class InstanceActor : ReceiveActor
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
// Debug snapshot (one-shot, no subscription)
Receive<DebugSnapshotRequest>(HandleDebugSnapshot);
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
}
@@ -399,6 +402,35 @@ public class InstanceActor : ReceiveActor
_instanceUniqueName, request.CorrelationId);
}
/// <summary>
/// One-shot debug snapshot — returns current state without registering a subscriber.
/// </summary>
private void HandleDebugSnapshot(DebugSnapshotRequest request)
{
var attributeValues = _attributes.Select(kvp => new AttributeValueChanged(
_instanceUniqueName,
kvp.Key,
kvp.Key,
kvp.Value,
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
DateTimeOffset.UtcNow)).ToList();
var alarmStates = _alarmStates.Select(kvp => new AlarmStateChanged(
_instanceUniqueName,
kvp.Key,
kvp.Value,
0,
DateTimeOffset.UtcNow)).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
}
/// <summary>
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
/// WP-22: Tell for attribute notifications (fire-and-forget, never blocks).