feat: replace debug view polling with real-time SignalR streaming

The debug view polled every 2s by re-subscribing for full snapshots. Now a
persistent DebugStreamBridgeActor on central subscribes once and receives
incremental Akka stream events from the site, forwarding them to the Blazor
component via callbacks and to the CLI via a new SignalR hub at
/hubs/debug-stream. Adds `debug stream` CLI command with auto-reconnect.
This commit is contained in:
Joseph Doherty
2026-03-21 01:34:53 -04:00
parent d91aa83665
commit fd2e96fea2
15 changed files with 777 additions and 75 deletions

View File

@@ -1,5 +1,9 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI.Commands;
@@ -11,6 +15,7 @@ public static class DebugCommands
var command = new Command("debug") { Description = "Runtime debugging" };
command.Add(BuildSnapshot(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildStream(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
@@ -28,4 +33,230 @@ public static class DebugCommands
});
return cmd;
}
private static Command BuildStream(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("stream") { Description = "Stream live attribute values and alarm states in real-time (Ctrl+C to stop)" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var instanceId = result.GetValue(idOption);
var format = result.GetValue(formatOption) ?? "json";
var config = CliConfig.Load();
var url = result.GetValue(urlOption);
if (string.IsNullOrWhiteSpace(url))
url = config.ManagementUrl;
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.",
"NO_URL");
return 1;
}
var username = result.GetValue(usernameOption);
var password = result.GetValue(passwordOption);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username and --password options.",
"NO_CREDENTIALS");
return 1;
}
return await StreamDebugAsync(url, username, password, instanceId, format);
});
return cmd;
}
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
{
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.Headers.Add("Authorization", $"Basic {credentials}");
})
.WithAutomaticReconnect()
.Build();
var cts = new CancellationTokenSource();
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
var isTable = string.Equals(format, "table", StringComparison.OrdinalIgnoreCase);
// Register event handlers
connection.On<JsonElement>("OnSnapshot", snapshot =>
{
if (isTable)
{
Console.WriteLine("=== Initial Snapshot ===");
PrintSnapshotTable(snapshot);
Console.WriteLine("=== Streaming (Ctrl+C to stop) ===");
}
else
{
var obj = new { type = "snapshot", data = snapshot };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<JsonElement>("OnAttributeChanged", changed =>
{
if (isTable)
{
var name = changed.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
var value = changed.TryGetProperty("value", out var v) ? v.ToString() : "?";
var quality = changed.TryGetProperty("quality", out var q) ? q.GetString() : "?";
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" ATTR {name,-30} {value,-20} {quality,-10} {ts}");
}
else
{
var obj = new { type = "attributeChanged", data = changed };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<JsonElement>("OnAlarmChanged", changed =>
{
if (isTable)
{
var name = changed.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
var state = changed.TryGetProperty("state", out var s) ? s.ToString() : "?";
var priority = changed.TryGetProperty("priority", out var p) ? p.ToString() : "?";
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" ALARM {name,-30} {state,-20} P{priority,-9} {ts}");
}
else
{
var obj = new { type = "alarmChanged", data = changed };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<string>("OnStreamTerminated", reason =>
{
Console.Error.WriteLine($"Stream terminated: {reason}");
exitTcs.TrySetResult(1);
});
connection.Closed += ex =>
{
if (!cts.IsCancellationRequested)
{
Console.Error.WriteLine(ex != null
? $"Connection lost: {ex.Message}"
: "Connection closed.");
}
exitTcs.TrySetResult(cts.IsCancellationRequested ? 0 : 1);
return Task.CompletedTask;
};
connection.Reconnecting += ex =>
{
Console.Error.WriteLine($"Reconnecting... ({ex?.Message})");
return Task.CompletedTask;
};
connection.Reconnected += _ =>
{
Console.Error.WriteLine("Reconnected. Re-subscribing...");
return connection.InvokeAsync("SubscribeInstance", instanceId);
};
// Connect and subscribe
try
{
await connection.StartAsync(cts.Token);
}
catch (Exception ex)
{
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
return 1;
}
try
{
await connection.InvokeAsync("SubscribeInstance", instanceId, cts.Token);
}
catch (Exception ex)
{
OutputFormatter.WriteError($"Subscribe failed: {ex.Message}", "SUBSCRIBE_FAILED");
await connection.DisposeAsync();
return 1;
}
if (isTable)
{
Console.WriteLine($"Connected to instance {instanceId}. Waiting for data...");
}
// Wait for cancellation (Ctrl+C) or stream termination
try
{
await exitTcs.Task.WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
// Ctrl+C — graceful shutdown
}
try
{
await connection.InvokeAsync("UnsubscribeInstance");
}
catch
{
// Best effort
}
await connection.DisposeAsync();
return exitTcs.Task.IsCompletedSuccessfully ? exitTcs.Task.Result : 0;
}
private static void PrintSnapshotTable(JsonElement snapshot)
{
if (snapshot.TryGetProperty("attributeValues", out var attrs) && attrs.ValueKind == JsonValueKind.Array)
{
Console.WriteLine(" Attributes:");
Console.WriteLine($" {"Name",-30} {"Value",-20} {"Quality",-10} Timestamp");
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
foreach (var av in attrs.EnumerateArray())
{
var name = av.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
var value = av.TryGetProperty("value", out var v) ? v.ToString() : "?";
var quality = av.TryGetProperty("quality", out var q) ? q.GetString() : "?";
var ts = av.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" {name,-30} {value,-20} {quality,-10} {ts}");
}
}
if (snapshot.TryGetProperty("alarmStates", out var alarms) && alarms.ValueKind == JsonValueKind.Array)
{
Console.WriteLine(" Alarms:");
Console.WriteLine($" {"Name",-30} {"State",-20} {"Priority",-10} Timestamp");
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
foreach (var al in alarms.EnumerateArray())
{
var name = al.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
var state = al.TryGetProperty("state", out var s) ? s.ToString() : "?";
var priority = al.TryGetProperty("priority", out var p) ? p.ToString() : "?";
var ts = al.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" {name,-30} {state,-20} P{priority,-9} {ts}");
}
}
}
}