diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..48a526b --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,25 @@ +name: .NET WPF CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release diff --git a/MainWindow.xaml b/MainWindow.xaml index 6f0a056..a4959e1 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -1309,7 +1309,18 @@ - + + + + + + + + + + + + diff --git a/Services/ErrorHandlingService.cs b/Services/ErrorHandlingService.cs index ab206af..cfca526 100644 --- a/Services/ErrorHandlingService.cs +++ b/Services/ErrorHandlingService.cs @@ -257,6 +257,56 @@ public bool ExecuteWithRetry( return fallback; } + public async System.Threading.Tasks.Task ExecuteWithRetryAsync( + Func> func, + string operationName, + ErrorCategory category, + T? fallback = default, + int maxRetries = 3, + int baseDelayMs = 50) + { + var breaker = GetOrCreateCircuitBreaker(operationName); + if (!breaker.AllowAttempt()) + { + ReportError( + ErrorSeverity.Warning, + category, + operationName, + $"Circuit breaker open for '{operationName}'. Returning fallback.", + wasRecovered: false); + return fallback; + } + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + T result = await func().ConfigureAwait(false); + breaker.RecordSuccess(); + return result; + } + catch (Exception ex) when (attempt < maxRetries && IsTransient(ex)) + { + int delay = baseDelayMs * (int)Math.Pow(2, attempt); + await System.Threading.Tasks.Task.Delay(delay).ConfigureAwait(false); + ReportError( + ErrorSeverity.Warning, + category, + operationName, + $"Retry {attempt + 1}/{maxRetries}: {ex.Message}", + wasRecovered: true); + } + catch (Exception ex) + { + breaker.RecordFailure(); + ReportException(category, operationName, ex, wasRecovered: false); + return fallback; + } + } + + return fallback; + } + public IReadOnlyList GetRecentErrors(int count = 50) { return _errorLog diff --git a/Services/HmiServiceContracts.cs b/Services/HmiServiceContracts.cs index 168a8cb..ebbaec6 100644 --- a/Services/HmiServiceContracts.cs +++ b/Services/HmiServiceContracts.cs @@ -4,10 +4,21 @@ namespace XTSPrimeMoverProject.Services { + public enum GatewayConnectionState + { + Connected, + Degraded, + Reconnecting, + Offline + } + public interface IMachineGatewayService { event EventHandler? StateChanged; event EventHandler? LogGenerated; + event EventHandler? ConnectionStateChanged; + + GatewayConnectionState ConnectionState { get; } IReadOnlyList Movers { get; } IReadOnlyList Machines { get; } @@ -29,24 +40,24 @@ public interface IMachineGatewayService IReadOnlyList GetWatchdogStatus(); IReadOnlyList GetOrchestrationSteps(); - bool TryApplyOrchestration(IReadOnlyList stepDefinitions, out string message); - IReadOnlyList PreviewOrchestrationValidation(IReadOnlyList stepDefinitions); - IReadOnlyList GetOrchestrationSafetyGateStatuses(); + Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList stepDefinitions); + Task> PreviewOrchestrationValidationAsync(IReadOnlyList stepDefinitions); + Task> GetOrchestrationSafetyGateStatusesAsync(); } public interface IDataGatewayService { string DatabasePath { get; } - IReadOnlyList GetPartHistory(string trackingNumber); - PartSummaryRecord? GetPartSummary(string trackingNumber); + Task> GetPartHistoryAsync(string trackingNumber); + Task GetPartSummaryAsync(string trackingNumber); - IReadOnlyList GetExportableTables(); - IReadOnlyList GetAllTables(); - IReadOnlyList GetTableColumns(string tableName); - int GetTableRowCount(string tableName); - IReadOnlyList> GetTableRows(string tableName, int maxRows = 500); - string ExportTableToCsv(string tableName, string? exportDirectory = null); - string GetDefaultExportDirectory(); + Task> GetExportableTablesAsync(); + Task> GetAllTablesAsync(); + Task> GetTableColumnsAsync(string tableName); + Task GetTableRowCountAsync(string tableName); + Task>> GetTableRowsAsync(string tableName, int maxRows = 500); + Task ExportTableToCsvAsync(string tableName, string? exportDirectory = null); + Task GetDefaultExportDirectoryAsync(); } } diff --git a/Services/LocalSimulationServiceGateway.cs b/Services/LocalSimulationServiceGateway.cs index f912dd6..f69825a 100644 --- a/Services/LocalSimulationServiceGateway.cs +++ b/Services/LocalSimulationServiceGateway.cs @@ -26,6 +26,9 @@ public LocalSimulationServiceGateway(XTSSimulationEngine engine) public event EventHandler? StateChanged; public event EventHandler? LogGenerated; + public event EventHandler? ConnectionStateChanged { add { } remove { } } + + public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected; public IReadOnlyList Movers => _engine.Movers; public IReadOnlyList Machines => _engine.Machines; @@ -106,115 +109,115 @@ public IReadOnlyList GetOrchestrationSteps() fallback: Array.Empty())!; } - public bool TryApplyOrchestration(IReadOnlyList stepDefinitions, out string message) + public Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList stepDefinitions) { try { - return _engine.TryApplyOrchestration(stepDefinitions, out message); + bool success = _engine.TryApplyOrchestration(stepDefinitions, out string msg); + return Task.FromResult((success, msg)); } catch (Exception ex) { _errorHandler.ReportException(ErrorCategory.Gateway, "LocalGateway.TryApplyOrchestration", ex); - message = $"Gateway error: {ex.Message}"; - return false; + return Task.FromResult((false, $"Gateway error: {ex.Message}")); } } - public IReadOnlyList PreviewOrchestrationValidation(IReadOnlyList stepDefinitions) + public Task> PreviewOrchestrationValidationAsync(IReadOnlyList stepDefinitions) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.PreviewOrchestrationValidation(stepDefinitions), "LocalGateway.PreviewOrchestrationValidation", ErrorCategory.Gateway, - fallback: new List { "Validation unavailable due to gateway error." })!; + fallback: new List { "Validation unavailable due to gateway error." })!); } - public IReadOnlyList GetOrchestrationSafetyGateStatuses() + public Task> GetOrchestrationSafetyGateStatusesAsync() { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetOrchestrationSafetyGateStatuses(), "LocalGateway.GetOrchestrationSafetyGateStatuses", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: Array.Empty())!); } // --- IDataGatewayService --- public string DatabasePath => _engine.DatabasePath; - public IReadOnlyList GetPartHistory(string trackingNumber) + public Task> GetPartHistoryAsync(string trackingNumber) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetPartHistory(trackingNumber), "LocalGateway.GetPartHistory", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: Array.Empty())!); } - public PartSummaryRecord? GetPartSummary(string trackingNumber) + public Task GetPartSummaryAsync(string trackingNumber) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetPartSummary(trackingNumber), "LocalGateway.GetPartSummary", ErrorCategory.Gateway, - fallback: null); + fallback: null)); } - public IReadOnlyList GetExportableTables() + public Task> GetExportableTablesAsync() { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetExportableTables(), "LocalGateway.GetExportableTables", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: Array.Empty())!); } - public IReadOnlyList GetAllTables() + public Task> GetAllTablesAsync() { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetAllTables(), "LocalGateway.GetAllTables", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: Array.Empty())!); } - public IReadOnlyList GetTableColumns(string tableName) + public Task> GetTableColumnsAsync(string tableName) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetTableColumns(tableName), "LocalGateway.GetTableColumns", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: Array.Empty())!); } - public int GetTableRowCount(string tableName) + public Task GetTableRowCountAsync(string tableName) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetTableRowCount(tableName), "LocalGateway.GetTableRowCount", ErrorCategory.Gateway, - fallback: 0); + fallback: 0)); } - public IReadOnlyList> GetTableRows(string tableName, int maxRows = 500) + public Task>> GetTableRowsAsync(string tableName, int maxRows = 500) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.GetTableRows(tableName, maxRows), "LocalGateway.GetTableRows", ErrorCategory.Gateway, - fallback: Array.Empty>())!; + fallback: Array.Empty>())!); } - public string ExportTableToCsv(string tableName, string? exportDirectory = null) + public Task ExportTableToCsvAsync(string tableName, string? exportDirectory = null) { - return _errorHandler.ExecuteWithRetry( + return Task.FromResult(_errorHandler.ExecuteWithRetry( () => _engine.ExportTableToCsv(tableName, exportDirectory), "LocalGateway.ExportTableToCsv", ErrorCategory.Gateway, - fallback: string.Empty)!; + fallback: string.Empty)!); } - public string GetDefaultExportDirectory() => _engine.GetDefaultExportDirectory(); + public Task GetDefaultExportDirectoryAsync() => Task.FromResult(_engine.GetDefaultExportDirectory()); // --- Event forwarding --- diff --git a/Services/RemoteTwinCatMock/RemoteTwinCatMachineGatewayMock.cs b/Services/RemoteTwinCatMock/RemoteTwinCatMachineGatewayMock.cs index 1af6776..a5ff68c 100644 --- a/Services/RemoteTwinCatMock/RemoteTwinCatMachineGatewayMock.cs +++ b/Services/RemoteTwinCatMock/RemoteTwinCatMachineGatewayMock.cs @@ -31,6 +31,13 @@ public RemoteTwinCatMachineGatewayMock(IMachineGatewayService inner, int command public event EventHandler? StateChanged; public event EventHandler? LogGenerated; + public event EventHandler? ConnectionStateChanged + { + add => _inner.ConnectionStateChanged += value; + remove => _inner.ConnectionStateChanged -= value; + } + + public GatewayConnectionState ConnectionState => _inner.ConnectionState; public IReadOnlyList Movers => _inner.Movers; public IReadOnlyList Machines => _inner.Machines; @@ -83,37 +90,47 @@ public IReadOnlyList GetOrchestrationSteps() fallback: Array.Empty())!; } - public bool TryApplyOrchestration(IReadOnlyList stepDefinitions, out string message) + public async Task<(bool Success, string Message)> TryApplyOrchestrationAsync(IReadOnlyList stepDefinitions) { try { - SimulateNetworkLatencySync(); - return _inner.TryApplyOrchestration(stepDefinitions, out message); + if (_commandLatencyMs > 0) + { + await Task.Delay(_commandLatencyMs).ConfigureAwait(false); + } + return await _inner.TryApplyOrchestrationAsync(stepDefinitions).ConfigureAwait(false); } catch (Exception ex) { _errorHandler.ReportException(ErrorCategory.Gateway, "RemoteMock.TryApplyOrchestration", ex); - message = $"Remote gateway error: {ex.Message}"; - return false; + return (false, $"Remote gateway error: {ex.Message}"); } } - public IReadOnlyList PreviewOrchestrationValidation(IReadOnlyList stepDefinitions) + public async Task> PreviewOrchestrationValidationAsync(IReadOnlyList stepDefinitions) { - return _errorHandler.ExecuteWithRetry( - () => _inner.PreviewOrchestrationValidation(stepDefinitions), + if (_commandLatencyMs > 0) + { + await Task.Delay(_commandLatencyMs).ConfigureAwait(false); + } + return await _errorHandler.ExecuteWithRetryAsync( + async () => await _inner.PreviewOrchestrationValidationAsync(stepDefinitions), "RemoteMock.PreviewOrchestrationValidation", ErrorCategory.Gateway, - fallback: new List { "Validation unavailable due to remote gateway error." })!; + fallback: new List { "Validation unavailable due to remote gateway error." }).ConfigureAwait(false) ?? new List(); } - public IReadOnlyList GetOrchestrationSafetyGateStatuses() + public async Task> GetOrchestrationSafetyGateStatusesAsync() { - return _errorHandler.ExecuteWithRetry( - () => _inner.GetOrchestrationSafetyGateStatuses(), + if (_commandLatencyMs > 0) + { + await Task.Delay(_commandLatencyMs).ConfigureAwait(false); + } + return await _errorHandler.ExecuteWithRetryAsync( + async () => await _inner.GetOrchestrationSafetyGateStatusesAsync(), "RemoteMock.GetOrchestrationSafetyGateStatuses", ErrorCategory.Gateway, - fallback: Array.Empty())!; + fallback: (IReadOnlyList)Array.Empty()).ConfigureAwait(false) ?? Array.Empty(); } private void DispatchWithLatency(Action command) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 29cbd8c..a8fa19f 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -34,6 +34,10 @@ public class MainViewModel : INotifyPropertyChanged private string _orchestrationStatus; private string _orchestrationValidationStatus; private string _gatewayModeStatus; + private string _gatewayMode; + private string _gatewayModeIndicatorColor = "Gray"; + private string _connectionState = "Connected"; + private string _connectionStateColor = "Green"; public ObservableCollection Movers { get; } public ObservableCollection Machines { get; } @@ -90,12 +94,30 @@ public string PartHistoryStatus set { _partHistoryStatus = value; OnPropertyChanged(); } } + public string GatewayModeIndicatorColor + { + get => _gatewayModeIndicatorColor; + private set { _gatewayModeIndicatorColor = value; OnPropertyChanged(); } + } + public string PartHistorySummary { get => _partHistorySummary; set { _partHistorySummary = value; OnPropertyChanged(); } } + public string ConnectionState + { + get => _connectionState; + private set { _connectionState = value; OnPropertyChanged(); } + } + + public string ConnectionStateColor + { + get => _connectionStateColor; + private set { _connectionStateColor = value; OnPropertyChanged(); } + } + public string SelectedExportTable { get => _selectedExportTable; @@ -203,8 +225,14 @@ public MainViewModel(IMachineGatewayService machine, IDataGatewayService data, s _gatewayModeStatus = string.IsNullOrWhiteSpace(gatewayModeStatus) ? "Machine Gateway: Local" : gatewayModeStatus; + + _gatewayMode = _gatewayModeStatus.Contains("Remote") ? "Remote" : "Local"; + _gatewayModeIndicatorColor = _gatewayMode == "Remote" ? "Orange" : "LightBlue"; + _machine.StateChanged += OnEngineStateChanged; _machine.LogGenerated += OnEngineLogGenerated; + _machine.ConnectionStateChanged += OnMachineConnectionStateChanged; + UpdateConnectionState(_machine.ConnectionState); _statusText = "SYSTEM STOPPED"; _partHistoryTrackingNumber = string.Empty; @@ -276,47 +304,63 @@ private void InitializeViewModels() } } - private void LoadExportTables() + private async void LoadExportTables() { - ExportTables.Clear(); - foreach (var table in _data.GetExportableTables()) + try { - ExportTables.Add(table); - } + var tables = await _data.GetExportableTablesAsync(); + ExportTables.Clear(); + foreach (var table in tables) + { + ExportTables.Add(table); + } - if (ExportTables.Count > 0) + if (ExportTables.Count > 0) + { + SelectedExportTable = ExportTables[0]; + } + } + catch (Exception ex) { - SelectedExportTable = ExportTables[0]; + _errorHandler.ReportException(ErrorCategory.ViewModel, "LoadExportTables", ex); } } - private void LoadDbTables() + private async void LoadDbTables() { - DbTables.Clear(); - foreach (var table in _data.GetAllTables()) + try { - DbTables.Add(table); - } + var tables = await _data.GetAllTablesAsync(); + DbTables.Clear(); + foreach (var table in tables) + { + DbTables.Add(table); + } - if (DbTables.Count == 0) - { - SelectedDbTable = string.Empty; - DbTableRowsView = CreateEmptyDbTableView(); - DbTableStatus = "No tables found in the runtime database."; - return; - } + if (DbTables.Count == 0) + { + SelectedDbTable = string.Empty; + DbTableRowsView = CreateEmptyDbTableView(); + DbTableStatus = "No tables found in the runtime database."; + return; + } - if (string.IsNullOrWhiteSpace(SelectedDbTable) || !DbTables.Contains(SelectedDbTable)) - { - SelectedDbTable = DbTables[0]; + if (string.IsNullOrWhiteSpace(SelectedDbTable) || !DbTables.Contains(SelectedDbTable)) + { + SelectedDbTable = DbTables[0]; + } + else + { + await LoadSelectedDbTableRowsAsync(); + } } - else + catch (Exception ex) { - LoadSelectedDbTableRows(); + _errorHandler.ReportException(ErrorCategory.ViewModel, "LoadDbTables", ex); } } - private void LoadSelectedDbTableRows() + private async System.Threading.Tasks.Task LoadSelectedDbTableRowsAsync() { if (string.IsNullOrWhiteSpace(SelectedDbTable)) { @@ -327,9 +371,9 @@ private void LoadSelectedDbTableRows() try { - var columns = _data.GetTableColumns(SelectedDbTable); - int totalRows = _data.GetTableRowCount(SelectedDbTable); - var rows = _data.GetTableRows(SelectedDbTable, 500); + var columns = await _data.GetTableColumnsAsync(SelectedDbTable); + int totalRows = await _data.GetTableRowCountAsync(SelectedDbTable); + var rows = await _data.GetTableRowsAsync(SelectedDbTable, 500); var table = new DataTable(SelectedDbTable); foreach (var col in columns) @@ -354,12 +398,17 @@ private void LoadSelectedDbTableRows() } catch (Exception ex) { - _errorHandler.ReportException(ErrorCategory.ViewModel, "MainVM.LoadSelectedDbTableRows", ex); + _errorHandler.ReportException(ErrorCategory.ViewModel, "LoadSelectedDbTableRowsAsync", ex); DbTableRowsView = CreateEmptyDbTableView(); DbTableStatus = $"DB table validation/load failed: {ex.Message}"; } } + private void LoadSelectedDbTableRows() + { + _ = LoadSelectedDbTableRowsAsync(); + } + private static DataView CreateEmptyDbTableView() { var t = new DataTable("DbTableRows"); @@ -451,23 +500,24 @@ private void ReindexOrchestrationSteps() } } - private void ApplyOrchestrationFromHmi() + private async void ApplyOrchestrationFromHmi() { try { var stepDefs = BuildStepDefinitionsFromEditor(); - if (_machine.TryApplyOrchestration(stepDefs, out var message)) + var result = await _machine.TryApplyOrchestrationAsync(stepDefs); + if (result.Success) { - OrchestrationStatus = message; + OrchestrationStatus = result.Message; OrchestrationValidationStatus = "Apply successful."; LoadOrchestrationSteps(); } else { - OrchestrationStatus = message; + OrchestrationStatus = result.Message; } - RefreshSafetyGates(); + await RefreshSafetyGatesAsync(); } catch (Exception ex) { @@ -476,10 +526,10 @@ private void ApplyOrchestrationFromHmi() } } - private void PreviewOrchestrationValidation() + private async void PreviewOrchestrationValidation() { var stepDefs = BuildStepDefinitionsFromEditor(); - var errors = _machine.PreviewOrchestrationValidation(stepDefs); + var errors = await _machine.PreviewOrchestrationValidationAsync(stepDefs); if (errors.Count == 0) { OrchestrationValidationStatus = "Validation OK: no rule violations."; @@ -504,10 +554,10 @@ private List BuildStepDefinitionsFromEditor() .ToList(); } - private void RefreshSafetyGates() + private async System.Threading.Tasks.Task RefreshSafetyGatesAsync() { SafetyGates.Clear(); - foreach (var gate in _machine.GetOrchestrationSafetyGateStatuses()) + foreach (var gate in await _machine.GetOrchestrationSafetyGateStatusesAsync()) { SafetyGates.Add(new SafetyGateStatusItemViewModel { @@ -518,7 +568,12 @@ private void RefreshSafetyGates() } } - private void InspectPartHistory() + private void RefreshSafetyGates() + { + _ = RefreshSafetyGatesAsync(); + } + + private async void InspectPartHistory() { string tracking = PartHistoryTrackingNumber?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(tracking)) @@ -532,13 +587,13 @@ private void InspectPartHistory() try { PartHistoryEvents.Clear(); - var events = _data.GetPartHistory(tracking); + var events = await _data.GetPartHistoryAsync(tracking); foreach (var item in events) { PartHistoryEvents.Add(item); } - var summary = _data.GetPartSummary(tracking); + var summary = await _data.GetPartSummaryAsync(tracking); if (summary == null) { PartHistoryStatus = $"No records found for {tracking}."; @@ -565,12 +620,12 @@ private async void ExportCsv() } string tableName = SelectedExportTable; - string exportDir = _data.GetDefaultExportDirectory(); + string exportDir = await _data.GetDefaultExportDirectoryAsync(); CsvExportStatus = $"Exporting {tableName}..."; try { - string filePath = await Task.Run(() => _data.ExportTableToCsv(tableName, exportDir)); + string filePath = await _data.ExportTableToCsvAsync(tableName, exportDir); CsvExportStatus = $"Exported {tableName} -> {filePath}"; } catch (Exception ex) @@ -595,6 +650,29 @@ private void OnEngineLogGenerated(object? sender, string message) } } + private void OnMachineConnectionStateChanged(object? sender, GatewayConnectionState state) + { + if (!_dispatcher.CheckAccess()) + { + _dispatcher.BeginInvoke(() => OnMachineConnectionStateChanged(sender, state)); + return; + } + UpdateConnectionState(state); + } + + private void UpdateConnectionState(GatewayConnectionState state) + { + ConnectionState = state.ToString(); + ConnectionStateColor = state switch + { + GatewayConnectionState.Connected => "Green", + GatewayConnectionState.Degraded => "Orange", + GatewayConnectionState.Reconnecting => "Gold", + GatewayConnectionState.Offline => "Red", + _ => "Gray" + }; + } + private void RefreshWatchdogStatuses() { var latest = _machine.GetWatchdogStatus(); diff --git a/XTSPrimeMoverProject.csproj b/XTSPrimeMoverProject.csproj index a5b1003..7c83c4d 100644 --- a/XTSPrimeMoverProject.csproj +++ b/XTSPrimeMoverProject.csproj @@ -3,6 +3,7 @@ WinExe net10.0-windows + true enable enable true