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}");
}
}
}
}

View File

@@ -1038,6 +1038,25 @@ scadalink --url <url> debug snapshot --id <int>
The command resolves the instance's site internally and routes the request to the correct site cluster. Returns all attribute values (name, value, quality, timestamp) and alarm states (name, state, priority, timestamp) at the moment the request reaches the site.
#### `debug stream`
Stream live attribute values and alarm state changes in real-time using a SignalR WebSocket connection to the central server's `/hubs/debug-stream` hub. Events are printed as they arrive. Press Ctrl+C to disconnect.
```sh
scadalink --url <url> debug stream --id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Instance ID |
The default JSON format outputs one NDJSON object per event line with a `type` field (`snapshot`, `attributeChanged`, or `alarmChanged`). Table format (`--format table`) shows a formatted initial snapshot followed by streaming rows prefixed with `ATTR` or `ALARM`.
Features:
- Automatic reconnection on connection loss with re-subscribe.
- Works through the Traefik load balancer (WebSocket upgrade proxied natively).
- Requires the `Deployment` role.
---
### `audit-log` — Audit log queries

View File

@@ -11,6 +11,7 @@
<InternalsVisibleTo Include="ScadaLink.CLI.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.5" />
</ItemGroup>
<ItemGroup>