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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user