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 urlOption, Option formatOption, Option usernameOption, Option 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--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 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(TaskCreationOptions.RunContinuationsAsynchronously); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; var isTable = string.Equals(format, "table", StringComparison.OrdinalIgnoreCase); // Register event handlers connection.On("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("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("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("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}"); } } } }