Files
ScadaBridge/src/ScadaLink.CLI/Commands/DebugCommands.cs
T

286 lines
12 KiB
C#

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;
public static class DebugCommands
{
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("debug") { Description = "Runtime debugging" };
command.Add(BuildSnapshot(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildStream(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildSnapshot(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("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, urlOption, formatOption, usernameOption, passwordOption,
new DebugSnapshotCommand(result.GetValue(idOption)));
});
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 config = CliConfig.Load();
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
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;
}
if (!CommandHelpers.IsValidManagementUrl(url))
{
OutputFormatter.WriteError(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
return 1;
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"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();
// CLI-011: CancellationTokenSource owns a WaitHandle and must be disposed.
using 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)
{
// CLI-010: Ctrl+C during connect throws OperationCanceledException — that is
// a graceful user cancellation, not a connection failure.
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
if (failure.IsCancellation)
{
await connection.DisposeAsync();
return failure.ExitCode;
}
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
await connection.DisposeAsync();
return failure.ExitCode;
}
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();
// CLI-012: resolve the exit code from a single authoritative source. A result
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
}
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}");
}
}
}
}