From 8f386a63ed59d48627f60837c491a2fe53e3ef46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:33:44 +0000 Subject: [PATCH 01/10] Fix build errors: Grpc version pinning, namespace resolution, interface-based workflow handles --- MainWindow.xaml.cs | 71 +++- Services/RemoteRestGateway.cs | 270 ++++++++++++++ XTSPrimeMoverProject.csproj | 2 + XTSPrimeMoverProject.slnx | 16 + docker/docker-compose.dev.yml | 345 ++++++++++++++++++ docker/machine-api.Dockerfile | 13 + docker/machine-hmi.Dockerfile | 11 + docker/machine-service.Dockerfile | 14 + docker/machine-worker.Dockerfile | 11 + docker/machine.Dockerfile | 35 ++ docker/master-worker.Dockerfile | 11 + docker/nginx-machine-hmi.conf | 25 ++ docker/nginx-prime-mover-hmi.conf | 23 ++ docker/prime-mover-api.Dockerfile | 13 + docker/prime-mover-hmi.Dockerfile | 11 + docker/prime-mover.Dockerfile | 14 + docker/prometheus.yml | 43 +++ docs/TEMPORAL-ARCHITECTURE.md | 238 ++++++++++++ k8s/ingress/nginx-ingress.yaml | 113 ++++++ k8s/machines/helm/Chart.yaml | 6 + k8s/machines/helm/templates/deployment.yaml | 97 +++++ k8s/machines/helm/templates/service.yaml | 24 ++ k8s/machines/helm/values.yaml | 39 ++ k8s/machines/values-functional-testing.yaml | 5 + k8s/machines/values-laser-welding.yaml | 5 + k8s/machines/values-precision-assembly.yaml | 5 + k8s/machines/values-quality-inspection.yaml | 5 + k8s/monitoring/grafana.yaml | 44 +++ k8s/monitoring/jaeger.yaml | 41 +++ k8s/monitoring/prometheus.yaml | 79 ++++ k8s/namespace.yaml | 6 + k8s/prime-mover/prime-mover-deployment.yaml | 97 +++++ k8s/temporal/temporal-deployment.yaml | 94 +++++ src/desktop-hmi/RemoteRestGateway.cs | 223 +++++++++++ .../Controllers/MachineApiController.cs | 63 ++++ src/machines/MachineApi/Hubs/MachineHub.cs | 58 +++ src/machines/MachineApi/MachineApi.csproj | 13 + src/machines/MachineApi/Program.cs | 35 ++ src/machines/MachineApi/appsettings.json | 13 + .../Controllers/MachineController.cs | 79 ++++ .../GrpcServices/MachineGrpcService.cs | 78 ++++ .../MachineCompletionTracker.cs | 19 + .../MachineService/MachineService.csproj | 14 + .../PlcEngine/MachinePlcEngine.cs | 216 +++++++++++ src/machines/MachineService/Program.cs | 24 ++ src/machines/MachineService/appsettings.json | 20 + src/machines/machine-hmi/index.html | 16 + src/machines/machine-hmi/package.json | 24 ++ src/machines/machine-hmi/src/App.tsx | 63 ++++ .../src/components/AlarmBanner.tsx | 15 + .../machine-hmi/src/components/KpiBar.tsx | 24 ++ .../src/components/RotaryTable.tsx | 54 +++ .../src/components/StationCard.tsx | 39 ++ src/machines/machine-hmi/src/main.tsx | 9 + .../machine-hmi/src/signalr/machineHub.ts | 72 ++++ src/machines/machine-hmi/tsconfig.json | 17 + src/machines/machine-hmi/vite.config.ts | 13 + .../Controllers/MoversController.cs | 37 ++ .../Controllers/PartsController.cs | 30 ++ .../Controllers/RecipesController.cs | 36 ++ .../Controllers/SystemController.cs | 70 ++++ .../PrimeMoverApi/Hubs/SystemHub.cs | 47 +++ .../PrimeMoverApi/PrimeMoverApi.csproj | 14 + src/prime-mover/PrimeMoverApi/Program.cs | 49 +++ .../Services/MachineAggregatorService.cs | 35 ++ .../PrimeMoverApi/appsettings.json | 22 ++ .../Controllers/TrackController.cs | 38 ++ .../GrpcServices/PrimeMoverGrpcService.cs | 58 +++ .../PrimeMoverService.csproj | 15 + src/prime-mover/PrimeMoverService/Program.cs | 20 + .../TrackEngine/XtsTrackEngine.cs | 131 +++++++ .../PrimeMoverService/appsettings.json | 11 + src/prime-mover/prime-mover-hmi/index.html | 16 + src/prime-mover/prime-mover-hmi/package.json | 24 ++ src/prime-mover/prime-mover-hmi/src/App.tsx | 57 +++ .../src/components/ControlBar.tsx | 50 +++ .../src/components/KpiDashboard.tsx | 31 ++ .../src/components/MachineMiniHmi.tsx | 58 +++ .../src/components/OvalTrack.tsx | 101 +++++ src/prime-mover/prime-mover-hmi/src/main.tsx | 7 + .../prime-mover-hmi/src/signalr/systemHub.ts | 88 +++++ src/prime-mover/prime-mover-hmi/tsconfig.json | 17 + .../prime-mover-hmi/vite.config.ts | 13 + src/shared/Contracts/Dtos/MachineDto.cs | 17 + src/shared/Contracts/Dtos/MoverDto.cs | 10 + src/shared/Contracts/Dtos/PartDto.cs | 13 + src/shared/Contracts/Dtos/RobotDto.cs | 10 + src/shared/Contracts/Dtos/StationDto.cs | 11 + src/shared/Contracts/Dtos/SystemStatusDto.cs | 29 ++ .../Contracts/Protos/machine_service.proto | 84 +++++ src/shared/Contracts/Protos/prime_mover.proto | 67 ++++ .../Workflows/IMachineCycleWorkflow.cs | 37 ++ .../Workflows/IPartLifecycleWorkflow.cs | 58 +++ .../Workflows/IRobotTransferWorkflow.cs | 30 ++ .../Workflows/IXTSPrimeMoverWorkflow.cs | 49 +++ src/shared/Contracts/XtsContracts.csproj | 16 + src/shared/Database/V1__initial_schema.sql | 96 +++++ .../Activities/MachineActivities.cs | 61 ++++ .../MachineCycleWorkflowStarter.cs | 47 +++ .../machine-worker/MachineWorker.csproj | 14 + src/temporal/machine-worker/Program.cs | 30 ++ .../Workflows/MachineCycleWorkflow.cs | 125 +++++++ src/temporal/machine-worker/appsettings.json | 18 + .../Activities/PrimeMoverActivities.cs | 92 +++++ .../master-worker/MasterWorker.csproj | 16 + src/temporal/master-worker/Program.cs | 28 ++ .../Workflows/PartLifecycleWorkflow.cs | 145 ++++++++ .../Workflows/RobotTransferWorkflow.cs | 66 ++++ .../Workflows/XTSPrimeMoverWorkflow.cs | 157 ++++++++ src/temporal/master-worker/appsettings.json | 18 + 110 files changed, 5433 insertions(+), 13 deletions(-) create mode 100644 Services/RemoteRestGateway.cs create mode 100644 docker/docker-compose.dev.yml create mode 100644 docker/machine-api.Dockerfile create mode 100644 docker/machine-hmi.Dockerfile create mode 100644 docker/machine-service.Dockerfile create mode 100644 docker/machine-worker.Dockerfile create mode 100644 docker/machine.Dockerfile create mode 100644 docker/master-worker.Dockerfile create mode 100644 docker/nginx-machine-hmi.conf create mode 100644 docker/nginx-prime-mover-hmi.conf create mode 100644 docker/prime-mover-api.Dockerfile create mode 100644 docker/prime-mover-hmi.Dockerfile create mode 100644 docker/prime-mover.Dockerfile create mode 100644 docker/prometheus.yml create mode 100644 docs/TEMPORAL-ARCHITECTURE.md create mode 100644 k8s/ingress/nginx-ingress.yaml create mode 100644 k8s/machines/helm/Chart.yaml create mode 100644 k8s/machines/helm/templates/deployment.yaml create mode 100644 k8s/machines/helm/templates/service.yaml create mode 100644 k8s/machines/helm/values.yaml create mode 100644 k8s/machines/values-functional-testing.yaml create mode 100644 k8s/machines/values-laser-welding.yaml create mode 100644 k8s/machines/values-precision-assembly.yaml create mode 100644 k8s/machines/values-quality-inspection.yaml create mode 100644 k8s/monitoring/grafana.yaml create mode 100644 k8s/monitoring/jaeger.yaml create mode 100644 k8s/monitoring/prometheus.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/prime-mover/prime-mover-deployment.yaml create mode 100644 k8s/temporal/temporal-deployment.yaml create mode 100644 src/desktop-hmi/RemoteRestGateway.cs create mode 100644 src/machines/MachineApi/Controllers/MachineApiController.cs create mode 100644 src/machines/MachineApi/Hubs/MachineHub.cs create mode 100644 src/machines/MachineApi/MachineApi.csproj create mode 100644 src/machines/MachineApi/Program.cs create mode 100644 src/machines/MachineApi/appsettings.json create mode 100644 src/machines/MachineService/Controllers/MachineController.cs create mode 100644 src/machines/MachineService/GrpcServices/MachineGrpcService.cs create mode 100644 src/machines/MachineService/MachineCompletionTracker.cs create mode 100644 src/machines/MachineService/MachineService.csproj create mode 100644 src/machines/MachineService/PlcEngine/MachinePlcEngine.cs create mode 100644 src/machines/MachineService/Program.cs create mode 100644 src/machines/MachineService/appsettings.json create mode 100644 src/machines/machine-hmi/index.html create mode 100644 src/machines/machine-hmi/package.json create mode 100644 src/machines/machine-hmi/src/App.tsx create mode 100644 src/machines/machine-hmi/src/components/AlarmBanner.tsx create mode 100644 src/machines/machine-hmi/src/components/KpiBar.tsx create mode 100644 src/machines/machine-hmi/src/components/RotaryTable.tsx create mode 100644 src/machines/machine-hmi/src/components/StationCard.tsx create mode 100644 src/machines/machine-hmi/src/main.tsx create mode 100644 src/machines/machine-hmi/src/signalr/machineHub.ts create mode 100644 src/machines/machine-hmi/tsconfig.json create mode 100644 src/machines/machine-hmi/vite.config.ts create mode 100644 src/prime-mover/PrimeMoverApi/Controllers/MoversController.cs create mode 100644 src/prime-mover/PrimeMoverApi/Controllers/PartsController.cs create mode 100644 src/prime-mover/PrimeMoverApi/Controllers/RecipesController.cs create mode 100644 src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs create mode 100644 src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs create mode 100644 src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj create mode 100644 src/prime-mover/PrimeMoverApi/Program.cs create mode 100644 src/prime-mover/PrimeMoverApi/Services/MachineAggregatorService.cs create mode 100644 src/prime-mover/PrimeMoverApi/appsettings.json create mode 100644 src/prime-mover/PrimeMoverService/Controllers/TrackController.cs create mode 100644 src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs create mode 100644 src/prime-mover/PrimeMoverService/PrimeMoverService.csproj create mode 100644 src/prime-mover/PrimeMoverService/Program.cs create mode 100644 src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs create mode 100644 src/prime-mover/PrimeMoverService/appsettings.json create mode 100644 src/prime-mover/prime-mover-hmi/index.html create mode 100644 src/prime-mover/prime-mover-hmi/package.json create mode 100644 src/prime-mover/prime-mover-hmi/src/App.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/components/ControlBar.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/components/KpiDashboard.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/components/MachineMiniHmi.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/components/OvalTrack.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/main.tsx create mode 100644 src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts create mode 100644 src/prime-mover/prime-mover-hmi/tsconfig.json create mode 100644 src/prime-mover/prime-mover-hmi/vite.config.ts create mode 100644 src/shared/Contracts/Dtos/MachineDto.cs create mode 100644 src/shared/Contracts/Dtos/MoverDto.cs create mode 100644 src/shared/Contracts/Dtos/PartDto.cs create mode 100644 src/shared/Contracts/Dtos/RobotDto.cs create mode 100644 src/shared/Contracts/Dtos/StationDto.cs create mode 100644 src/shared/Contracts/Dtos/SystemStatusDto.cs create mode 100644 src/shared/Contracts/Protos/machine_service.proto create mode 100644 src/shared/Contracts/Protos/prime_mover.proto create mode 100644 src/shared/Contracts/Workflows/IMachineCycleWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IPartLifecycleWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IRobotTransferWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs create mode 100644 src/shared/Contracts/XtsContracts.csproj create mode 100644 src/shared/Database/V1__initial_schema.sql create mode 100644 src/temporal/machine-worker/Activities/MachineActivities.cs create mode 100644 src/temporal/machine-worker/MachineCycleWorkflowStarter.cs create mode 100644 src/temporal/machine-worker/MachineWorker.csproj create mode 100644 src/temporal/machine-worker/Program.cs create mode 100644 src/temporal/machine-worker/Workflows/MachineCycleWorkflow.cs create mode 100644 src/temporal/machine-worker/appsettings.json create mode 100644 src/temporal/master-worker/Activities/PrimeMoverActivities.cs create mode 100644 src/temporal/master-worker/MasterWorker.csproj create mode 100644 src/temporal/master-worker/Program.cs create mode 100644 src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs create mode 100644 src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs create mode 100644 src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs create mode 100644 src/temporal/master-worker/appsettings.json diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 7dfce49..8a56527 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -22,29 +22,62 @@ namespace XTSPrimeMoverProject public partial class MainWindow : Window { private Services.XTSSimulationEngine? _engine; - + private Services.RemoteRestGateway? _remoteGateway; + + /// + /// Gateway mode is selected by the environment variable XTS_GATEWAY_MODE: + /// "local" — in-process engine (original behaviour, default) + /// "remote-mock" — local engine wrapped in RemoteTwinCatMachineGatewayMock (adds latency) + /// "remote" — connects to the Temporal/K8s Prime Mover API via REST + SignalR + /// The URL for remote mode is read from XTS_PRIME_MOVER_API_URL or defaults to http://localhost:8082. + /// public MainWindow() { InitializeComponent(); try { - var engine = new Services.XTSSimulationEngine(); - _engine = engine; - var localGateway = new Services.LocalSimulationServiceGateway(engine); + string gatewayMode = ReadAppStringSetting("GatewayMode", + Environment.GetEnvironmentVariable("XTS_GATEWAY_MODE") ?? "local"); - bool useRemoteMock = ReadAppBoolSetting("UseRemoteTwinCatMachineGatewayMock", defaultValue: true); - int latencyMs = ReadAppIntSetting("RemoteTwinCatMachineGatewayMockLatencyMs", defaultValue: 40); + Services.IMachineGatewayService machineGateway; + Services.IDataGatewayService dataGateway; + string gatewayModeStatus; - Services.IMachineGatewayService machineGateway = useRemoteMock - ? new RemoteTwinCatMachineGatewayMock(localGateway, commandLatencyMs: latencyMs) - : localGateway; + if (gatewayMode.Equals("remote", StringComparison.OrdinalIgnoreCase)) + { + // ── Remote mode: WPF becomes a thick REST/SignalR client ────────── + string apiUrl = ReadAppStringSetting("PrimeMoverApiUrl", + Environment.GetEnvironmentVariable("XTS_PRIME_MOVER_API_URL") ?? "http://localhost:8082"); + + var remoteGateway = new Services.RemoteRestGateway(apiUrl); + _remoteGateway = remoteGateway; + machineGateway = remoteGateway; + dataGateway = remoteGateway; + gatewayModeStatus = $"Machine Gateway: Remote Temporal/K8s — {apiUrl}"; + } + else + { + // ── Local mode: in-process engine (original behaviour) ──────────── + var engine = new Services.XTSSimulationEngine(); + _engine = engine; + var localGateway = new Services.LocalSimulationServiceGateway(engine); - string gatewayModeStatus = useRemoteMock - ? $"Machine Gateway: Remote TwinCAT Mock ({latencyMs} ms)" - : "Machine Gateway: Local In-Process"; + bool useRemoteMock = gatewayMode.Equals("remote-mock", StringComparison.OrdinalIgnoreCase) + || ReadAppBoolSetting("UseRemoteTwinCatMachineGatewayMock", defaultValue: true); + int latencyMs = ReadAppIntSetting("RemoteTwinCatMachineGatewayMockLatencyMs", defaultValue: 40); + + machineGateway = useRemoteMock + ? new RemoteTwinCatMachineGatewayMock(localGateway, commandLatencyMs: latencyMs) + : localGateway; + + gatewayModeStatus = useRemoteMock + ? $"Machine Gateway: Remote TwinCAT Mock ({latencyMs} ms)" + : "Machine Gateway: Local In-Process"; + + dataGateway = localGateway; + } - var dataGateway = (Services.IDataGatewayService)localGateway; var viewModel = new MainViewModel(machineGateway, dataGateway, gatewayModeStatus); DataContext = viewModel; @@ -70,6 +103,8 @@ private void OnWindowClosed(object? sender, EventArgs e) { _engine?.Dispose(); _engine = null; + _remoteGateway?.Dispose(); + _remoteGateway = null; } private void OnExecutionLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -105,6 +140,16 @@ private static int ReadAppIntSetting(string key, int defaultValue) return defaultValue; } + + private static string ReadAppStringSetting(string key, string defaultValue) + { + if (Application.Current?.Resources[key] is string value && !string.IsNullOrEmpty(value)) + { + return value; + } + + return defaultValue; + } } public class MoverPositionConverter : IMultiValueConverter diff --git a/Services/RemoteRestGateway.cs b/Services/RemoteRestGateway.cs new file mode 100644 index 0000000..3da6388 --- /dev/null +++ b/Services/RemoteRestGateway.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using XTSPrimeMoverProject.Models; + +namespace XTSPrimeMoverProject.Services +{ + /// + /// REST + SignalR gateway that connects the WPF desktop client to the Prime Mover API + /// running in Kubernetes / Docker Compose. Replaces LocalSimulationServiceGateway when + /// the engine runs remotely (XTS_GATEWAY_MODE=remote in environment or app config). + /// + /// All ViewModels and XAML bindings work identically regardless of which gateway is active — + /// only this class needs to change for remote mode. + /// + public sealed class RemoteRestGateway : IMachineGatewayService, IDataGatewayService, IDisposable + { + private readonly HttpClient _http; + private readonly HubConnection _hub; + private readonly ErrorHandlingService _errorHandler = ErrorHandlingService.Instance; + private readonly string _baseUrl; + + // Cached state — updated from SignalR push at ~10 Hz + private readonly object _stateLock = new(); + private List _movers = new(); + private List _machines = new(); + private List _robots = new(); + private bool _isRunning; + private int _totalParts; + private int _goodParts; + private int _badParts; + private int _entered; + + // ── IMachineGatewayService ─────────────────────────────────────────── + + public event EventHandler? StateChanged; + public event EventHandler? LogGenerated; + + public IReadOnlyList Movers { get { lock (_stateLock) return _movers; } } + public IReadOnlyList Machines { get { lock (_stateLock) return _machines; } } + public IReadOnlyList Robots { get { lock (_stateLock) return _robots; } } + public bool IsRunning { get { lock (_stateLock) return _isRunning; } } + public bool EntryZoneBlink => false; // driven by server-side engine + public bool ExitZoneBlink => false; + public int TotalPartsProduced { get { lock (_stateLock) return _totalParts; } } + public int GoodPartsCount { get { lock (_stateLock) return _goodParts; } } + public int BadPartsCount { get { lock (_stateLock) return _badParts; } } + public int PrimeMoverEnteredCount { get { lock (_stateLock) return _entered; } } + public int PrimeMoverExitedCount { get { lock (_stateLock) return _goodParts + _badParts; } } + + public RemoteRestGateway(string baseUrl = "http://localhost:8082") + { + _baseUrl = baseUrl.TrimEnd('/'); + _http = new HttpClient { BaseAddress = new Uri(_baseUrl + "/") }; + + _hub = new HubConnectionBuilder() + .WithUrl($"{_baseUrl}/hubs/system") + .WithAutomaticReconnect() + .Build(); + + _hub.On("SystemStatusUpdate", OnStatusUpdate); + _hub.On("LogMessage", msg => LogGenerated?.Invoke(this, $"[Remote] {msg}")); + + _hub.Reconnecting += _ => + { + LogGenerated?.Invoke(this, "[RemoteGateway] SignalR reconnecting…"); + return Task.CompletedTask; + }; + _hub.Reconnected += _ => + { + LogGenerated?.Invoke(this, "[RemoteGateway] SignalR reconnected."); + return Task.CompletedTask; + }; + + _ = ConnectAsync(); + } + + private async Task ConnectAsync() + { + try + { + await _hub.StartAsync(); + LogGenerated?.Invoke(this, $"[RemoteGateway] Connected to {_baseUrl}/hubs/system"); + } + catch (Exception ex) + { + _errorHandler.ReportException(ErrorCategory.Gateway, "RemoteGateway.Connect", ex, wasRecovered: true); + LogGenerated?.Invoke(this, $"[RemoteGateway] Could not connect to SignalR hub: {ex.Message}"); + } + } + + private void OnStatusUpdate(SystemStatusPayload payload) + { + lock (_stateLock) + { + _isRunning = payload.IsRunning; + _totalParts = payload.TotalPartsProduced; + _goodParts = payload.GoodPartsCount; + _badParts = payload.BadPartsCount; + _entered = payload.PrimeMoverEnteredCount; + _movers = MapMovers(payload.Movers ?? new List()); + _machines = MapMachines(payload.Machines ?? new List()); + _robots = new List(); + } + StateChanged?.Invoke(this, EventArgs.Empty); + } + + // ── Commands ───────────────────────────────────────────────────────── + + public void Start() => + FireAndForget(() => _http.PostAsync("api/system/start", null), "Start"); + + public void Stop() => + FireAndForget(() => _http.PostAsync("api/system/stop", null), "Stop"); + + public void Reset() => + FireAndForget(() => _http.PostAsync("api/system/reset", null), "Reset"); + + public void SetSimulationSpeed(double speed) => + FireAndForget(() => _http.PostAsync($"api/system/set-speed?factor={speed:F2}", null), "SetSpeed"); + + // ── Watchdog / Orchestration (not available in remote mode) ────────── + + public IReadOnlyList GetWatchdogStatus() => + Array.Empty(); + + public IReadOnlyList GetOrchestrationSteps() => + Array.Empty(); + + public bool TryApplyOrchestration(IReadOnlyList stepDefinitions, out string message) + { + message = "Orchestration changes require direct API call in remote mode. Use POST /api/recipes."; + return false; + } + + public IReadOnlyList PreviewOrchestrationValidation(IReadOnlyList stepDefinitions) => + new[] { "Validation not supported in remote gateway mode — use the Prime Mover web HMI." }; + + public IReadOnlyList GetOrchestrationSafetyGateStatuses() => + Array.Empty(); + + // ── IDataGatewayService ─────────────────────────────────────────────── + + public string DatabasePath => $"(Remote PostgreSQL via {_baseUrl})"; + + public IReadOnlyList GetPartHistory(string trackingNumber) + { + try + { + var resp = _http.GetAsync($"api/parts/{Uri.EscapeDataString(trackingNumber)}").GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) return Array.Empty(); + // Response comes from Temporal workflow query; surface as simple list for now + return Array.Empty(); + } + catch (Exception ex) + { + _errorHandler.ReportException(ErrorCategory.Gateway, "RemoteGateway.GetPartHistory", ex, wasRecovered: true); + return Array.Empty(); + } + } + + public PartSummaryRecord? GetPartSummary(string trackingNumber) => null; + + public IReadOnlyList GetExportableTables() => + new[] { "(Use Prime Mover web HMI for CSV exports in remote mode)" }; + + public IReadOnlyList GetAllTables() => Array.Empty(); + public IReadOnlyList GetTableColumns(string tableName) => Array.Empty(); + public int GetTableRowCount(string tableName) => 0; + + public IReadOnlyList> GetTableRows(string tableName, int maxRows = 500) => + Array.Empty>(); + + public string ExportTableToCsv(string tableName, string? exportDirectory = null) => string.Empty; + public string GetDefaultExportDirectory() => string.Empty; + + // ── Mapping helpers ─────────────────────────────────────────────────── + + private static List MapMovers(List dtos) => + dtos.ConvertAll(d => + { + var m = new Mover(d.MoverId); + m.Position = d.Position; + m.Velocity = d.Velocity; + m.State = Enum.TryParse(d.State, true, out var s) ? s : MoverState.Idle; + return m; + }); + + private static List MapMachines(List dtos) => + dtos.ConvertAll(d => + { + var type = Enum.TryParse(d.Type, true, out var t) ? t : MachineType.LaserWelding; + var m = new Machine(d.MachineId, d.Name ?? $"Machine-{d.MachineId}", type, 0); + m.IsOperational = d.IsOperational; + m.FaultActive = d.FaultActive; + m.FaultMessage = d.FaultMessage ?? string.Empty; + m.PartsEnteredCount = d.PartsEnteredCount; + m.PartsExitedCount = d.PartsExitedCount; + m.CurrentStationIndex = d.CurrentStationIndex; + m.IsIndexing = d.IsIndexing; + m.RotaryAngle = d.RotaryAngle; + if (Enum.TryParse(d.SequencerState, true, out var seq)) + m.SequencerState = seq; + return m; + }); + + private void FireAndForget(Func> action, string opName) + { + _ = Task.Run(async () => + { + try + { + var response = await action(); + if (!response.IsSuccessStatusCode) + LogGenerated?.Invoke(this, $"[RemoteGateway] {opName} returned {response.StatusCode}"); + } + catch (Exception ex) + { + _errorHandler.ReportException(ErrorCategory.Gateway, $"RemoteGateway.{opName}", ex, wasRecovered: true); + } + }); + } + + public void Dispose() + { + try { _hub.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(3)); } catch { } + _http.Dispose(); + } + + // ── JSON payload shapes ─────────────────────────────────────────────── + + private class SystemStatusPayload + { + public bool IsRunning { get; set; } + public int TotalPartsProduced { get; set; } + public int GoodPartsCount { get; set; } + public int BadPartsCount { get; set; } + public int PrimeMoverEnteredCount { get; set; } + public List? Movers { get; set; } + public List? Machines { get; set; } + } + + private class RemoteMoverDto + { + public int MoverId { get; set; } + public double Position { get; set; } + public double Velocity { get; set; } + public string State { get; set; } = "Idle"; + } + + private class RemoteMachineDto + { + public int MachineId { get; set; } + public string? Name { get; set; } + public string Type { get; set; } = "LaserWelding"; + public string SequencerState { get; set; } = "Init"; + public bool IsOperational { get; set; } + public bool FaultActive { get; set; } + public string? FaultMessage { get; set; } + public int PartsEnteredCount { get; set; } + public int PartsExitedCount { get; set; } + public int CurrentStationIndex { get; set; } + public bool IsIndexing { get; set; } + public double RotaryAngle { get; set; } + } + } +} diff --git a/XTSPrimeMoverProject.csproj b/XTSPrimeMoverProject.csproj index a5b1003..d5968c6 100644 --- a/XTSPrimeMoverProject.csproj +++ b/XTSPrimeMoverProject.csproj @@ -14,6 +14,8 @@ + + diff --git a/XTSPrimeMoverProject.slnx b/XTSPrimeMoverProject.slnx index 1485d94..5e1b9f0 100644 --- a/XTSPrimeMoverProject.slnx +++ b/XTSPrimeMoverProject.slnx @@ -1,3 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..dad9c0b --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,345 @@ +# XTS Prime Mover — Full Dev Stack +# Brings up all microservices, Temporal, PostgreSQL, Redis, and all HMIs locally. +# Usage: docker compose -f docker/docker-compose.dev.yml up + +version: '3.9' + +networks: + xts-net: + driver: bridge + +volumes: + postgres-data: + redis-data: + temporal-data: + +services: + + # ── Infrastructure ───────────────────────────────────────────────────────── + + postgresql: + image: postgres:16-alpine + environment: + POSTGRES_USER: xts + POSTGRES_PASSWORD: xts_dev_pass + POSTGRES_DB: xts_system + ports: ["5432:5432"] + volumes: ["postgres-data:/var/lib/postgresql/data"] + networks: [xts-net] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xts"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + volumes: ["redis-data:/data"] + networks: [xts-net] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + retries: 5 + + elasticsearch: + image: elasticsearch:8.16.1 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m + ports: ["9200:9200"] + networks: [xts-net] + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'"] + interval: 10s + retries: 10 + + temporal: + image: temporalio/auto-setup:1.25.2 + environment: + - DB=postgresql + - DB_PORT=5432 + - POSTGRES_USER=xts + - POSTGRES_PWD=xts_dev_pass + - POSTGRES_SEEDS=postgresql + - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml + ports: ["7233:7233"] + networks: [xts-net] + depends_on: + postgresql: + condition: service_healthy + + temporal-ui: + image: temporalio/ui:2.31.2 + environment: + - TEMPORAL_ADDRESS=temporal:7233 + - TEMPORAL_CORS_ORIGINS=http://localhost:8088 + ports: ["8088:8080"] + networks: [xts-net] + depends_on: [temporal] + + mosquitto: + image: eclipse-mosquitto:2.0 + ports: ["1883:1883", "9001:9001"] + networks: [xts-net] + volumes: ["./mosquitto.conf:/mosquitto/config/mosquitto.conf"] + + # ── Machine Pods (×4) ────────────────────────────────────────────────────── + + machine-service-0: + build: + context: .. + dockerfile: docker/machine-service.Dockerfile + environment: + Machine__Id: "0" + Machine__Type: "LaserWelding" + ConnectionStrings__Postgres: "Host=postgresql;Database=machine_0_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8080" + ports: ["8090:8080", "9090:9090"] + networks: [xts-net] + depends_on: [postgresql] + + machine-api-0: + build: + context: .. + dockerfile: docker/machine-api.Dockerfile + environment: + Temporal__Address: "temporal:7233" + MachineService__BaseUrl: "http://machine-service-0:8080" + Machine__Id: "0" + ports: ["8091:8081"] + networks: [xts-net] + depends_on: [temporal, machine-service-0] + + machine-hmi-0: + build: + context: .. + dockerfile: docker/machine-hmi.Dockerfile + ports: ["3001:80"] + networks: [xts-net] + depends_on: [machine-api-0] + + machine-service-1: + build: + context: .. + dockerfile: docker/machine-service.Dockerfile + environment: + Machine__Id: "1" + Machine__Type: "PrecisionAssembly" + ConnectionStrings__Postgres: "Host=postgresql;Database=machine_1_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8080" + ports: ["8092:8080", "9092:9090"] + networks: [xts-net] + depends_on: [postgresql] + + machine-api-1: + build: + context: .. + dockerfile: docker/machine-api.Dockerfile + environment: + Temporal__Address: "temporal:7233" + MachineService__BaseUrl: "http://machine-service-1:8080" + Machine__Id: "1" + ports: ["8093:8081"] + networks: [xts-net] + depends_on: [temporal, machine-service-1] + + machine-hmi-1: + build: + context: .. + dockerfile: docker/machine-hmi.Dockerfile + ports: ["3002:80"] + networks: [xts-net] + depends_on: [machine-api-1] + + machine-service-2: + build: + context: .. + dockerfile: docker/machine-service.Dockerfile + environment: + Machine__Id: "2" + Machine__Type: "QualityInspection" + ConnectionStrings__Postgres: "Host=postgresql;Database=machine_2_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8080" + ports: ["8094:8080", "9094:9090"] + networks: [xts-net] + depends_on: [postgresql] + + machine-api-2: + build: + context: .. + dockerfile: docker/machine-api.Dockerfile + environment: + Temporal__Address: "temporal:7233" + MachineService__BaseUrl: "http://machine-service-2:8080" + Machine__Id: "2" + ports: ["8095:8081"] + networks: [xts-net] + depends_on: [temporal, machine-service-2] + + machine-hmi-2: + build: + context: .. + dockerfile: docker/machine-hmi.Dockerfile + ports: ["3003:80"] + networks: [xts-net] + depends_on: [machine-api-2] + + machine-service-3: + build: + context: .. + dockerfile: docker/machine-service.Dockerfile + environment: + Machine__Id: "3" + Machine__Type: "FunctionalTesting" + ConnectionStrings__Postgres: "Host=postgresql;Database=machine_3_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8080" + ports: ["8096:8080", "9096:9090"] + networks: [xts-net] + depends_on: [postgresql] + + machine-api-3: + build: + context: .. + dockerfile: docker/machine-api.Dockerfile + environment: + Temporal__Address: "temporal:7233" + MachineService__BaseUrl: "http://machine-service-3:8080" + Machine__Id: "3" + ports: ["8097:8081"] + networks: [xts-net] + depends_on: [temporal, machine-service-3] + + machine-hmi-3: + build: + context: .. + dockerfile: docker/machine-hmi.Dockerfile + ports: ["3004:80"] + networks: [xts-net] + depends_on: [machine-api-3] + + # ── Machine Temporal Workers (×4) ────────────────────────────────────────── + + machine-worker-0: + build: + context: .. + dockerfile: docker/machine-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + Machine__Id: "0" + Machine__Name: "LaserWelding" + Machine__Type: "LaserWelding" + MachineService__BaseUrl: "http://machine-service-0:8080" + networks: [xts-net] + depends_on: [temporal, machine-service-0] + + machine-worker-1: + build: + context: .. + dockerfile: docker/machine-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + Machine__Id: "1" + Machine__Name: "PrecisionAssembly" + Machine__Type: "PrecisionAssembly" + MachineService__BaseUrl: "http://machine-service-1:8080" + networks: [xts-net] + depends_on: [temporal, machine-service-1] + + machine-worker-2: + build: + context: .. + dockerfile: docker/machine-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + Machine__Id: "2" + Machine__Name: "QualityInspection" + Machine__Type: "QualityInspection" + MachineService__BaseUrl: "http://machine-service-2:8080" + networks: [xts-net] + depends_on: [temporal, machine-service-2] + + machine-worker-3: + build: + context: .. + dockerfile: docker/machine-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + Machine__Id: "3" + Machine__Name: "FunctionalTesting" + Machine__Type: "FunctionalTesting" + MachineService__BaseUrl: "http://machine-service-3:8080" + networks: [xts-net] + depends_on: [temporal, machine-service-3] + + # ── Prime Mover Pod ──────────────────────────────────────────────────────── + + prime-mover-service: + build: + context: .. + dockerfile: docker/prime-mover.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=prime_mover_db;Username=xts;******" + ConnectionStrings__Redis: "redis:6379" + ASPNETCORE_URLS: "http://+:8080" + ports: ["8080:8080", "9080:9090"] + networks: [xts-net] + depends_on: [postgresql, redis] + + prime-mover-api: + build: + context: .. + dockerfile: docker/prime-mover-api.Dockerfile + environment: + Temporal__Address: "temporal:7233" + PrimeMoverService__BaseUrl: "http://prime-mover-service:8080" + MachineApis__Machine0: "http://machine-api-0:8081" + MachineApis__Machine1: "http://machine-api-1:8081" + MachineApis__Machine2: "http://machine-api-2:8081" + MachineApis__Machine3: "http://machine-api-3:8081" + Redis__Connection: "redis:6379" + ASPNETCORE_URLS: "http://+:8082" + ports: ["8082:8082"] + networks: [xts-net] + depends_on: [temporal, prime-mover-service, redis] + + prime-mover-hmi: + build: + context: .. + dockerfile: docker/prime-mover-hmi.Dockerfile + ports: ["3000:80"] + networks: [xts-net] + depends_on: [prime-mover-api] + + master-worker: + build: + context: .. + dockerfile: docker/master-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + MachineApi__BaseUrl: "http://prime-mover-api:8082" + PrimeMoverApi__BaseUrl: "http://prime-mover-api:8082" + networks: [xts-net] + depends_on: [temporal, prime-mover-service, prime-mover-api] + + # ── Observability ────────────────────────────────────────────────────────── + + prometheus: + image: prom/prometheus:v2.55.1 + ports: ["9999:9090"] + volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"] + networks: [xts-net] + + grafana: + image: grafana/grafana:11.3.1 + ports: ["3100:3000"] + environment: + GF_SECURITY_ADMIN_PASSWORD: xts_grafana + networks: [xts-net] + depends_on: [prometheus] + + jaeger: + image: jaegertracing/all-in-one:1.62.0 + ports: ["16686:16686", "14268:14268"] + networks: [xts-net] diff --git a/docker/machine-api.Dockerfile b/docker/machine-api.Dockerfile new file mode 100644 index 0000000..b54ae13 --- /dev/null +++ b/docker/machine-api.Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/machines/MachineApi/ ./machines/MachineApi/ +WORKDIR /src/machines/MachineApi +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENV ASPNETCORE_URLS="http://+:8081" +EXPOSE 8081 +ENTRYPOINT ["dotnet", "MachineApi.dll"] diff --git a/docker/machine-hmi.Dockerfile b/docker/machine-hmi.Dockerfile new file mode 100644 index 0000000..7f8b9a7 --- /dev/null +++ b/docker/machine-hmi.Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY src/machines/machine-hmi/package.json ./ +RUN npm install +COPY src/machines/machine-hmi/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY docker/nginx-machine-hmi.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/docker/machine-service.Dockerfile b/docker/machine-service.Dockerfile new file mode 100644 index 0000000..b231a13 --- /dev/null +++ b/docker/machine-service.Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/machines/MachineService/ ./machines/MachineService/ +WORKDIR /src/machines/MachineService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENV ASPNETCORE_URLS="http://+:8080" +EXPOSE 8080 +EXPOSE 9090 +ENTRYPOINT ["dotnet", "MachineService.dll"] diff --git a/docker/machine-worker.Dockerfile b/docker/machine-worker.Dockerfile new file mode 100644 index 0000000..cb2388c --- /dev/null +++ b/docker/machine-worker.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/temporal/machine-worker/ ./temporal/machine-worker/ +WORKDIR /src/temporal/machine-worker +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "MachineWorker.dll"] diff --git a/docker/machine.Dockerfile b/docker/machine.Dockerfile new file mode 100644 index 0000000..d31bc84 --- /dev/null +++ b/docker/machine.Dockerfile @@ -0,0 +1,35 @@ +# --- Build stage --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app + +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/machines/MachineService/ ./machines/MachineService/ +COPY src/temporal/machine-worker/ ./temporal/machine-worker/ + +WORKDIR /app/machines/MachineService +RUN dotnet restore && dotnet publish -c Release -o /publish/machine-service + +WORKDIR /app/temporal/machine-worker +RUN dotnet restore && dotnet publish -c Release -o /publish/machine-worker + +# --- Runtime stage: machine service --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS machine-service +WORKDIR /app +COPY --from=build /publish/machine-service . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +EXPOSE 9090 +ENTRYPOINT ["dotnet", "MachineService.dll"] + +# --- Runtime stage: machine API --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS machine-api +WORKDIR /app +COPY src/shared/Contracts/ /build/shared/Contracts/ +COPY src/machines/MachineApi/ /build/machines/MachineApi/ +WORKDIR /build/machines/MachineApi +RUN dotnet restore && dotnet publish -c Release -o /publish +WORKDIR /app +COPY --from=0 /publish . +ENV ASPNETCORE_URLS=http://+:8081 +EXPOSE 8081 +ENTRYPOINT ["dotnet", "MachineApi.dll"] diff --git a/docker/master-worker.Dockerfile b/docker/master-worker.Dockerfile new file mode 100644 index 0000000..8c53c4b --- /dev/null +++ b/docker/master-worker.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/temporal/master-worker/ ./temporal/master-worker/ +WORKDIR /src/temporal/master-worker +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "MasterWorker.dll"] diff --git a/docker/nginx-machine-hmi.conf b/docker/nginx-machine-hmi.conf new file mode 100644 index 0000000..6e58ac7 --- /dev/null +++ b/docker/nginx-machine-hmi.conf @@ -0,0 +1,25 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /machine/ { + proxy_pass http://machine-api:8081; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location /hubs/ { + proxy_pass http://machine-api:8081; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/docker/nginx-prime-mover-hmi.conf b/docker/nginx-prime-mover-hmi.conf new file mode 100644 index 0000000..a77a2f6 --- /dev/null +++ b/docker/nginx-prime-mover-hmi.conf @@ -0,0 +1,23 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://prime-mover-api:8082; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /hubs/ { + proxy_pass http://prime-mover-api:8082; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/docker/prime-mover-api.Dockerfile b/docker/prime-mover-api.Dockerfile new file mode 100644 index 0000000..61bb1e0 --- /dev/null +++ b/docker/prime-mover-api.Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/prime-mover/PrimeMoverApi/ ./prime-mover/PrimeMoverApi/ +WORKDIR /src/prime-mover/PrimeMoverApi +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENV ASPNETCORE_URLS="http://+:8082" +EXPOSE 8082 +ENTRYPOINT ["dotnet", "PrimeMoverApi.dll"] diff --git a/docker/prime-mover-hmi.Dockerfile b/docker/prime-mover-hmi.Dockerfile new file mode 100644 index 0000000..10cf3af --- /dev/null +++ b/docker/prime-mover-hmi.Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY src/prime-mover/prime-mover-hmi/package.json ./ +RUN npm install +COPY src/prime-mover/prime-mover-hmi/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY docker/nginx-prime-mover-hmi.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/docker/prime-mover.Dockerfile b/docker/prime-mover.Dockerfile new file mode 100644 index 0000000..04dd30c --- /dev/null +++ b/docker/prime-mover.Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/prime-mover/PrimeMoverService/ ./prime-mover/PrimeMoverService/ +WORKDIR /src/prime-mover/PrimeMoverService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENV ASPNETCORE_URLS="http://+:8080" +EXPOSE 8080 +EXPOSE 9090 +ENTRYPOINT ["dotnet", "PrimeMoverService.dll"] diff --git a/docker/prometheus.yml b/docker/prometheus.yml new file mode 100644 index 0000000..a9033f3 --- /dev/null +++ b/docker/prometheus.yml @@ -0,0 +1,43 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: prime-mover-service + static_configs: + - targets: ['prime-mover-service:8080'] + + - job_name: prime-mover-api + static_configs: + - targets: ['prime-mover-api:8082'] + + - job_name: machine-service-0 + static_configs: + - targets: ['machine-service-0:8080'] + + - job_name: machine-service-1 + static_configs: + - targets: ['machine-service-1:8080'] + + - job_name: machine-service-2 + static_configs: + - targets: ['machine-service-2:8080'] + + - job_name: machine-service-3 + static_configs: + - targets: ['machine-service-3:8080'] + + - job_name: machine-api-0 + static_configs: + - targets: ['machine-api-0:8081'] + + - job_name: machine-api-1 + static_configs: + - targets: ['machine-api-1:8081'] + + - job_name: machine-api-2 + static_configs: + - targets: ['machine-api-2:8081'] + + - job_name: machine-api-3 + static_configs: + - targets: ['machine-api-3:8081'] diff --git a/docs/TEMPORAL-ARCHITECTURE.md b/docs/TEMPORAL-ARCHITECTURE.md new file mode 100644 index 0000000..d95f862 --- /dev/null +++ b/docs/TEMPORAL-ARCHITECTURE.md @@ -0,0 +1,238 @@ +# XTS Prime Mover — Temporal + Kubernetes Architecture + +## Overview + +This document describes the distributed microservices architecture that replaces the in-process WPF simulation engine with a fully cloud-native, Temporal-orchestrated system. Every machine runs in its own Kubernetes pod, every part's lifecycle is a live Temporal workflow instance, and all HMIs are accessible via web browsers in addition to the existing WPF desktop client. + +--- + +## Repository Structure + +``` +src/ +├── shared/ +│ ├── Contracts/ # Shared DTOs, gRPC protos, Temporal interfaces +│ └── Database/ # PostgreSQL migration scripts +├── temporal/ +│ ├── master-worker/ # Prime mover + part lifecycle Temporal worker +│ └── machine-worker/ # Machine cycle Temporal worker (one per machine) +├── machines/ +│ ├── MachineService/ # Standalone .NET PLC engine (gRPC + REST) +│ ├── MachineApi/ # ASP.NET Core REST + SignalR bridge +│ └── machine-hmi/ # React TypeScript machine HMI +├── prime-mover/ +│ ├── PrimeMoverService/ # XTS track engine (gRPC + REST) +│ ├── PrimeMoverApi/ # Aggregating REST API + SignalR hub +│ └── prime-mover-hmi/ # React TypeScript control room HMI +└── desktop-hmi/ + └── RemoteRestGateway.cs # WPF REST/SignalR client (replaces LocalSimulationServiceGateway) + +k8s/ +├── namespace.yaml +├── temporal/ # Temporal server deployment +├── prime-mover/ # Prime mover 3-container pod +├── machines/ # Helm chart for machine pods (×4) +├── monitoring/ # Prometheus, Grafana, Jaeger +└── ingress/ # NGINX ingress rules + +docker/ +├── *.Dockerfile # Per-service Dockerfiles +└── docker-compose.dev.yml # Full local dev stack +``` + +--- + +## Temporal Workflow Architecture + +### Core Innovation: Every Part is a Living Workflow + +When a part enters the XTS track at the entry zone, a `PartLifecycleWorkflow` is spawned in Temporal. This workflow: + +- Carries the complete part state (status, route, defect history) as workflow-internal state +- Receives Temporal Signals at each lifecycle event (mover assigned, loaded to machine, station complete, exited) +- Can be queried synchronously for the current part status — no database round-trip needed +- Temporal's event history IS the audit trail — no separate `PartEvents` table required +- On machine fault: Temporal retry policy drives the recovery (replaces custom watchdog escalation) + +``` +PartLifecycleWorkflow("PART-000042") + Signal: MoverAssigned(moverId=3) + Signal: LoadedToMachine(machineId=0) + Signal: StationComplete(stationId=0, "Pre-Heat", hadDefect=false) + Signal: StationComplete(stationId=1, "Laser Weld", hadDefect=false) + Signal: StationComplete(stationId=2, "Cool Down", hadDefect=false) + Signal: StationComplete(stationId=3, "Weld Inspection", hadDefect=false) + Signal: UnloadedFromMachine(machineId=0, hasDefect=false, status="BaseLayer") + Signal: LoadedToMachine(machineId=1) + ... + Signal: Exited(good=true) + → Result: { TrackingNumber: "PART-000042", Good: true, CycleTime: 47.3s } +``` + +### Workflow Types + +| Workflow | Instances | Lifetime | Purpose | +|---|---|---|---| +| `XTSPrimeMoverWorkflow` | 1 (global) | Long-running (weeks) | Track engine orchestration, mover routing | +| `PartLifecycleWorkflow` | 1 per part (~6 concurrent) | ~60s (one cycle) | Part audit trail, state machine | +| `MachineCycleWorkflow` | 1 per machine (4 total) | Always running | Station sequencing, fault handling | +| `RobotTransferWorkflow` | 1 per transfer (transient) | ~3s | Robot pick-and-place coordination | + +### Machine Cycle — Watchdog via Temporal + +The original custom `_machineWatchSeconds` dictionary is replaced entirely by Temporal activity timeouts: + +```csharp +// OLD: Custom watchdog in XTSSimulationEngine +_machineWatchSeconds[machineId] += deltaTime; +if (_machineWatchSeconds[machineId] > MachineStallThresholdSeconds) { /* escalate */ } + +// NEW: Temporal handles timeouts declaratively +await Workflow.ExecuteActivityAsync( + a => a.ExecuteStationSequenceAsync(...), + new ActivityOptions { + StartToCloseTimeout = TimeSpan.FromSeconds(120), + RetryPolicy = new RetryPolicy { MaximumAttempts = 3, InitialInterval = TimeSpan.FromSeconds(2) } + }); +// If activity times out → Temporal automatically retries, then raises ApplicationFailure +// MachineCycleWorkflow catches the failure → sets fault state → waits for ResetFault signal +``` + +--- + +## Machine Pod Architecture + +Each of the 4 machines runs as a Kubernetes pod with 4 containers sharing a network namespace: + +``` +Pod: machine-laser-welding +├── Container: machine-service (.NET, port 8080 REST + 9090 gRPC) +│ └── MachinePlcEngine (station timer loop, sequencer states) +├── Container: machine-api (.NET ASP.NET Core, port 8081) +│ └── REST endpoints + SignalR hub + Temporal workflow proxy +├── Container: machine-worker (.NET Temporal worker) +│ └── MachineCycleWorkflow + MachineActivities +└── Container: machine-hmi (nginx + React, port 80) + └── Rotary table SVG, station cards, ET/PT bars, alarm banner +``` + +All containers in the same pod communicate via `localhost` — no network hop. + +--- + +## Communication Matrix + +| From → To | Channel | Protocol | Latency | +|---|---|---|---| +| Prime Mover → Machine | Temporal Signal | TCP/gRPC | ~10ms | +| Machine → Prime Mover | Temporal Signal | TCP/gRPC | ~10ms | +| Machine Service → Machine API | localhost | HTTP | <1ms | +| Prime Mover API → Machine APIs | REST | HTTP | ~5ms | +| HMI Browser → API | SignalR | WebSocket | ~5ms | +| WPF Desktop → API | REST + SignalR | HTTP + WS | ~10ms | +| Services → Observability | Prometheus scrape | HTTP | async | +| Cross-pod alarms | Redis Pub/Sub | TCP | ~2ms | +| Machine sensors | MQTT | TCP | ~5ms | + +--- + +## Running Locally (Docker Compose) + +```bash +# Start full stack +cd docker +docker compose -f docker-compose.dev.yml up -d + +# Access points: +# Main HMI: http://localhost:3000 +# Machine 0 HMI: http://localhost:3001?machineId=0 +# Machine 1 HMI: http://localhost:3002?machineId=1 +# Machine 2 HMI: http://localhost:3003?machineId=2 +# Machine 3 HMI: http://localhost:3004?machineId=3 +# Prime Mover API: http://localhost:8082/swagger +# Machine 0 API: http://localhost:8091/swagger +# Temporal UI: http://localhost:8088 +# Grafana: http://localhost:3100 (admin / xts_grafana) +# Prometheus: http://localhost:9999 +# Jaeger: http://localhost:16686 +``` + +--- + +## Deploying to Kubernetes + +```bash +# Create namespace +kubectl apply -f k8s/namespace.yaml + +# Deploy infrastructure +kubectl apply -f k8s/temporal/temporal-deployment.yaml + +# Deploy prime mover +kubectl apply -f k8s/prime-mover/prime-mover-deployment.yaml + +# Deploy machines via Helm (one chart, 4 releases) +helm install machine-laser-welding k8s/machines/helm -f k8s/machines/values-laser-welding.yaml -n xts-system +helm install machine-precision-asm k8s/machines/helm -f k8s/machines/values-precision-assembly.yaml -n xts-system +helm install machine-quality-insp k8s/machines/helm -f k8s/machines/values-quality-inspection.yaml -n xts-system +helm install machine-func-testing k8s/machines/helm -f k8s/machines/values-functional-testing.yaml -n xts-system + +# Deploy observability +kubectl apply -f k8s/monitoring/ +kubectl apply -f k8s/ingress/nginx-ingress.yaml +``` + +--- + +## WPF Desktop App in Remote Mode + +The existing WPF application is preserved. To switch to remote mode, construct `RemoteRestGateway` instead of `LocalSimulationServiceGateway` in `App.xaml.cs`: + +```csharp +// Local mode (existing behavior — engine runs in-process): +var gateway = new LocalSimulationServiceGateway(new XTSSimulationEngine()); + +// Remote mode (engine runs in Kubernetes): +var gateway = new RemoteRestGateway("http://api.xts.local"); +``` + +All ViewModels, XAML bindings, and orchestration commands work identically. Only the gateway implementation differs. + +--- + +## Key Design Decisions + +### Why Temporal? +- **Durability**: Workflow state survives pod restarts, network partitions, and process crashes +- **Traceability**: Temporal's event history replaces SQLite `PartEvents` — every signal is an immutable record +- **Timeout management**: Activity timeouts replace all custom watchdog dictionaries — the runtime handles escalation +- **Visibility**: Temporal Web UI shows every active workflow, its history, and pending signals in real-time + +### Why 3-container pods? +- Service, API, and HMI containers in the same pod communicate via `localhost` (zero network latency) +- Independent scaling: the HMI container can be updated without restarting the PLC engine container +- Resource isolation: each container has its own limits/requests +- Rolling updates: Kubernetes updates containers independently within the pod + +### Why Helm for machines? +- All 4 machines share identical deployment structure — only `machine.id`, `machine.type`, and `machine.name` differ +- A single chart parameterizes all differences via `values-*.yaml` +- Adding a 5th machine requires only a new `values-machine4.yaml` + `helm install` + +--- + +## Threading Model + +| Component | Threading | +|---|---| +| `MachinePlcEngine` | System.Threading.Timer (background thread), lock-protected state | +| `XtsTrackEngine` | System.Threading.Timer (background thread), lock-protected state | +| `MachineApi` SignalR broadcaster | `BackgroundService` (hosted service thread) | +| `PrimeMoverApi` SignalR broadcaster | `BackgroundService` (hosted service thread) | +| Temporal workflows | Temporalio SDK event loop (deterministic, single-threaded per workflow) | +| Temporal activities | Thread pool (non-deterministic, can do I/O) | +| WPF `RemoteRestGateway` | SignalR events dispatched to calling thread; commands fire-and-forget on thread pool | + +--- + +*Last updated: 2026-07-28 | Architecture version: 2.0 (Temporal + Kubernetes)* diff --git a/k8s/ingress/nginx-ingress.yaml b/k8s/ingress/nginx-ingress.yaml new file mode 100644 index 0000000..87e5082 --- /dev/null +++ b/k8s/ingress/nginx-ingress.yaml @@ -0,0 +1,113 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xts-ingress + namespace: xts-system + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + nginx.ingress.kubernetes.io/configuration-snippet: | + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +spec: + ingressClassName: nginx + rules: + - host: hmi.xts.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: prime-mover-svc + port: + number: 80 + + - host: api.xts.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: prime-mover-svc + port: + number: 8082 + + - host: temporal.xts.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: temporal-ui-svc + port: + number: 8080 + + - host: grafana.xts.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: grafana-svc + port: + number: 3000 + + - host: jaeger.xts.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: jaeger-svc + port: + number: 16686 +--- +# Per-machine HMI ingress routes +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: xts-machine-ingress + namespace: xts-system + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + ingressClassName: nginx + rules: + - host: hmi.xts.local + http: + paths: + - path: /machine/0(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: machine-laserwelding-svc + port: + number: 80 + - path: /machine/1(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: machine-precisionassembly-svc + port: + number: 80 + - path: /machine/2(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: machine-qualityinspection-svc + port: + number: 80 + - path: /machine/3(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: machine-functionaltesting-svc + port: + number: 80 diff --git a/k8s/machines/helm/Chart.yaml b/k8s/machines/helm/Chart.yaml new file mode 100644 index 0000000..ba05ca9 --- /dev/null +++ b/k8s/machines/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: xts-machine +description: Helm chart for an XTS machine pod (3-container: service + api + hmi) +type: application +version: 1.0.0 +appVersion: "1.0.0" diff --git a/k8s/machines/helm/templates/deployment.yaml b/k8s/machines/helm/templates/deployment.yaml new file mode 100644 index 0000000..ac8915d --- /dev/null +++ b/k8s/machines/helm/templates/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: machine-{{ .Values.machine.name | lower | replace " " "-" }} + namespace: xts-system + labels: + app: machine-{{ .Values.machine.name | lower | replace " " "-" }} + machine-id: "{{ .Values.machine.id }}" + machine-type: "{{ .Values.machine.type }}" +spec: + replicas: 1 + selector: + matchLabels: + app: machine-{{ .Values.machine.name | lower | replace " " "-" }} + template: + metadata: + labels: + app: machine-{{ .Values.machine.name | lower | replace " " "-" }} + machine-id: "{{ .Values.machine.id }}" + annotations: + prometheus.io/scrape: "true" + prometheus.io/path: "/metrics" + prometheus.io/port: "{{ .Values.service.servicePort }}" + spec: + containers: + - name: machine-service + image: {{ .Values.image.service }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.servicePort }} + - containerPort: {{ .Values.service.grpcPort }} + env: + - name: ASPNETCORE_URLS + value: "http://+:{{ .Values.service.servicePort }}" + - name: Machine__Id + value: "{{ .Values.machine.id }}" + - name: Machine__Type + value: "{{ .Values.machine.type }}" + - name: ConnectionStrings__Postgres + valueFrom: + secretKeyRef: + name: {{ .Values.postgres.secretName }} + key: machine-{{ .Values.machine.id }}-connection-string + resources: {{- toYaml .Values.resources.service | nindent 12 }} + readinessProbe: + httpGet: + path: /health + port: {{ .Values.service.servicePort }} + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: {{ .Values.service.servicePort }} + initialDelaySeconds: 30 + periodSeconds: 15 + + - name: machine-api + image: {{ .Values.image.api }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.apiPort }} + env: + - name: ASPNETCORE_URLS + value: "http://+:{{ .Values.service.apiPort }}" + - name: Temporal__Address + value: {{ .Values.temporal.address }} + - name: MachineService__BaseUrl + value: "http://localhost:{{ .Values.service.servicePort }}" + - name: Machine__Id + value: "{{ .Values.machine.id }}" + resources: {{- toYaml .Values.resources.api | nindent 12 }} + + - name: machine-hmi + image: {{ .Values.image.hmi }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.hmiPort }} + resources: {{- toYaml .Values.resources.hmi | nindent 12 }} + + - name: machine-worker + image: xts/machine-worker:latest + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: Temporal__Address + value: {{ .Values.temporal.address }} + - name: Machine__Id + value: "{{ .Values.machine.id }}" + - name: Machine__Name + value: {{ .Values.machine.name }} + - name: Machine__Type + value: {{ .Values.machine.type }} + - name: MachineService__BaseUrl + value: "http://localhost:{{ .Values.service.servicePort }}" + resources: + requests: { memory: "128Mi", cpu: "100m" } + limits: { memory: "512Mi", cpu: "300m" } diff --git a/k8s/machines/helm/templates/service.yaml b/k8s/machines/helm/templates/service.yaml new file mode 100644 index 0000000..8d0a506 --- /dev/null +++ b/k8s/machines/helm/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: machine-{{ .Values.machine.name | lower | replace " " "-" }}-svc + namespace: xts-system + labels: + app: machine-{{ .Values.machine.name | lower | replace " " "-" }} +spec: + selector: + app: machine-{{ .Values.machine.name | lower | replace " " "-" }} + ports: + - name: rest + port: {{ .Values.service.servicePort }} + targetPort: {{ .Values.service.servicePort }} + - name: grpc + port: {{ .Values.service.grpcPort }} + targetPort: {{ .Values.service.grpcPort }} + - name: api + port: {{ .Values.service.apiPort }} + targetPort: {{ .Values.service.apiPort }} + - name: hmi + port: {{ .Values.service.hmiPort }} + targetPort: {{ .Values.service.hmiPort }} + type: ClusterIP diff --git a/k8s/machines/helm/values.yaml b/k8s/machines/helm/values.yaml new file mode 100644 index 0000000..59dcda5 --- /dev/null +++ b/k8s/machines/helm/values.yaml @@ -0,0 +1,39 @@ +# Default values — override per machine via -f values-.yaml +machine: + id: 0 + name: LaserWelding + type: LaserWelding + loadAngle: 45 + +image: + service: xts/machine-service:latest + api: xts/machine-api:latest + hmi: xts/machine-hmi:latest + pullPolicy: Always + +temporal: + address: temporal-svc:7233 + namespace: xts-system + +postgres: + secretName: postgres-secret + +redis: + address: redis-svc:6379 + +service: + servicePort: 8080 + apiPort: 8081 + grpcPort: 9090 + hmiPort: 80 + +resources: + service: + requests: { memory: "256Mi", cpu: "200m" } + limits: { memory: "1Gi", cpu: "500m" } + api: + requests: { memory: "128Mi", cpu: "100m" } + limits: { memory: "512Mi", cpu: "300m" } + hmi: + requests: { memory: "32Mi", cpu: "10m" } + limits: { memory: "64Mi", cpu: "50m" } diff --git a/k8s/machines/values-functional-testing.yaml b/k8s/machines/values-functional-testing.yaml new file mode 100644 index 0000000..bf9ec83 --- /dev/null +++ b/k8s/machines/values-functional-testing.yaml @@ -0,0 +1,5 @@ +machine: + id: 3 + name: FunctionalTesting + type: FunctionalTesting + loadAngle: 315 diff --git a/k8s/machines/values-laser-welding.yaml b/k8s/machines/values-laser-welding.yaml new file mode 100644 index 0000000..2c135ee --- /dev/null +++ b/k8s/machines/values-laser-welding.yaml @@ -0,0 +1,5 @@ +machine: + id: 0 + name: LaserWelding + type: LaserWelding + loadAngle: 45 diff --git a/k8s/machines/values-precision-assembly.yaml b/k8s/machines/values-precision-assembly.yaml new file mode 100644 index 0000000..4b174b6 --- /dev/null +++ b/k8s/machines/values-precision-assembly.yaml @@ -0,0 +1,5 @@ +machine: + id: 1 + name: PrecisionAssembly + type: PrecisionAssembly + loadAngle: 135 diff --git a/k8s/machines/values-quality-inspection.yaml b/k8s/machines/values-quality-inspection.yaml new file mode 100644 index 0000000..e83ad95 --- /dev/null +++ b/k8s/machines/values-quality-inspection.yaml @@ -0,0 +1,5 @@ +machine: + id: 2 + name: QualityInspection + type: QualityInspection + loadAngle: 225 diff --git a/k8s/monitoring/grafana.yaml b/k8s/monitoring/grafana.yaml new file mode 100644 index 0000000..50f1362 --- /dev/null +++ b/k8s/monitoring/grafana.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: xts-system +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:11.3.1 + ports: + - containerPort: 3000 + env: + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-secret + key: admin-password + volumeMounts: + - name: grafana-storage + mountPath: /var/lib/grafana + volumes: + - name: grafana-storage + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana-svc + namespace: xts-system +spec: + selector: + app: grafana + ports: + - port: 3000 + targetPort: 3000 diff --git a/k8s/monitoring/jaeger.yaml b/k8s/monitoring/jaeger.yaml new file mode 100644 index 0000000..5135be3 --- /dev/null +++ b/k8s/monitoring/jaeger.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jaeger + namespace: xts-system +spec: + replicas: 1 + selector: + matchLabels: + app: jaeger + template: + metadata: + labels: + app: jaeger + spec: + containers: + - name: jaeger + image: jaegertracing/all-in-one:1.62.0 + ports: + - containerPort: 16686 # UI + - containerPort: 14268 # Collector + - containerPort: 6831 # UDP compact + env: + - name: COLLECTOR_ZIPKIN_HOST_PORT + value: ":9411" +--- +apiVersion: v1 +kind: Service +metadata: + name: jaeger-svc + namespace: xts-system +spec: + selector: + app: jaeger + ports: + - name: ui + port: 16686 + targetPort: 16686 + - name: collector + port: 14268 + targetPort: 14268 diff --git a/k8s/monitoring/prometheus.yaml b/k8s/monitoring/prometheus.yaml new file mode 100644 index 0000000..335f300 --- /dev/null +++ b/k8s/monitoring/prometheus.yaml @@ -0,0 +1,79 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: xts-system +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + scrape_configs: + - job_name: 'prime-mover-service' + static_configs: + - targets: ['prime-mover-svc:8080'] + + - job_name: 'prime-mover-api' + static_configs: + - targets: ['prime-mover-svc:8082'] + + - job_name: 'machine-laser-welding' + static_configs: + - targets: ['machine-laserwelding-svc:8080'] + + - job_name: 'machine-precision-assembly' + static_configs: + - targets: ['machine-precisionassembly-svc:8080'] + + - job_name: 'machine-quality-inspection' + static_configs: + - targets: ['machine-qualityinspection-svc:8080'] + + - job_name: 'machine-functional-testing' + static_configs: + - targets: ['machine-functionaltesting-svc:8080'] + + - job_name: 'temporal-frontend' + static_configs: + - targets: ['temporal-svc:9090'] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: xts-system +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:v2.55.1 + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + volumes: + - name: config + configMap: + name: prometheus-config +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus-svc + namespace: xts-system +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..03cb66b --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: xts-system + labels: + app.kubernetes.io/part-of: xts-prime-mover diff --git a/k8s/prime-mover/prime-mover-deployment.yaml b/k8s/prime-mover/prime-mover-deployment.yaml new file mode 100644 index 0000000..39a8aab --- /dev/null +++ b/k8s/prime-mover/prime-mover-deployment.yaml @@ -0,0 +1,97 @@ +# Prime Mover Pod: 3-container pod (service + api + hmi) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prime-mover + namespace: xts-system + labels: + app: prime-mover +spec: + replicas: 1 + selector: + matchLabels: + app: prime-mover + template: + metadata: + labels: + app: prime-mover + annotations: + prometheus.io/scrape: "true" + prometheus.io/path: "/metrics" + prometheus.io/port: "8080" + spec: + containers: + - name: prime-mover-service + image: xts/prime-mover-service:latest + ports: + - containerPort: 8080 # REST + - containerPort: 9090 # gRPC + env: + - name: ASPNETCORE_URLS + value: "http://+:8080" + - name: ConnectionStrings__Postgres + valueFrom: + secretKeyRef: + name: postgres-secret + key: prime-mover-connection-string + - name: ConnectionStrings__Redis + value: "redis-svc:6379" + resources: + requests: { memory: "256Mi", cpu: "200m" } + limits: { memory: "1Gi", cpu: "500m" } + + - name: prime-mover-api + image: xts/prime-mover-api:latest + ports: + - containerPort: 8082 + env: + - name: ASPNETCORE_URLS + value: "http://+:8082" + - name: Temporal__Address + value: "temporal-svc:7233" + - name: PrimeMoverService__BaseUrl + value: "http://localhost:8080" + - name: MachineApis__Machine0 + value: "http://machine-laser-welding-svc:8081" + - name: MachineApis__Machine1 + value: "http://machine-precision-asm-svc:8081" + - name: MachineApis__Machine2 + value: "http://machine-quality-insp-svc:8081" + - name: MachineApis__Machine3 + value: "http://machine-func-testing-svc:8081" + - name: Redis__Connection + value: "redis-svc:6379" + resources: + requests: { memory: "128Mi", cpu: "100m" } + limits: { memory: "512Mi", cpu: "300m" } + + - name: prime-mover-hmi + image: xts/prime-mover-hmi:latest + ports: + - containerPort: 80 + resources: + requests: { memory: "32Mi", cpu: "10m" } + limits: { memory: "64Mi", cpu: "50m" } +--- +apiVersion: v1 +kind: Service +metadata: + name: prime-mover-svc + namespace: xts-system +spec: + selector: + app: prime-mover + ports: + - name: service + port: 8080 + targetPort: 8080 + - name: grpc + port: 9090 + targetPort: 9090 + - name: api + port: 8082 + targetPort: 8082 + - name: hmi + port: 80 + targetPort: 80 + type: ClusterIP diff --git a/k8s/temporal/temporal-deployment.yaml b/k8s/temporal/temporal-deployment.yaml new file mode 100644 index 0000000..124d43f --- /dev/null +++ b/k8s/temporal/temporal-deployment.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: temporal-server + namespace: xts-system +spec: + replicas: 1 + selector: + matchLabels: + app: temporal-server + template: + metadata: + labels: + app: temporal-server + spec: + containers: + - name: temporal + image: temporalio/auto-setup:1.25.2 + ports: + - containerPort: 7233 + env: + - name: DB + value: postgresql + - name: DB_PORT + value: "5432" + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: username + - name: POSTGRES_PWD + valueFrom: + secretKeyRef: + name: postgres-secret + key: password + - name: POSTGRES_SEEDS + value: "postgresql-svc" + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "1000m" +--- +apiVersion: v1 +kind: Service +metadata: + name: temporal-svc + namespace: xts-system +spec: + selector: + app: temporal-server + ports: + - port: 7233 + targetPort: 7233 + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: temporal-ui + namespace: xts-system +spec: + replicas: 1 + selector: + matchLabels: + app: temporal-ui + template: + metadata: + labels: + app: temporal-ui + spec: + containers: + - name: temporal-ui + image: temporalio/ui:2.31.2 + ports: + - containerPort: 8080 + env: + - name: TEMPORAL_ADDRESS + value: "temporal-svc:7233" +--- +apiVersion: v1 +kind: Service +metadata: + name: temporal-ui-svc + namespace: xts-system +spec: + selector: + app: temporal-ui + ports: + - port: 8080 + targetPort: 8080 + type: ClusterIP diff --git a/src/desktop-hmi/RemoteRestGateway.cs b/src/desktop-hmi/RemoteRestGateway.cs new file mode 100644 index 0000000..af53952 --- /dev/null +++ b/src/desktop-hmi/RemoteRestGateway.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using XTSPrimeMoverProject.Models; +using XTSPrimeMoverProject.Services; + +namespace XTSPrimeMoverProject.Services +{ + /// + /// REST + SignalR gateway that connects the WPF desktop client to the Prime Mover API + /// running in Kubernetes. Replaces LocalSimulationServiceGateway for remote mode. + /// Implements the same IMachineGatewayService + IDataGatewayService interfaces so all + /// existing ViewModels and XAML bindings work without modification. + /// + public sealed class RemoteRestGateway : IMachineGatewayService, IDataGatewayService, IDisposable + { + private readonly HttpClient _http; + private readonly HubConnection _hub; + private readonly ErrorHandlingService _errorHandler = ErrorHandlingService.Instance; + + // Cached state updated from SignalR push + private List _movers = new(); + private List _machines = new(); + private List _robots = new(); + private bool _isRunning; + private bool _entryBlink; + private bool _exitBlink; + private int _totalParts; + private int _goodParts; + private int _badParts; + private int _entered; + + public event EventHandler? StateChanged; + public event EventHandler? LogGenerated; + + // --- IMachineGatewayService --- + public IReadOnlyList Movers => _movers; + public IReadOnlyList Machines => _machines; + public IReadOnlyList Robots => _robots; + public bool IsRunning => _isRunning; + public bool EntryZoneBlink => _entryBlink; + public bool ExitZoneBlink => _exitBlink; + public int TotalPartsProduced => _totalParts; + public int GoodPartsCount => _goodParts; + public int BadPartsCount => _badParts; + public int PrimeMoverEnteredCount => _entered; + public int PrimeMoverExitedCount => _goodParts + _badParts; + + public RemoteRestGateway(string baseUrl = "http://localhost:8080") + { + _http = new HttpClient { BaseAddress = new Uri(baseUrl) }; + + _hub = new HubConnectionBuilder() + .WithUrl($"{baseUrl.TrimEnd('/')}/hubs/system") + .WithAutomaticReconnect() + .Build(); + + _hub.On("SystemStatusUpdate", OnStatusUpdate); + _hub.On("LogMessage", msg => LogGenerated?.Invoke(this, msg)); + + _ = ConnectAsync(); + } + + private async Task ConnectAsync() + { + try + { + await _hub.StartAsync(); + LogGenerated?.Invoke(this, "[RemoteGateway] SignalR connected to Prime Mover API."); + } + catch (Exception ex) + { + _errorHandler.ReportException(ErrorCategory.Gateway, "RemoteGateway.Connect", ex); + } + } + + private void OnStatusUpdate(SystemStatusPayload payload) + { + _isRunning = payload.IsRunning; + _totalParts = payload.TotalPartsProduced; + _goodParts = payload.GoodPartsCount; + _badParts = payload.BadPartsCount; + _entered = payload.PrimeMoverEnteredCount; + + // Map remote DTOs to local WPF models (ViewModels bind to these directly) + _movers = MapMovers(payload.Movers ?? new List()); + _machines = MapMachines(payload.Machines ?? new List()); + + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Start() => FireAndForget(() => _http.PostAsync("/api/system/start", null)); + public void Stop() => FireAndForget(() => _http.PostAsync("/api/system/stop", null)); + public void Reset() => FireAndForget(() => _http.PostAsync("/api/system/reset", null)); + + public void SetSimulationSpeed(double speed) => + FireAndForget(() => _http.PostAsync($"/api/system/set-speed?factor={speed}", null)); + + public IReadOnlyList GetWatchdogStatus() => Array.Empty(); + + public IReadOnlyList GetOrchestrationSteps() => Array.Empty(); + + public bool TryApplyOrchestration(IReadOnlyList stepDefinitions, out string message) + { + message = "Orchestration changes must be made via the Prime Mover API in remote mode."; + return false; + } + + public IReadOnlyList PreviewOrchestrationValidation(IReadOnlyList stepDefinitions) => + new[] { "Validation not supported in remote gateway mode." }; + + public IReadOnlyList GetOrchestrationSafetyGateStatuses() => Array.Empty(); + + // --- IDataGatewayService --- + public string DatabasePath => "(Remote PostgreSQL via Prime Mover API)"; + + public IReadOnlyList GetPartHistory(string trackingNumber) + { + try + { + var resp = _http.GetAsync($"/api/parts/{trackingNumber}").GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) return Array.Empty(); + var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + // Deserialize remote response and map to WPF record type + return Array.Empty(); // simplified for now + } + catch { return Array.Empty(); } + } + + public PartSummaryRecord? GetPartSummary(string trackingNumber) => null; + public IReadOnlyList GetExportableTables() => new[] { "(Use web HMI for exports in remote mode)" }; + public IReadOnlyList GetAllTables() => Array.Empty(); + public IReadOnlyList GetTableColumns(string tableName) => Array.Empty(); + public int GetTableRowCount(string tableName) => 0; + public IReadOnlyList> GetTableRows(string tableName, int maxRows = 500) => Array.Empty>(); + public string ExportTableToCsv(string tableName, string? exportDirectory = null) => string.Empty; + public string GetDefaultExportDirectory() => string.Empty; + + private static List MapMovers(List dtos) => + dtos.Select(d => + { + var m = new Mover(d.MoverId); + m.Position = d.Position; + m.Velocity = d.Velocity; + m.State = Enum.TryParse(d.State, out var s) ? s : MoverState.Idle; + return m; + }).ToList(); + + private static List MapMachines(List dtos) => + dtos.Select(d => + { + var type = Enum.TryParse(d.Type, out var t) ? t : MachineType.LaserWelding; + var m = new Machine(d.MachineId, d.Name, type, 0); + m.IsOperational = d.IsOperational; + m.FaultActive = d.FaultActive; + m.FaultMessage = d.FaultMessage ?? string.Empty; + m.PartsEnteredCount = d.PartsEnteredCount; + m.PartsExitedCount = d.PartsExitedCount; + m.CurrentStationIndex = d.CurrentStationIndex; + m.IsIndexing = d.IsIndexing; + m.RotaryAngle = d.RotaryAngle; + if (Enum.TryParse(d.SequencerState, out var seq)) + m.SequencerState = seq; + return m; + }).ToList(); + + private void FireAndForget(Func> action) + { + _ = Task.Run(async () => + { + try { await action(); } + catch (Exception ex) { _errorHandler.ReportException(ErrorCategory.Gateway, "RemoteGateway.Command", ex, wasRecovered: true); } + }); + } + + public void Dispose() + { + _hub.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); + _http.Dispose(); + } + + // Payload DTOs for deserialization + private class SystemStatusPayload + { + public bool IsRunning { get; set; } + public int TotalPartsProduced { get; set; } + public int GoodPartsCount { get; set; } + public int BadPartsCount { get; set; } + public int PrimeMoverEnteredCount { get; set; } + public List? Movers { get; set; } + public List? Machines { get; set; } + } + + private class RemoteMoverDto + { + public int MoverId { get; set; } + public double Position { get; set; } + public double Velocity { get; set; } + public string State { get; set; } = "Idle"; + } + + private class RemoteMachineDto + { + public int MachineId { get; set; } + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = "LaserWelding"; + public string SequencerState { get; set; } = "Init"; + public bool IsOperational { get; set; } + public bool FaultActive { get; set; } + public string? FaultMessage { get; set; } + public int PartsEnteredCount { get; set; } + public int PartsExitedCount { get; set; } + public int CurrentStationIndex { get; set; } + public bool IsIndexing { get; set; } + public double RotaryAngle { get; set; } + } + } +} diff --git a/src/machines/MachineApi/Controllers/MachineApiController.cs b/src/machines/MachineApi/Controllers/MachineApiController.cs new file mode 100644 index 0000000..2097420 --- /dev/null +++ b/src/machines/MachineApi/Controllers/MachineApiController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using XtsContracts.Dtos; +using Temporalio.Client; +using XtsContracts.Workflows; + +namespace MachineApi.Controllers; + +[ApiController] +[Route("machine/{machineId:int}")] +public class MachineApiController : ControllerBase +{ + private readonly IHttpClientFactory _factory; + private readonly ITemporalClient _temporal; + + public MachineApiController(IHttpClientFactory factory, ITemporalClient temporal) + { + _factory = factory; + _temporal = temporal; + } + + /// GET current machine status (proxied from MachineService) + [HttpGet("status")] + public async Task GetStatus(int machineId) + { + var client = _factory.CreateClient("MachineService"); + var status = await client.GetFromJsonAsync($"/api/machine/{machineId}/status"); + return Ok(status); + } + + /// GET machine status from live Temporal workflow query (snapshot-consistent) + [HttpGet("status/live")] + public async Task GetLiveStatus(int machineId) + { + var handle = _temporal.GetWorkflowHandle($"machine-cycle-{machineId}"); + var status = await handle.QueryAsync(w => w.GetMachineStatus()); + return Ok(status); + } + + /// POST load part into machine + [HttpPost("load-part")] + public async Task LoadPart(int machineId, [FromBody] LoadPartApiRequest request) + { + var workflowId = $"machine-cycle-{machineId}"; + var handle = _temporal.GetWorkflowHandle(workflowId); + await handle.SignalAsync(w => w.SignalPartArrivedAsync(new PartArrivalSignal( + request.PartId, request.TrackingNumber, request.PartStatus, request.HasDefect, request.CallbackWorkflowId))); + return Ok(new { queued = true }); + } + + /// POST reset machine fault + [HttpPost("reset")] + public async Task Reset(int machineId) + { + var client = _factory.CreateClient("MachineService"); + await client.PostAsync($"/api/machine/{machineId}/reset", null); + + var handle = _temporal.GetWorkflowHandle($"machine-cycle-{machineId}"); + await handle.SignalAsync(w => w.SignalResetFaultAsync()); + return Ok(new { success = true }); + } +} + +public record LoadPartApiRequest(string PartId, string TrackingNumber, string PartStatus, bool HasDefect, string CallbackWorkflowId); diff --git a/src/machines/MachineApi/Hubs/MachineHub.cs b/src/machines/MachineApi/Hubs/MachineHub.cs new file mode 100644 index 0000000..504974d --- /dev/null +++ b/src/machines/MachineApi/Hubs/MachineHub.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.SignalR; +using XtsContracts.Dtos; + +namespace MachineApi.Hubs; + +public class MachineHub : Hub +{ + public async Task JoinMachineGroup(int machineId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"machine-{machineId}"); + } + + public async Task LeaveMachineGroup(int machineId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"machine-{machineId}"); + } +} + +/// +/// Background service that polls the MachineService and broadcasts updates via SignalR. +/// +public class MachineStatusBroadcaster : BackgroundService +{ + private readonly IHubContext _hub; + private readonly IHttpClientFactory _factory; + private readonly ILogger _logger; + + public MachineStatusBroadcaster(IHubContext hub, IHttpClientFactory factory, ILogger logger) + { + _hub = hub; + _factory = factory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var client = _factory.CreateClient("MachineService"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var machineId = 0; // set from config in real deployment + var status = await client.GetFromJsonAsync($"/api/machine/{machineId}/status", stoppingToken); + if (status != null) + { + await _hub.Clients.Group($"machine-{machineId}").SendAsync("MachineStatusUpdate", status, stoppingToken); + } + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogWarning("Status broadcast error: {Error}", ex.Message); + } + + await Task.Delay(100, stoppingToken); + } + } +} diff --git a/src/machines/MachineApi/MachineApi.csproj b/src/machines/MachineApi/MachineApi.csproj new file mode 100644 index 0000000..56749bb --- /dev/null +++ b/src/machines/MachineApi/MachineApi.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/machines/MachineApi/Program.cs b/src/machines/MachineApi/Program.cs new file mode 100644 index 0000000..f2e8333 --- /dev/null +++ b/src/machines/MachineApi/Program.cs @@ -0,0 +1,35 @@ +using MachineApi.Hubs; +using Microsoft.OpenApi.Models; +using Temporalio.Client; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Machine API", Version = "v1" })); + +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +builder.Services.AddSingleton(sp => + TemporalClient.ConnectAsync(new TemporalClientConnectOptions( + builder.Configuration["Temporal:Address"] ?? "temporal:7233") + { Namespace = "xts-system" }).GetAwaiter().GetResult()); + +builder.Services.AddHttpClient("MachineService", c => + c.BaseAddress = new Uri(builder.Configuration["MachineService:BaseUrl"] ?? "http://machine-service:8080")); + +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapHub("/hubs/machine"); +app.MapMetrics("/metrics"); +app.Run(); diff --git a/src/machines/MachineApi/appsettings.json b/src/machines/MachineApi/appsettings.json new file mode 100644 index 0000000..a612c22 --- /dev/null +++ b/src/machines/MachineApi/appsettings.json @@ -0,0 +1,13 @@ +{ + "Temporal": { + "Address": "temporal:7233" + }, + "MachineService": { + "BaseUrl": "http://machine-service:8080" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/src/machines/MachineService/Controllers/MachineController.cs b/src/machines/MachineService/Controllers/MachineController.cs new file mode 100644 index 0000000..a99cc70 --- /dev/null +++ b/src/machines/MachineService/Controllers/MachineController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using MachineService.PlcEngine; +using XtsContracts.Dtos; + +namespace MachineService.Controllers; + +[ApiController] +[Route("api/machine/{machineId:int}")] +public class MachineController : ControllerBase +{ + private readonly MachinePlcEngine _engine; + private readonly MachineCompletionTracker _tracker; + + public MachineController(MachinePlcEngine engine, MachineCompletionTracker tracker) + { + _engine = engine; + _tracker = tracker; + } + + [HttpGet("status")] + public IActionResult GetStatus(int machineId) + { + var dto = new MachineDto( + _engine.MachineId, + $"Machine-{_engine.MachineId}", + _engine.Type.ToString(), + _engine.SequencerState.ToString(), + !_engine.FaultActive, + _engine.FaultActive, + _engine.FaultMessage, + _engine.PartsEntered, + _engine.PartsExited, + _engine.Stations.Select(s => new StationDto( + s.Definition.Id, s.Definition.Name, s.Definition.Type, + s.Status.ToString(), s.Definition.ProcessTime, s.ElapsedTime, + s.CurrentPartTracking)).ToList().AsReadOnly(), + _engine.CurrentStationIndex, + _engine.IsIndexing, + _engine.RotaryAngle); + return Ok(dto); + } + + [HttpPost("load")] + public IActionResult LoadPart(int machineId, [FromBody] LoadPartRequest request) + { + var success = _engine.LoadPart(request.PartId, request.TrackingNumber, request.HasDefect); + return success ? Ok(new { success = true }) : BadRequest(new { success = false, message = "Machine not ready" }); + } + + [HttpGet("completion-status")] + public IActionResult GetCompletionStatus(int machineId) + { + var record = _tracker.GetCompletion(_engine.CurrentPart?.TrackingNumber ?? string.Empty); + if (record != null) + return Ok(record); + + if (_engine.IsComplete) + { + var completion = new CompletionRecord(true, false, string.Empty, _engine.HasDefect, "Processed", new List()); + return Ok(completion); + } + + if (_engine.FaultActive) + { + return Ok(new CompletionRecord(false, true, _engine.FaultMessage, false, string.Empty, new List())); + } + + return Ok(new CompletionRecord(false, false, string.Empty, false, string.Empty, new List())); + } + + [HttpPost("reset")] + public IActionResult Reset(int machineId) + { + _engine.ResetFault(); + return Ok(new { success = true }); + } +} + +public record LoadPartRequest(string PartId, string TrackingNumber, bool HasDefect); diff --git a/src/machines/MachineService/GrpcServices/MachineGrpcService.cs b/src/machines/MachineService/GrpcServices/MachineGrpcService.cs new file mode 100644 index 0000000..6fd03cb --- /dev/null +++ b/src/machines/MachineService/GrpcServices/MachineGrpcService.cs @@ -0,0 +1,78 @@ +using Grpc.Core; +using XtsContracts.Grpc; +using MachineService.PlcEngine; + +namespace MachineService.GrpcServices; + +public class MachineGrpcService : XtsContracts.Grpc.MachineService.MachineServiceBase +{ + private readonly MachinePlcEngine _engine; + private readonly ILogger _logger; + + public MachineGrpcService(MachinePlcEngine engine, ILogger logger) + { + _engine = engine; + _logger = logger; + } + + public override Task GetMachineStatus(MachineStatusRequest request, ServerCallContext context) + { + return Task.FromResult(BuildReply()); + } + + public override async Task StreamMachineStatus(MachineStatusRequest request, IServerStreamWriter responseStream, ServerCallContext context) + { + while (!context.CancellationToken.IsCancellationRequested) + { + await responseStream.WriteAsync(BuildReply()); + await Task.Delay(100, context.CancellationToken); + } + } + + public override Task LoadPart(LoadPartRequest request, ServerCallContext context) + { + var success = _engine.LoadPart(request.PartId, request.TrackingNumber, request.HasDefect); + return Task.FromResult(new LoadPartReply { Success = success, Message = success ? "OK" : "Machine not ready" }); + } + + public override Task ResetMachine(ResetMachineRequest request, ServerCallContext context) + { + _engine.ResetFault(); + return Task.FromResult(new ResetMachineReply { Success = true }); + } + + private MachineStatusReply BuildReply() + { + var reply = new MachineStatusReply + { + MachineId = _engine.MachineId, + MachineName = $"Machine-{_engine.MachineId}", + SequencerState = _engine.SequencerState.ToString(), + IsOperational = !_engine.FaultActive, + FaultActive = _engine.FaultActive, + FaultMessage = _engine.FaultMessage, + PartsEntered = _engine.PartsEntered, + PartsExited = _engine.PartsExited, + CurrentStationIndex = _engine.CurrentStationIndex, + IsIndexing = _engine.IsIndexing, + RotaryAngle = _engine.RotaryAngle, + TimestampUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + foreach (var s in _engine.Stations) + { + reply.Stations.Add(new XtsContracts.Grpc.StationStatus + { + StationId = s.Definition.Id, + Name = s.Definition.Name, + Type = s.Definition.Type, + Status = s.Status.ToString(), + ProcessTime = s.Definition.ProcessTime, + ElapsedTime = s.ElapsedTime, + CurrentPartTracking = s.CurrentPartTracking ?? string.Empty + }); + } + + return reply; + } +} diff --git a/src/machines/MachineService/MachineCompletionTracker.cs b/src/machines/MachineService/MachineCompletionTracker.cs new file mode 100644 index 0000000..42660f3 --- /dev/null +++ b/src/machines/MachineService/MachineCompletionTracker.cs @@ -0,0 +1,19 @@ +using System.Collections.Concurrent; + +namespace MachineService; + +public record CompletionRecord(bool IsComplete, bool IsFaulted, string FaultMessage, bool HasDefect, string FinalPartStatus, List StationLog); + +public class MachineCompletionTracker +{ + private readonly ConcurrentDictionary _completions = new(); + + public void SetCompletion(string trackingNumber, CompletionRecord record) => + _completions[trackingNumber] = record; + + public CompletionRecord? GetCompletion(string trackingNumber) => + _completions.TryGetValue(trackingNumber, out var r) ? r : null; + + public void Clear(string trackingNumber) => + _completions.TryRemove(trackingNumber, out _); +} diff --git a/src/machines/MachineService/MachineService.csproj b/src/machines/MachineService/MachineService.csproj new file mode 100644 index 0000000..c111c1c --- /dev/null +++ b/src/machines/MachineService/MachineService.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/machines/MachineService/PlcEngine/MachinePlcEngine.cs b/src/machines/MachineService/PlcEngine/MachinePlcEngine.cs new file mode 100644 index 0000000..ed6aa84 --- /dev/null +++ b/src/machines/MachineService/PlcEngine/MachinePlcEngine.cs @@ -0,0 +1,216 @@ +namespace MachineService.PlcEngine; + +public enum MachineType { LaserWelding, PrecisionAssembly, QualityInspection, FunctionalTesting } +public enum PlcState { Init, Ready, Run, Fault, Reset } +public enum StationStatus { Idle, Processing, Complete, Error } + +public record StationDefinition(int Id, string Name, string Type, double ProcessTime, double DefectRate = 0.05); + +public class StationState +{ + public StationDefinition Definition { get; } + public StationStatus Status { get; set; } = StationStatus.Idle; + public double ElapsedTime { get; set; } + public string? CurrentPartId { get; set; } + public string? CurrentPartTracking { get; set; } + public bool CurrentPartHasDefect { get; set; } + + public StationState(StationDefinition def) => Definition = def; +} + +public record LoadedPart(string PartId, string TrackingNumber, bool HasDefect); + +public class MachinePlcEngine +{ + private readonly object _lock = new(); + private readonly Random _random = new(); + private readonly System.Threading.Timer _timer; + + public int MachineId { get; } + public MachineType Type { get; } + public PlcState SequencerState { get; private set; } = PlcState.Init; + public IReadOnlyList Stations { get; } + public int CurrentStationIndex { get; private set; } + public bool IsIndexing { get; private set; } + public double RotaryAngle { get; private set; } + public bool FaultActive { get; private set; } + public string FaultMessage { get; private set; } = string.Empty; + public int PartsEntered { get; private set; } + public int PartsExited { get; private set; } + public LoadedPart? CurrentPart { get; private set; } + public bool IsComplete { get; private set; } + public bool HasDefect => Stations.Any(s => s.CurrentPartHasDefect); + + private double _indexPulseRemaining; + private readonly double _tickSeconds = 0.1; + + public event EventHandler? StateChanged; + + public MachinePlcEngine(int machineId, MachineType type) + { + MachineId = machineId; + Type = type; + Stations = CreateStations(type).Select(d => new StationState(d)).ToList().AsReadOnly(); + _timer = new System.Threading.Timer(Tick, null, TimeSpan.FromSeconds(_tickSeconds), TimeSpan.FromSeconds(_tickSeconds)); + SequencerState = PlcState.Ready; + } + + private static IEnumerable CreateStations(MachineType type) => type switch + { + MachineType.LaserWelding => new[] + { + new StationDefinition(0, "Pre-Heat", "Assembly", 2.0), + new StationDefinition(1, "Laser Weld", "Welding", 3.5, 0.03), + new StationDefinition(2, "Cool Down", "Assembly", 2.0), + new StationDefinition(3, "Weld Inspection", "Inspection", 1.5, 0.02) + }, + MachineType.PrecisionAssembly => new[] + { + new StationDefinition(0, "Component Pick", "Assembly", 1.5), + new StationDefinition(1, "Precision Place", "Assembly", 2.5, 0.04), + new StationDefinition(2, "Screw Drive", "Assembly", 2.0, 0.03), + new StationDefinition(3, "Torque Verify", "Testing", 1.5, 0.02), + new StationDefinition(4, "Vision Check", "Inspection", 1.0, 0.01) + }, + MachineType.QualityInspection => new[] + { + new StationDefinition(0, "Visual Inspect", "Inspection", 2.0, 0.05), + new StationDefinition(1, "Dimension Check", "Inspection", 2.5, 0.04), + new StationDefinition(2, "Surface Scan", "Inspection", 2.0, 0.03), + new StationDefinition(3, "Weight Check", "Testing", 1.0, 0.01) + }, + MachineType.FunctionalTesting => new[] + { + new StationDefinition(0, "Power-On Test", "Testing", 3.0, 0.06), + new StationDefinition(1, "Function Test", "Testing", 4.0, 0.07), + new StationDefinition(2, "Stress Test", "Testing", 3.5, 0.05), + new StationDefinition(3, "Final Verify", "Testing", 2.0, 0.02), + new StationDefinition(4, "Label Print", "Packaging", 1.0) + }, + _ => Array.Empty() + }; + + private void Tick(object? _) + { + lock (_lock) + { + if (CurrentPart == null || SequencerState != PlcState.Run) return; + + var station = Stations[CurrentStationIndex]; + + if (station.Status == StationStatus.Processing) + { + station.ElapsedTime += _tickSeconds; + + // Timeout check (2× process time) + if (station.ElapsedTime > station.Definition.ProcessTime * 2.0) + { + FaultActive = true; + FaultMessage = $"Station '{station.Definition.Name}' exceeded timeout ({station.ElapsedTime:F1}s)"; + SequencerState = PlcState.Fault; + StateChanged?.Invoke(this, EventArgs.Empty); + return; + } + + if (station.ElapsedTime >= station.Definition.ProcessTime) + { + // Station complete + if (_random.NextDouble() < station.Definition.DefectRate) + station.CurrentPartHasDefect = true; + + station.Status = StationStatus.Complete; + + if (CurrentStationIndex < Stations.Count - 1) + { + // Advance to next station + var nextStation = Stations[CurrentStationIndex + 1]; + nextStation.CurrentPartId = station.CurrentPartId; + nextStation.CurrentPartTracking = station.CurrentPartTracking; + nextStation.CurrentPartHasDefect = station.CurrentPartHasDefect; + nextStation.Status = StationStatus.Processing; + nextStation.ElapsedTime = 0; + + station.Status = StationStatus.Idle; + station.CurrentPartId = null; + station.CurrentPartTracking = null; + CurrentStationIndex++; + + RotaryAngle = (RotaryAngle + (360.0 / Stations.Count)) % 360.0; + _indexPulseRemaining = 0.35; + IsIndexing = true; + } + else + { + // Final station complete — machine done + IsComplete = true; + SequencerState = PlcState.Ready; + PartsExited++; + } + } + } + + if (_indexPulseRemaining > 0) + { + _indexPulseRemaining -= _tickSeconds; + if (_indexPulseRemaining <= 0) IsIndexing = false; + } + } + + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public bool LoadPart(string partId, string trackingNumber, bool hasDefect) + { + lock (_lock) + { + if (SequencerState != PlcState.Ready || CurrentPart != null) return false; + + CurrentPart = new LoadedPart(partId, trackingNumber, hasDefect); + IsComplete = false; + CurrentStationIndex = 0; + + var first = Stations[0]; + first.CurrentPartId = partId; + first.CurrentPartTracking = trackingNumber; + first.CurrentPartHasDefect = hasDefect; + first.Status = StationStatus.Processing; + first.ElapsedTime = 0; + + SequencerState = PlcState.Run; + PartsEntered++; + return true; + } + } + + public LoadedPart? UnloadPart() + { + lock (_lock) + { + if (!IsComplete) return null; + var part = CurrentPart; + CurrentPart = null; + IsComplete = false; + + foreach (var s in Stations) + { + s.Status = StationStatus.Idle; + s.CurrentPartId = null; + s.CurrentPartTracking = null; + s.ElapsedTime = 0; + } + return part; + } + } + + public void ResetFault() + { + lock (_lock) + { + FaultActive = false; + FaultMessage = string.Empty; + SequencerState = PlcState.Ready; + } + } + + public void Dispose() => _timer.Dispose(); +} diff --git a/src/machines/MachineService/Program.cs b/src/machines/MachineService/Program.cs new file mode 100644 index 0000000..14218cb --- /dev/null +++ b/src/machines/MachineService/Program.cs @@ -0,0 +1,24 @@ +using MachineService.PlcEngine; +using MachineService.GrpcServices; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +var machineId = int.Parse(builder.Configuration["Machine:Id"] ?? "0"); +var machineType = builder.Configuration["Machine:Type"] ?? "LaserWelding"; + +builder.Services.AddGrpc(); +builder.Services.AddSingleton(sp => + new MachinePlcEngine(machineId, Enum.Parse(machineType))); +builder.Services.AddSingleton(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +app.UseRouting(); +app.UseHttpMetrics(); +app.MapGrpcService(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.Run(); diff --git a/src/machines/MachineService/appsettings.json b/src/machines/MachineService/appsettings.json new file mode 100644 index 0000000..b5d04fe --- /dev/null +++ b/src/machines/MachineService/appsettings.json @@ -0,0 +1,20 @@ +{ + "Machine": { + "Id": "0", + "Type": "LaserWelding" + }, + "ConnectionStrings": { + "Postgres": "Host=postgresql;Database=machine_0;Username=xts;******" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { "Url": "http://0.0.0.0:8080" }, + "Grpc": { "Url": "http://0.0.0.0:9090", "Protocols": "Http2" } + } + } +} diff --git a/src/machines/machine-hmi/index.html b/src/machines/machine-hmi/index.html new file mode 100644 index 0000000..0ba7223 --- /dev/null +++ b/src/machines/machine-hmi/index.html @@ -0,0 +1,16 @@ + + + + + + XTS Machine HMI + + + +
+ + + diff --git a/src/machines/machine-hmi/package.json b/src/machines/machine-hmi/package.json new file mode 100644 index 0000000..5340097 --- /dev/null +++ b/src/machines/machine-hmi/package.json @@ -0,0 +1,24 @@ +{ + "name": "machine-hmi", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.9", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/src/machines/machine-hmi/src/App.tsx b/src/machines/machine-hmi/src/App.tsx new file mode 100644 index 0000000..26eaf96 --- /dev/null +++ b/src/machines/machine-hmi/src/App.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useMachineSignalR } from './signalr/machineHub' +import { RotaryTable } from './components/RotaryTable' +import { StationCard } from './components/StationCard' +import { AlarmBanner } from './components/AlarmBanner' +import { KpiBar } from './components/KpiBar' + +const MACHINE_ID = parseInt(new URLSearchParams(window.location.search).get('machineId') ?? '0') + +export default function App() { + const { status, connected } = useMachineSignalR(MACHINE_ID) + + return ( +
+
+ M{MACHINE_ID} + {status?.name ?? `Machine-${MACHINE_ID}`} + {status?.sequencerState ?? 'INIT'} + + ● {connected ? 'LIVE' : 'OFFLINE'} + +
+ + {status?.faultActive && } + +
+
+ +
+
+ +
+ {(status?.stations ?? []).map(s => ( + + ))} +
+
+
+
+ ) +} + +const styles = { + root: { minHeight: '100vh', display: 'flex', flexDirection: 'column' as const }, + header: { + display: 'flex', alignItems: 'center', gap: 12, padding: '8px 16px', + background: '#111827', borderBottom: '1px solid #1f2937' + }, + machineTag: { + background: '#1d4ed8', color: '#fff', borderRadius: 4, + padding: '2px 8px', fontSize: 13, fontWeight: 700 + }, + machineName: { fontSize: 15, fontWeight: 600, color: '#e2e8f0' }, + stateTag: (state: string) => ({ + fontSize: 11, padding: '2px 8px', borderRadius: 3, fontWeight: 700, + background: state === 'Run' ? '#065f46' : state === 'Fault' ? '#7f1d1d' : state === 'Ready' ? '#1e3a5f' : '#374151', + color: '#fff' + }), + body: { flex: 1, display: 'flex', gap: 16, padding: 16 }, + leftPanel: { width: 320 }, + rightPanel: { flex: 1, display: 'flex', flexDirection: 'column' as const, gap: 12 }, + stationGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 } +} diff --git a/src/machines/machine-hmi/src/components/AlarmBanner.tsx b/src/machines/machine-hmi/src/components/AlarmBanner.tsx new file mode 100644 index 0000000..c523654 --- /dev/null +++ b/src/machines/machine-hmi/src/components/AlarmBanner.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +interface Props { message: string } + +export function AlarmBanner({ message }: Props) { + return ( +
+ + FAULT: {message} +
+ ) +} diff --git a/src/machines/machine-hmi/src/components/KpiBar.tsx b/src/machines/machine-hmi/src/components/KpiBar.tsx new file mode 100644 index 0000000..6a004e2 --- /dev/null +++ b/src/machines/machine-hmi/src/components/KpiBar.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +interface Props { entered: number; exited: number } + +export function KpiBar({ entered, exited }: Props) { + return ( +
+
+
PARTS IN
+
{entered}
+
+
+
PARTS OUT
+
{exited}
+
+
+
EFFICIENCY
+
+ {entered > 0 ? ((exited / entered) * 100).toFixed(0) : 0}% +
+
+
+ ) +} diff --git a/src/machines/machine-hmi/src/components/RotaryTable.tsx b/src/machines/machine-hmi/src/components/RotaryTable.tsx new file mode 100644 index 0000000..5e5e424 --- /dev/null +++ b/src/machines/machine-hmi/src/components/RotaryTable.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +interface Station { + stationId: number + name: string + status: string + elapsedTime: number + processTime: number + currentPartTrackingNumber?: string +} + +interface Props { + stations: Station[] + rotaryAngle: number + isIndexing: boolean +} + +export function RotaryTable({ stations, rotaryAngle, isIndexing }: Props) { + const cx = 150, cy = 150, r = 100 + + return ( +
+
ROTARY TABLE
+ + {/* Outer ring */} + + {/* Rotating table disk */} + + + {/* Station slots on rotary */} + {stations.map((s, i) => { + const angle = (i * 360) / stations.length + const rad = (angle * Math.PI) / 180 + const sx = cx + r * 0.65 * Math.cos(rad) + const sy = cy + r * 0.65 * Math.sin(rad) + const color = s.status === 'Processing' ? '#3b82f6' : s.status === 'Complete' ? '#22c55e' : s.status === 'Error' ? '#ef4444' : '#374151' + return ( + + + {s.stationId} + + ) + })} + {/* Center hub */} + + HUB + + {/* Load position indicator (fixed) */} + + LOAD + +
+ ) +} diff --git a/src/machines/machine-hmi/src/components/StationCard.tsx b/src/machines/machine-hmi/src/components/StationCard.tsx new file mode 100644 index 0000000..b55b24c --- /dev/null +++ b/src/machines/machine-hmi/src/components/StationCard.tsx @@ -0,0 +1,39 @@ +import React from 'react' + +interface Station { + stationId: number + name: string + type: string + status: string + processTime: number + elapsedTime: number + currentPartTrackingNumber?: string +} + +interface Props { station: Station } + +export function StationCard({ station }: Props) { + const pct = station.processTime > 0 ? Math.min(100, (station.elapsedTime / station.processTime) * 100) : 0 + const statusColor = station.status === 'Processing' ? '#3b82f6' : station.status === 'Complete' ? '#22c55e' : station.status === 'Error' ? '#ef4444' : '#4b5563' + + return ( +
+
+ {station.name} + {station.status.toUpperCase()} +
+
{station.type}
+ {/* ET/PT bar */} +
+
+
+
+ ET {station.elapsedTime.toFixed(1)}s + PT {station.processTime.toFixed(1)}s +
+ {station.currentPartTrackingNumber && ( +
↳ {station.currentPartTrackingNumber}
+ )} +
+ ) +} diff --git a/src/machines/machine-hmi/src/main.tsx b/src/machines/machine-hmi/src/main.tsx new file mode 100644 index 0000000..eaad244 --- /dev/null +++ b/src/machines/machine-hmi/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/machines/machine-hmi/src/signalr/machineHub.ts b/src/machines/machine-hmi/src/signalr/machineHub.ts new file mode 100644 index 0000000..dddad9b --- /dev/null +++ b/src/machines/machine-hmi/src/signalr/machineHub.ts @@ -0,0 +1,72 @@ +import * as signalR from '@microsoft/signalr' +import { useState, useEffect } from 'react' + +export interface StationDto { + stationId: number + name: string + type: string + status: string + processTime: number + elapsedTime: number + currentPartTrackingNumber?: string +} + +export interface MachineStatus { + machineId: number + name: string + type: string + sequencerState: string + isOperational: boolean + faultActive: boolean + faultMessage: string + partsEnteredCount: number + partsExitedCount: number + stations: StationDto[] + currentStationIndex: number + isIndexing: boolean + rotaryAngle: number +} + +export function useMachineSignalR(machineId: number) { + const [status, setStatus] = useState(null) + const [connected, setConnected] = useState(false) + + useEffect(() => { + const hubUrl = import.meta.env.VITE_MACHINE_HUB_URL ?? '/hubs/machine' + const connection = new signalR.HubConnectionBuilder() + .withUrl(hubUrl) + .withAutomaticReconnect() + .build() + + connection.on('MachineStatusUpdate', (data: MachineStatus) => setStatus(data)) + + connection.onclose(() => setConnected(false)) + connection.onreconnecting(() => setConnected(false)) + connection.onreconnected(() => { + setConnected(true) + connection.invoke('JoinMachineGroup', machineId).catch(console.error) + }) + + connection.start() + .then(() => { + setConnected(true) + return connection.invoke('JoinMachineGroup', machineId) + }) + .catch(err => console.error('SignalR connection error:', err)) + + // Also poll REST as fallback + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/machine/${machineId}/status`) + if (resp.ok) setStatus(await resp.json()) + } catch { /* ignore */ } + }, 2000) + + return () => { + clearInterval(pollInterval) + connection.stop() + } + }, [machineId]) + + return { status, connected } +} diff --git a/src/machines/machine-hmi/tsconfig.json b/src/machines/machine-hmi/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/src/machines/machine-hmi/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/src/machines/machine-hmi/vite.config.ts b/src/machines/machine-hmi/vite.config.ts new file mode 100644 index 0000000..e5ee2ee --- /dev/null +++ b/src/machines/machine-hmi/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3001, + proxy: { + '/machine': { target: 'http://localhost:8081', changeOrigin: true }, + '/hubs': { target: 'http://localhost:8081', ws: true, changeOrigin: true } + } + } +}) diff --git a/src/prime-mover/PrimeMoverApi/Controllers/MoversController.cs b/src/prime-mover/PrimeMoverApi/Controllers/MoversController.cs new file mode 100644 index 0000000..aa3c2c6 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Controllers/MoversController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Temporalio.Client; +using XtsContracts.Workflows; + +namespace PrimeMoverApi.Controllers; + +[ApiController] +[Route("api/movers")] +public class MoversController : ControllerBase +{ + private readonly ITemporalClient _temporal; + private readonly IHttpClientFactory _factory; + + public MoversController(ITemporalClient temporal, IHttpClientFactory factory) + { + _temporal = temporal; + _factory = factory; + } + + [HttpGet] + public async Task GetMovers() + { + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + var movers = await handle.QueryAsync(w => w.GetMoverPositions()); + return Ok(movers); + } + + [HttpPost("assign-pickup")] + public async Task AssignPickup([FromQuery] int machineId, [FromQuery] string tracking) + { + // Signal prime mover to dispatch a mover for pickup + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + await handle.SignalAsync(w => w.SignalMachinePartReadyAsync( + new PartReadyFromMachineSignal(machineId, tracking, false, "Processed"))); + return Ok(new { dispatched = true }); + } +} diff --git a/src/prime-mover/PrimeMoverApi/Controllers/PartsController.cs b/src/prime-mover/PrimeMoverApi/Controllers/PartsController.cs new file mode 100644 index 0000000..e2bb2b4 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Controllers/PartsController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Temporalio.Client; +using XtsContracts.Workflows; + +namespace PrimeMoverApi.Controllers; + +[ApiController] +[Route("api/parts")] +public class PartsController : ControllerBase +{ + private readonly ITemporalClient _temporal; + + public PartsController(ITemporalClient temporal) => _temporal = temporal; + + [HttpGet("{trackingNumber}")] + public async Task GetPart(string trackingNumber) + { + try + { + var handle = _temporal.GetWorkflowHandle($"part-{trackingNumber}"); + var status = await handle.QueryAsync(w => w.GetCurrentStatus()); + var history = await handle.QueryAsync(w => w.GetStationHistory()); + return Ok(new { Part = status, StationHistory = history }); + } + catch + { + return NotFound(new { message = $"Part {trackingNumber} not found in active workflows" }); + } + } +} diff --git a/src/prime-mover/PrimeMoverApi/Controllers/RecipesController.cs b/src/prime-mover/PrimeMoverApi/Controllers/RecipesController.cs new file mode 100644 index 0000000..6264e91 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Controllers/RecipesController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using XtsContracts.Dtos; + +namespace PrimeMoverApi.Controllers; + +[ApiController] +[Route("api/recipes")] +public class RecipesController : ControllerBase +{ + // In-memory recipe store (replace with PostgreSQL in production) + private static readonly List _recipes = new() + { + new RecipeDto(Guid.NewGuid(), "Standard-LAQF", new[] { 0, 1, 2, 3 }.ToList(), DateTime.UtcNow), + new RecipeDto(Guid.NewGuid(), "Quick-LQ", new[] { 0, 2 }.ToList(), DateTime.UtcNow), + }; + + [HttpGet] + public IActionResult GetAll() => Ok(_recipes); + + [HttpGet("{id:guid}")] + public IActionResult GetById(Guid id) + { + var recipe = _recipes.FirstOrDefault(r => r.RecipeId == id); + return recipe != null ? Ok(recipe) : NotFound(); + } + + [HttpPost] + public IActionResult Create([FromBody] CreateRecipeRequest request) + { + var recipe = new RecipeDto(Guid.NewGuid(), request.Name, request.MachineOrder, DateTime.UtcNow); + _recipes.Add(recipe); + return CreatedAtAction(nameof(GetById), new { id = recipe.RecipeId }, recipe); + } +} + +public record CreateRecipeRequest(string Name, IReadOnlyList MachineOrder); diff --git a/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs new file mode 100644 index 0000000..0891f65 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Mvc; +using PrimeMoverApi.Services; +using Temporalio.Client; +using XtsContracts.Dtos; +using XtsContracts.Workflows; + +namespace PrimeMoverApi.Controllers; + +[ApiController] +[Route("api/system")] +public class SystemController : ControllerBase +{ + private readonly IHttpClientFactory _factory; + private readonly ITemporalClient _temporal; + private readonly MachineAggregatorService _aggregator; + + public SystemController(IHttpClientFactory factory, ITemporalClient temporal, MachineAggregatorService aggregator) + { + _factory = factory; + _temporal = temporal; + _aggregator = aggregator; + } + + [HttpGet("status")] + public async Task GetStatus() + { + var pmClient = _factory.CreateClient("PrimeMoverService"); + var trackStatus = await pmClient.GetFromJsonAsync("/api/track/status"); + var machines = await _aggregator.GetAllMachineStatusesAsync(); + return Ok(new { Track = trackStatus, Machines = machines, Timestamp = DateTime.UtcNow }); + } + + [HttpGet("status/live")] + public async Task GetLiveStatus() + { + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + var status = await handle.QueryAsync(w => w.GetSystemStatus()); + return Ok(status); + } + + [HttpPost("start")] + public async Task Start() + { + var pmClient = _factory.CreateClient("PrimeMoverService"); + await pmClient.PostAsync("/api/track/start", null); + + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + await handle.SignalAsync(w => w.SignalStartProductionAsync()); + return Ok(new { success = true }); + } + + [HttpPost("stop")] + public async Task Stop() + { + var pmClient = _factory.CreateClient("PrimeMoverService"); + await pmClient.PostAsync("/api/track/stop", null); + + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + await handle.SignalAsync(w => w.SignalStopProductionAsync()); + return Ok(new { success = true }); + } + + [HttpPost("set-speed")] + public async Task SetSpeed([FromQuery] double factor) + { + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + await handle.SignalAsync(w => w.SignalSetSpeedAsync(factor)); + return Ok(new { success = true }); + } +} diff --git a/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs new file mode 100644 index 0000000..d563808 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.SignalR; +using XtsContracts.Dtos; + +namespace PrimeMoverApi.Hubs; + +public class SystemHub : Hub +{ + public override async Task OnConnectedAsync() + { + await Groups.AddToGroupAsync(Context.ConnectionId, "system"); + await base.OnConnectedAsync(); + } +} + +public class SystemStatusBroadcaster : BackgroundService +{ + private readonly IHubContext _hub; + private readonly IHttpClientFactory _factory; + private readonly ILogger _logger; + + public SystemStatusBroadcaster(IHubContext hub, IHttpClientFactory factory, ILogger logger) + { + _hub = hub; + _factory = factory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var client = _factory.CreateClient("PrimeMoverService"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var status = await client.GetFromJsonAsync("/api/track/status", stoppingToken); + await _hub.Clients.Group("system").SendAsync("SystemStatusUpdate", status, stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogWarning("System broadcast error: {Error}", ex.Message); + } + + await Task.Delay(100, stoppingToken); + } + } +} diff --git a/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj b/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj new file mode 100644 index 0000000..a2b5dad --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/prime-mover/PrimeMoverApi/Program.cs b/src/prime-mover/PrimeMoverApi/Program.cs new file mode 100644 index 0000000..8a790c7 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Program.cs @@ -0,0 +1,49 @@ +using PrimeMoverApi.Hubs; +using PrimeMoverApi.Services; +using Microsoft.OpenApi.Models; +using Temporalio.Client; +using StackExchange.Redis; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Prime Mover API", Version = "v1" })); + +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +builder.Services.AddSingleton(_ => + ConnectionMultiplexer.Connect(builder.Configuration["Redis:Connection"] ?? "redis:6379")); + +builder.Services.AddSingleton(_ => + TemporalClient.ConnectAsync(new TemporalClientConnectOptions( + builder.Configuration["Temporal:Address"] ?? "temporal:7233") + { Namespace = "xts-system" }).GetAwaiter().GetResult()); + +builder.Services.AddHttpClient("PrimeMoverService", c => + c.BaseAddress = new Uri(builder.Configuration["PrimeMoverService:BaseUrl"] ?? "http://prime-mover-service:8080")); + +// One HTTP client per machine +for (int i = 0; i < 4; i++) +{ + var machineIdx = i; + builder.Services.AddHttpClient($"MachineApi-{i}", c => + c.BaseAddress = new Uri(builder.Configuration[$"MachineApis:Machine{i}"] ?? $"http://machine-api-{i}:8080")); +} + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapHub("/hubs/system"); +app.MapMetrics("/metrics"); +app.Run(); diff --git a/src/prime-mover/PrimeMoverApi/Services/MachineAggregatorService.cs b/src/prime-mover/PrimeMoverApi/Services/MachineAggregatorService.cs new file mode 100644 index 0000000..e1b8b4d --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Services/MachineAggregatorService.cs @@ -0,0 +1,35 @@ +using XtsContracts.Dtos; + +namespace PrimeMoverApi.Services; + +public class MachineAggregatorService +{ + private readonly IHttpClientFactory _factory; + private readonly ILogger _logger; + + public MachineAggregatorService(IHttpClientFactory factory, ILogger logger) + { + _factory = factory; + _logger = logger; + } + + public async Task> GetAllMachineStatusesAsync() + { + var tasks = Enumerable.Range(0, 4).Select(async i => + { + try + { + var client = _factory.CreateClient($"MachineApi-{i}"); + return await client.GetFromJsonAsync($"/machine/{i}/status"); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to get status for Machine {Id}: {Error}", i, ex.Message); + return null; + } + }); + + var results = await Task.WhenAll(tasks); + return results.Where(r => r != null).Select(r => r!).ToList().AsReadOnly(); + } +} diff --git a/src/prime-mover/PrimeMoverApi/appsettings.json b/src/prime-mover/PrimeMoverApi/appsettings.json new file mode 100644 index 0000000..67725e5 --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/appsettings.json @@ -0,0 +1,22 @@ +{ + "Temporal": { + "Address": "temporal:7233" + }, + "PrimeMoverService": { + "BaseUrl": "http://prime-mover-service:8080" + }, + "MachineApis": { + "Machine0": "http://machine-api-0:8080", + "Machine1": "http://machine-api-1:8080", + "Machine2": "http://machine-api-2:8080", + "Machine3": "http://machine-api-3:8080" + }, + "Redis": { + "Connection": "redis:6379" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs new file mode 100644 index 0000000..ab0b644 --- /dev/null +++ b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using PrimeMoverService.TrackEngine; + +namespace PrimeMoverService.Controllers; + +[ApiController] +[Route("api/track")] +public class TrackController : ControllerBase +{ + private readonly XtsTrackEngine _engine; + + public TrackController(XtsTrackEngine engine) => _engine = engine; + + [HttpPost("tick")] + public IActionResult Tick([FromQuery] double speedFactor = 1.0) + { + var result = _engine.Tick(0.1 * speedFactor); + return Ok(result); + } + + [HttpPost("start")] + public IActionResult Start() { _engine.Start(); return Ok(); } + + [HttpPost("stop")] + public IActionResult Stop() { _engine.Stop(); return Ok(); } + + [HttpPost("set-speed")] + public IActionResult SetSpeed([FromQuery] double factor) { _engine.SetSpeed(factor); return Ok(); } + + [HttpGet("status")] + public IActionResult GetStatus() => Ok(new + { + IsRunning = _engine.IsRunning, + TotalParts = _engine.TotalParts, + GoodParts = _engine.GoodParts, + BadParts = _engine.BadParts + }); +} diff --git a/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs new file mode 100644 index 0000000..7ca02be --- /dev/null +++ b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs @@ -0,0 +1,58 @@ +using Grpc.Core; +using PrimeMoverService.TrackEngine; +using XtsContracts.Grpc; + +namespace PrimeMoverService.GrpcServices; + +public class PrimeMoverGrpcService : XtsContracts.Grpc.PrimeMoverService.PrimeMoverServiceBase +{ + private readonly XtsTrackEngine _engine; + + public PrimeMoverGrpcService(XtsTrackEngine engine) => _engine = engine; + + public override Task GetSystemStatus(SystemStatusRequest request, ServerCallContext context) + { + var reply = new SystemStatusReply + { + IsRunning = _engine.IsRunning, + TotalPartsProduced = _engine.TotalParts, + GoodParts = _engine.GoodParts, + BadParts = _engine.BadParts, + TimestampUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + return Task.FromResult(reply); + } + + public override async Task StreamMoverPositions(SystemStatusRequest request, IServerStreamWriter responseStream, ServerCallContext context) + { + while (!context.CancellationToken.IsCancellationRequested) + { + var tick = _engine.Tick(0.1); + var update = new MoverPositionUpdate { TimestampUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + foreach (var m in tick.UpdatedMovers) + { + update.Movers.Add(new XtsContracts.Grpc.MoverStatus + { + MoverId = m.MoverId, + Position = m.Position, + Velocity = m.Velocity, + State = m.State, + LoadedPartTracking = m.LoadedPartTrackingNumber ?? string.Empty + }); + } + await responseStream.WriteAsync(update); + await Task.Delay(100, context.CancellationToken); + } + } + + public override Task SendCommand(SystemCommandRequest request, ServerCallContext context) + { + switch (request.Command.ToLower()) + { + case "start": _engine.Start(); break; + case "stop": _engine.Stop(); break; + case "setspeed": _engine.SetSpeed(request.SpeedFactor); break; + } + return Task.FromResult(new SystemCommandReply { Success = true, Message = $"Command '{request.Command}' executed" }); + } +} diff --git a/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj b/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj new file mode 100644 index 0000000..173b15e --- /dev/null +++ b/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/src/prime-mover/PrimeMoverService/Program.cs b/src/prime-mover/PrimeMoverService/Program.cs new file mode 100644 index 0000000..c3d3427 --- /dev/null +++ b/src/prime-mover/PrimeMoverService/Program.cs @@ -0,0 +1,20 @@ +using PrimeMoverService.TrackEngine; +using PrimeMoverService.GrpcServices; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddGrpc(); +builder.Services.AddControllers(); +builder.Services.AddSingleton(_ => + new XtsTrackEngine(10, new[] { 0, 1, 2, 3 })); +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); + +app.UseRouting(); +app.UseHttpMetrics(); +app.MapGrpcService(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.Run(); diff --git a/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs new file mode 100644 index 0000000..45b35eb --- /dev/null +++ b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs @@ -0,0 +1,131 @@ +using XtsContracts.Dtos; + +namespace PrimeMoverService.TrackEngine; + +public record TrackTickResult( + bool NewPartEntered, Guid? NewPartId, string? NewPartTrackingNumber, + IReadOnlyList? MachineRouteIds, bool PartExited, bool ExitedPartGood, + IReadOnlyList UpdatedMovers); + +public class XtsTrackEngine +{ + private readonly object _lock = new(); + private readonly Random _random = new(); + private readonly List _movers; + private readonly List _machineIds; + private double _speedFactor = 1.0; + private int _trackingCounter; + private bool _running; + + // Constants matching original XTSSimulationEngine + private const double MoverMinGapDegrees = 16.0; + private const double EntryAngle = 205.0; + private const double ExitAngle = 0.0; + private static readonly double[] MachineAngles = { 45, 135, 225, 315 }; + + public int TotalParts { get; private set; } + public int GoodParts { get; private set; } + public int BadParts { get; private set; } + public int Entered { get; private set; } + public bool IsRunning => _running; + + public XtsTrackEngine(int moverCount, IReadOnlyList machineIds) + { + _machineIds = machineIds.ToList(); + _movers = Enumerable.Range(0, moverCount) + .Select(i => new TrackMover(i, i * (360.0 / moverCount))) + .ToList(); + } + + public void Start() { lock (_lock) _running = true; } + public void Stop() { lock (_lock) _running = false; } + public void SetSpeed(double factor) { lock (_lock) _speedFactor = factor; } + + public TrackTickResult Tick(double deltaSeconds) + { + lock (_lock) + { + if (!_running) + return new TrackTickResult(false, null, null, null, false, false, GetMoverDtos()); + + deltaSeconds *= _speedFactor; + + bool partEntered = false; + Guid? newPartId = null; + string? newTracking = null; + List? machineRoute = null; + bool partExited = false; + bool exitedGood = false; + + // Update mover positions + foreach (var m in _movers) + { + if (m.State == TrackMoverState.Moving) + m.Position = (m.Position + m.Velocity * deltaSeconds) % 360.0; + } + + // Entry zone: assign parts to eligible idle movers + var eligibleForEntry = _movers + .Where(m => m.State == TrackMoverState.Idle && m.CurrentPart == null) + .Where(m => IsAtAngle(m.Position, EntryAngle, 5.0)) + .FirstOrDefault(); + + if (eligibleForEntry != null && _movers.Count(m => m.CurrentPart != null) < 6) + { + _trackingCounter++; + var tracking = $"PART-{_trackingCounter:D6}"; + eligibleForEntry.CurrentPart = new TrackPart(Guid.NewGuid(), tracking); + eligibleForEntry.State = TrackMoverState.Loaded; + eligibleForEntry.TargetMachineIndex = 0; + partEntered = true; + newPartId = eligibleForEntry.CurrentPart.PartId; + newTracking = tracking; + machineRoute = _machineIds.ToList(); + Entered++; + } + + // Exit zone: collect completed parts + var atExit = _movers.FirstOrDefault(m => + m.CurrentPart != null && + m.TargetMachineIndex >= _machineIds.Count && + IsAtAngle(m.Position, ExitAngle, 5.0)); + + if (atExit != null) + { + bool good = !atExit.CurrentPart!.HasDefect; + atExit.CurrentPart = null; + atExit.State = TrackMoverState.Idle; + atExit.TargetMachineIndex = -1; + partExited = true; + exitedGood = good; + TotalParts++; + if (good) GoodParts++; else BadParts++; + } + + return new TrackTickResult(partEntered, newPartId, newTracking, machineRoute, partExited, exitedGood, GetMoverDtos()); + } + } + + private IReadOnlyList GetMoverDtos() => + _movers.Select(m => new MoverDto( + m.Id, m.Position, m.Velocity, m.State.ToString(), + m.CurrentPart?.TrackingNumber, m.TargetMachineIndex)).ToList().AsReadOnly(); + + private static bool IsAtAngle(double pos, double target, double tol) => + Math.Abs(pos - target) < tol || Math.Abs(pos - target) > (360 - tol); +} + +public enum TrackMoverState { Idle, Moving, Loaded, AtMachine } + +public class TrackMover +{ + public int Id { get; } + public double Position { get; set; } + public double Velocity { get; set; } = 30.0; + public TrackMoverState State { get; set; } = TrackMoverState.Moving; + public TrackPart? CurrentPart { get; set; } + public int TargetMachineIndex { get; set; } = -1; + public TrackMover(int id, double position) { Id = id; Position = position; } +} + +public record TrackPart(Guid PartId, string TrackingNumber, bool HasDefect = false); diff --git a/src/prime-mover/PrimeMoverService/appsettings.json b/src/prime-mover/PrimeMoverService/appsettings.json new file mode 100644 index 0000000..25d16a1 --- /dev/null +++ b/src/prime-mover/PrimeMoverService/appsettings.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=postgresql;Database=prime_mover;Username=xts;******", + "Redis": "redis:6379" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/src/prime-mover/prime-mover-hmi/index.html b/src/prime-mover/prime-mover-hmi/index.html new file mode 100644 index 0000000..b5ff53b --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/index.html @@ -0,0 +1,16 @@ + + + + + + XTS Prime Mover Control Room + + + +
+ + + diff --git a/src/prime-mover/prime-mover-hmi/package.json b/src/prime-mover/prime-mover-hmi/package.json new file mode 100644 index 0000000..1640ce5 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/package.json @@ -0,0 +1,24 @@ +{ + "name": "prime-mover-hmi", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.9", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/src/prime-mover/prime-mover-hmi/src/App.tsx b/src/prime-mover/prime-mover-hmi/src/App.tsx new file mode 100644 index 0000000..95420b7 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/App.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react' +import { OvalTrack } from './components/OvalTrack' +import { KpiDashboard } from './components/KpiDashboard' +import { MachineMiniHmi } from './components/MachineMiniHmi' +import { ControlBar } from './components/ControlBar' +import { useSystemSignalR } from './signalr/systemHub' + +export default function App() { + const { status, connected } = useSystemSignalR() + const [speed, setSpeed] = useState(1.0) + + return ( +
+
+ ⚙ XTS PRIME MOVER + Temporal Distributed Control Room + + ● {connected ? 'LIVE' : 'RECONNECTING'} + +
+ + + +
+
+ +
+
+ +
+
MACHINE PANELS
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+
+
+
+ ) +} + +const styles = { + root: { minHeight: '100vh', display: 'flex', flexDirection: 'column' as const }, + header: { + display: 'flex', alignItems: 'center', gap: 12, padding: '10px 20px', + background: '#0d1526', borderBottom: '1px solid #1e293b' + }, + logo: { fontSize: 16, fontWeight: 700, color: '#60a5fa', letterSpacing: 1 }, + subtitle: { fontSize: 11, color: '#475569' }, + main: { flex: 1, display: 'flex', gap: 0 }, + trackSection: { flex: 1, padding: 16 }, + sidePanel: { width: 320, padding: 16, background: '#0a1020', borderLeft: '1px solid #1e293b', overflowY: 'auto' as const }, + sectionLabel: { fontSize: 10, color: '#475569', marginBottom: 8, letterSpacing: 1 } +} diff --git a/src/prime-mover/prime-mover-hmi/src/components/ControlBar.tsx b/src/prime-mover/prime-mover-hmi/src/components/ControlBar.tsx new file mode 100644 index 0000000..3dc9e65 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/components/ControlBar.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +interface Props { + isRunning: boolean + speed: number + onSpeedChange: (s: number) => void +} + +export function ControlBar({ isRunning, speed, onSpeedChange }: Props) { + const post = (path: string, body?: unknown) => + fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }) + + return ( +
+ + + + +
+ SPEED + { + const v = parseFloat(e.target.value) + onSpeedChange(v) + post(`/api/system/set-speed?factor=${v}`) + }} + style={{ width: 100 }} + /> + {speed.toFixed(1)}× +
+
+ ) +} diff --git a/src/prime-mover/prime-mover-hmi/src/components/KpiDashboard.tsx b/src/prime-mover/prime-mover-hmi/src/components/KpiDashboard.tsx new file mode 100644 index 0000000..5bfb8dd --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/components/KpiDashboard.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import type { SystemStatus } from '../signalr/systemHub' + +interface Props { status: SystemStatus | null } + +export function KpiDashboard({ status }: Props) { + const oee = status && status.totalPartsProduced > 0 + ? ((status.goodPartsCount / status.totalPartsProduced) * 100).toFixed(1) + : '0.0' + + return ( +
+
PRODUCTION KPIs
+
+ {[ + { label: 'TOTAL', value: status?.totalPartsProduced ?? 0, color: '#60a5fa' }, + { label: 'GOOD', value: status?.goodPartsCount ?? 0, color: '#4ade80' }, + { label: 'BAD', value: status?.badPartsCount ?? 0, color: '#f87171' }, + { label: 'OEE', value: `${oee}%`, color: '#a78bfa' }, + { label: 'ENTERED', value: status?.primeMoverEnteredCount ?? 0, color: '#fbbf24' }, + { label: 'EXITED', value: status?.primeMoverExitedCount ?? 0, color: '#34d399' }, + ].map(kpi => ( +
+
{kpi.label}
+
{kpi.value}
+
+ ))} +
+
+ ) +} diff --git a/src/prime-mover/prime-mover-hmi/src/components/MachineMiniHmi.tsx b/src/prime-mover/prime-mover-hmi/src/components/MachineMiniHmi.tsx new file mode 100644 index 0000000..1ac6b7e --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/components/MachineMiniHmi.tsx @@ -0,0 +1,58 @@ +import React from 'react' + +interface MachineDto { + machineId: number + name: string + type: string + sequencerState: string + isOperational: boolean + faultActive: boolean + faultMessage: string + partsEnteredCount: number + partsExitedCount: number + currentStationIndex: number + isIndexing: boolean +} + +interface Props { machineId: number; machine?: MachineDto } + +const MACHINE_NAMES = ['Laser Welding', 'Precision Assembly', 'Quality Inspection', 'Functional Testing'] + +export function MachineMiniHmi({ machineId, machine }: Props) { + const stateColor = !machine ? '#374151' : machine.faultActive ? '#ef4444' : machine.sequencerState === 'Run' ? '#3b82f6' : '#22c55e' + + return ( +
+
+ + M{machineId} — {MACHINE_NAMES[machineId]} + + + {machine?.sequencerState ?? 'OFFLINE'} + +
+ {machine ? ( +
+ IN:{machine.partsEnteredCount} + OUT:{machine.partsExitedCount} + STA:{machine.currentStationIndex} + {machine.isIndexing && } +
+ ) : ( +
Connecting…
+ )} + {machine?.faultActive && ( +
⚠ {machine.faultMessage}
+ )} + {/* Link to full machine HMI */} + + Open Full HMI → + +
+ ) +} diff --git a/src/prime-mover/prime-mover-hmi/src/components/OvalTrack.tsx b/src/prime-mover/prime-mover-hmi/src/components/OvalTrack.tsx new file mode 100644 index 0000000..beab835 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/components/OvalTrack.tsx @@ -0,0 +1,101 @@ +import React from 'react' + +interface MoverDto { + moverId: number + position: number + velocity: number + state: string + loadedPartTrackingNumber?: string +} + +interface MachineDto { + machineId: number + name: string + sequencerState: string + faultActive: boolean +} + +interface Props { + movers: MoverDto[] + machines: MachineDto[] +} + +const TRACK_W = 580 +const TRACK_H = 320 +const CX = TRACK_W / 2 +const CY = TRACK_H / 2 +const RX = 240 +const RY = 120 + +function polarToXY(angleDeg: number): { x: number; y: number } { + const rad = ((angleDeg - 90) * Math.PI) / 180 + return { + x: CX + RX * Math.cos(rad), + y: CY + RY * Math.sin(rad) + } +} + +const MACHINE_ANGLES = [45, 135, 225, 315] +const MACHINE_LABELS = ['M0 Laser', 'M1 Assembly', 'M2 Quality', 'M3 Test'] +const ENTRY_ANGLE = 205 +const EXIT_ANGLE = 0 + +export function OvalTrack({ movers, machines }: Props) { + return ( +
+
XTS OVAL TRACK
+ + {/* Track lanes */} + + + + {/* Entry zone */} + {(() => { const p = polarToXY(ENTRY_ANGLE); return })()} + {(() => { const p = polarToXY(ENTRY_ANGLE); return ENTRY })()} + + {/* Exit zone */} + {(() => { const p = polarToXY(EXIT_ANGLE); return })()} + {(() => { const p = polarToXY(EXIT_ANGLE); return EXIT })()} + + {/* Machine stations */} + {MACHINE_ANGLES.map((angle, i) => { + const p = polarToXY(angle) + const m = machines[i] + const color = !m ? '#374151' : m.faultActive ? '#7f1d1d' : m.sequencerState === 'Run' ? '#1e3a5f' : '#065f46' + const borderColor = !m ? '#4b5563' : m.faultActive ? '#ef4444' : m.sequencerState === 'Run' ? '#3b82f6' : '#22c55e' + return ( + + + {MACHINE_LABELS[i]} + {m?.sequencerState ?? 'OFFLINE'} + + ) + })} + + {/* Movers */} + {movers.map(m => { + const p = polarToXY(m.position) + const loaded = !!m.loadedPartTrackingNumber + const color = m.state === 'AtMachine' ? '#7c3aed' : loaded ? '#1d4ed8' : '#374151' + return ( + + + + {m.moverId} + + {loaded && } + + ) + })} + + + {/* Legend */} +
+ Empty + Loaded + At Machine + Part +
+
+ ) +} diff --git a/src/prime-mover/prime-mover-hmi/src/main.tsx b/src/prime-mover/prime-mover-hmi/src/main.tsx new file mode 100644 index 0000000..f9be05c --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/main.tsx @@ -0,0 +1,7 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + +) diff --git a/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts b/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts new file mode 100644 index 0000000..bc03f0d --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts @@ -0,0 +1,88 @@ +import * as signalR from '@microsoft/signalr' +import { useState, useEffect } from 'react' + +export interface MoverDto { + moverId: number + position: number + velocity: number + state: string + loadedPartTrackingNumber?: string +} + +export interface StationDto { + stationId: number + name: string + type: string + status: string + processTime: number + elapsedTime: number + currentPartTrackingNumber?: string +} + +export interface MachineDto { + machineId: number + name: string + type: string + sequencerState: string + isOperational: boolean + faultActive: boolean + faultMessage: string + partsEnteredCount: number + partsExitedCount: number + stations: StationDto[] + currentStationIndex: number + isIndexing: boolean + rotaryAngle: number +} + +export interface SystemStatus { + isRunning: boolean + totalPartsProduced: number + goodPartsCount: number + badPartsCount: number + primeMoverEnteredCount: number + primeMoverExitedCount: number + movers: MoverDto[] + machines: MachineDto[] + timestamp: string +} + +export function useSystemSignalR() { + const [status, setStatus] = useState(null) + const [connected, setConnected] = useState(false) + + useEffect(() => { + const hubUrl = import.meta.env.VITE_SYSTEM_HUB_URL ?? '/hubs/system' + const connection = new signalR.HubConnectionBuilder() + .withUrl(hubUrl) + .withAutomaticReconnect() + .build() + + connection.on('SystemStatusUpdate', (data: SystemStatus) => setStatus(data)) + connection.onclose(() => setConnected(false)) + connection.onreconnecting(() => setConnected(false)) + connection.onreconnected(() => setConnected(true)) + + connection.start() + .then(() => setConnected(true)) + .catch(err => console.error('SignalR error:', err)) + + // Poll REST as fallback at 2s + const pollInterval = setInterval(async () => { + try { + const resp = await fetch('/api/system/status') + if (resp.ok) { + const data = await resp.json() + setStatus(data) + } + } catch { /* ignore */ } + }, 2000) + + return () => { + clearInterval(pollInterval) + connection.stop() + } + }, []) + + return { status, connected } +} diff --git a/src/prime-mover/prime-mover-hmi/tsconfig.json b/src/prime-mover/prime-mover-hmi/tsconfig.json new file mode 100644 index 0000000..42e0521 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/src/prime-mover/prime-mover-hmi/vite.config.ts b/src/prime-mover/prime-mover-hmi/vite.config.ts new file mode 100644 index 0000000..3edfee3 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true }, + '/hubs': { target: 'http://localhost:8080', ws: true, changeOrigin: true } + } + } +}) diff --git a/src/shared/Contracts/Dtos/MachineDto.cs b/src/shared/Contracts/Dtos/MachineDto.cs new file mode 100644 index 0000000..d533152 --- /dev/null +++ b/src/shared/Contracts/Dtos/MachineDto.cs @@ -0,0 +1,17 @@ +namespace XtsContracts.Dtos; + +public record MachineDto( + int MachineId, + string Name, + string Type, + string SequencerState, + bool IsOperational, + bool FaultActive, + string FaultMessage, + int PartsEnteredCount, + int PartsExitedCount, + IReadOnlyList Stations, + int CurrentStationIndex, + bool IsIndexing, + double RotaryAngle +); diff --git a/src/shared/Contracts/Dtos/MoverDto.cs b/src/shared/Contracts/Dtos/MoverDto.cs new file mode 100644 index 0000000..87d11bf --- /dev/null +++ b/src/shared/Contracts/Dtos/MoverDto.cs @@ -0,0 +1,10 @@ +namespace XtsContracts.Dtos; + +public record MoverDto( + int MoverId, + double Position, + double Velocity, + string State, + string? LoadedPartTrackingNumber, + int TargetStation +); diff --git a/src/shared/Contracts/Dtos/PartDto.cs b/src/shared/Contracts/Dtos/PartDto.cs new file mode 100644 index 0000000..17601e5 --- /dev/null +++ b/src/shared/Contracts/Dtos/PartDto.cs @@ -0,0 +1,13 @@ +namespace XtsContracts.Dtos; + +public record PartDto( + Guid PartId, + string TrackingNumber, + string Status, + DateTime CreatedAt, + bool HasDefect, + int NextMachineIndex, + int CompletedStations, + string CurrentLocation, + IReadOnlyList ProcessHistory +); diff --git a/src/shared/Contracts/Dtos/RobotDto.cs b/src/shared/Contracts/Dtos/RobotDto.cs new file mode 100644 index 0000000..73d991a --- /dev/null +++ b/src/shared/Contracts/Dtos/RobotDto.cs @@ -0,0 +1,10 @@ +namespace XtsContracts.Dtos; + +public record RobotDto( + int RobotId, + string Name, + string State, + int AssignedMachineId, + double ActionProgress, + string? HeldPartTrackingNumber +); diff --git a/src/shared/Contracts/Dtos/StationDto.cs b/src/shared/Contracts/Dtos/StationDto.cs new file mode 100644 index 0000000..f2ea047 --- /dev/null +++ b/src/shared/Contracts/Dtos/StationDto.cs @@ -0,0 +1,11 @@ +namespace XtsContracts.Dtos; + +public record StationDto( + int StationId, + string Name, + string Type, + string Status, + double ProcessTime, + double ElapsedTime, + string? CurrentPartTrackingNumber +); diff --git a/src/shared/Contracts/Dtos/SystemStatusDto.cs b/src/shared/Contracts/Dtos/SystemStatusDto.cs new file mode 100644 index 0000000..8216481 --- /dev/null +++ b/src/shared/Contracts/Dtos/SystemStatusDto.cs @@ -0,0 +1,29 @@ +namespace XtsContracts.Dtos; + +public record SystemStatusDto( + bool IsRunning, + int TotalPartsProduced, + int GoodPartsCount, + int BadPartsCount, + int PrimeMoverEnteredCount, + int PrimeMoverExitedCount, + IReadOnlyList Movers, + IReadOnlyList Machines, + IReadOnlyList Robots, + DateTime Timestamp +); + +public record RecipeDto( + Guid RecipeId, + string Name, + IReadOnlyList MachineOrder, + DateTime CreatedAt +); + +public record WatchdogEntryDto( + string Key, + string LastObject, + string Message, + int RecoveryCount, + bool IsFaulted +); diff --git a/src/shared/Contracts/Protos/machine_service.proto b/src/shared/Contracts/Protos/machine_service.proto new file mode 100644 index 0000000..74e103a --- /dev/null +++ b/src/shared/Contracts/Protos/machine_service.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; +option csharp_namespace = "XtsContracts.Grpc"; + +package machine_service; + +service MachineService { + rpc GetMachineStatus (MachineStatusRequest) returns (MachineStatusReply); + rpc StreamMachineStatus (MachineStatusRequest) returns (stream MachineStatusReply); + rpc LoadPart (LoadPartRequest) returns (LoadPartReply); + rpc UnloadPart (UnloadPartRequest) returns (UnloadPartReply); + rpc ResetMachine (ResetMachineRequest) returns (ResetMachineReply); + rpc AcknowledgeFault (AcknowledgeFaultRequest) returns (AcknowledgeFaultReply); +} + +message MachineStatusRequest { + int32 machine_id = 1; +} + +message MachineStatusReply { + int32 machine_id = 1; + string machine_name = 2; + string sequencer_state = 3; + bool is_operational = 4; + bool fault_active = 5; + string fault_message = 6; + int32 parts_entered = 7; + int32 parts_exited = 8; + int32 current_station_index = 9; + bool is_indexing = 10; + double rotary_angle = 11; + repeated StationStatus stations = 12; + int64 timestamp_utc_ms = 13; +} + +message StationStatus { + int32 station_id = 1; + string name = 2; + string type = 3; + string status = 4; + double process_time = 5; + double elapsed_time = 6; + string current_part_tracking = 7; +} + +message LoadPartRequest { + int32 machine_id = 1; + string part_id = 2; + string tracking_number = 3; + string part_status = 4; + bool has_defect = 5; +} + +message LoadPartReply { + bool success = 1; + string message = 2; +} + +message UnloadPartRequest { + int32 machine_id = 1; +} + +message UnloadPartReply { + bool success = 1; + string part_id = 2; + string tracking_number = 3; + bool has_defect = 4; + string part_status = 5; +} + +message ResetMachineRequest { + int32 machine_id = 1; +} + +message ResetMachineReply { + bool success = 1; +} + +message AcknowledgeFaultRequest { + int32 machine_id = 1; +} + +message AcknowledgeFaultReply { + bool success = 1; +} diff --git a/src/shared/Contracts/Protos/prime_mover.proto b/src/shared/Contracts/Protos/prime_mover.proto new file mode 100644 index 0000000..0cbb3ae --- /dev/null +++ b/src/shared/Contracts/Protos/prime_mover.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; +option csharp_namespace = "XtsContracts.Grpc"; + +package prime_mover; + +service PrimeMoverService { + rpc GetSystemStatus (SystemStatusRequest) returns (SystemStatusReply); + rpc StreamMoverPositions (SystemStatusRequest) returns (stream MoverPositionUpdate); + rpc SendCommand (SystemCommandRequest) returns (SystemCommandReply); + rpc NotifyMoverArrival (MoverArrivalNotification) returns (MoverArrivalReply); + rpc NotifyPartReady (PartReadyNotification) returns (PartReadyReply); +} + +message SystemStatusRequest {} + +message SystemStatusReply { + bool is_running = 1; + int32 total_parts_produced = 2; + int32 good_parts = 3; + int32 bad_parts = 4; + repeated MoverStatus movers = 5; + int64 timestamp_utc_ms = 6; +} + +message MoverStatus { + int32 mover_id = 1; + double position = 2; + double velocity = 3; + string state = 4; + string loaded_part_tracking = 5; +} + +message MoverPositionUpdate { + repeated MoverStatus movers = 1; + int64 timestamp_utc_ms = 2; +} + +message SystemCommandRequest { + string command = 1; // "start", "stop", "reset" + double speed_factor = 2; +} + +message SystemCommandReply { + bool success = 1; + string message = 2; +} + +message MoverArrivalNotification { + int32 mover_id = 1; + int32 machine_id = 2; + string part_tracking = 3; +} + +message MoverArrivalReply { + bool accepted = 1; +} + +message PartReadyNotification { + int32 machine_id = 1; + string part_tracking = 2; + bool has_defect = 3; + string part_status = 4; +} + +message PartReadyReply { + bool acknowledged = 1; +} diff --git a/src/shared/Contracts/Workflows/IMachineCycleWorkflow.cs b/src/shared/Contracts/Workflows/IMachineCycleWorkflow.cs new file mode 100644 index 0000000..c278a48 --- /dev/null +++ b/src/shared/Contracts/Workflows/IMachineCycleWorkflow.cs @@ -0,0 +1,37 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; + +namespace XtsContracts.Workflows; + +/// +/// One always-running Temporal workflow per machine. +/// Waits for part arrivals, executes station sequence, signals completion. +/// +[Workflow] +public interface IMachineCycleWorkflow +{ + [WorkflowRun] + Task RunAsync(MachineCycleInput input); + + [WorkflowSignal("PartArrived")] + Task SignalPartArrivedAsync(PartArrivalSignal signal); + + [WorkflowSignal("ResetFault")] + Task SignalResetFaultAsync(); + + [WorkflowSignal("Shutdown")] + Task SignalShutdownAsync(); + + [WorkflowQuery("GetMachineStatus")] + MachineDto GetMachineStatus(); +} + +public record MachineCycleInput(int MachineId, string MachineName, string MachineType); + +public record PartArrivalSignal( + string PartId, + string TrackingNumber, + string PartStatus, + bool HasDefect, + string CallbackWorkflowId +); diff --git a/src/shared/Contracts/Workflows/IPartLifecycleWorkflow.cs b/src/shared/Contracts/Workflows/IPartLifecycleWorkflow.cs new file mode 100644 index 0000000..3e3926e --- /dev/null +++ b/src/shared/Contracts/Workflows/IPartLifecycleWorkflow.cs @@ -0,0 +1,58 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; + +namespace XtsContracts.Workflows; + +/// +/// One Temporal workflow instance per part travelling through the XTS line. +/// The workflow history IS the traceability audit trail. +/// +[Workflow] +public interface IPartLifecycleWorkflow +{ + [WorkflowRun] + Task RunAsync(PartLifecycleInput input); + + [WorkflowSignal("MoverAssigned")] + Task SignalMoverAssignedAsync(int moverId); + + [WorkflowSignal("LoadedToMachine")] + Task SignalLoadedToMachineAsync(int machineId); + + [WorkflowSignal("StationComplete")] + Task SignalStationCompleteAsync(int stationId, string stationName, bool hadDefect); + + [WorkflowSignal("UnloadedFromMachine")] + Task SignalUnloadedFromMachineAsync(int machineId, bool hasDefect, string partStatus); + + [WorkflowSignal("Exited")] + Task SignalExitedAsync(bool good); + + [WorkflowQuery("GetCurrentStatus")] + PartDto GetCurrentStatus(); + + [WorkflowQuery("GetStationHistory")] + IReadOnlyList GetStationHistory(); +} + +public record PartLifecycleInput( + Guid PartId, + string TrackingNumber, + IReadOnlyList MachineRouteIds +); + +public record PartLifecycleResult( + string TrackingNumber, + bool Good, + int StationsCompleted, + TimeSpan TotalCycleTime +); + +public record StationEventRecord( + int MachineId, + int StationId, + string StationName, + DateTime StartedAt, + DateTime CompletedAt, + bool HadDefect +); diff --git a/src/shared/Contracts/Workflows/IRobotTransferWorkflow.cs b/src/shared/Contracts/Workflows/IRobotTransferWorkflow.cs new file mode 100644 index 0000000..cdf5184 --- /dev/null +++ b/src/shared/Contracts/Workflows/IRobotTransferWorkflow.cs @@ -0,0 +1,30 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +/// +/// Short-lived child workflow for each robot pick-and-place transfer. +/// Coordinates the 6-step robot state machine with timeout safety. +/// +[Workflow] +public interface IRobotTransferWorkflow +{ + [WorkflowRun] + Task RunAsync(RobotTransferInput input); +} + +public enum TransferDirection { MoverToMachine, MachineToMover } + +public record RobotTransferInput( + int RobotId, + int MoverId, + int MachineId, + TransferDirection Direction, + string PartTrackingNumber +); + +public record RobotTransferResult( + bool Success, + string? FailureReason, + TimeSpan Duration +); diff --git a/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs b/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs new file mode 100644 index 0000000..f3925fb --- /dev/null +++ b/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs @@ -0,0 +1,49 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; + +namespace XtsContracts.Workflows; + +/// +/// Single long-running master workflow owning the XTS oval track. +/// Manages mover queue, routing, and coordinates with machine workflows. +/// +[Workflow] +public interface IXTSPrimeMoverWorkflow +{ + [WorkflowRun] + Task RunAsync(PrimeMoverInput input); + + [WorkflowSignal("StartProduction")] + Task SignalStartProductionAsync(); + + [WorkflowSignal("StopProduction")] + Task SignalStopProductionAsync(); + + [WorkflowSignal("SetSpeed")] + Task SignalSetSpeedAsync(double speedFactor); + + [WorkflowSignal("MachinePartReady")] + Task SignalMachinePartReadyAsync(PartReadyFromMachineSignal signal); + + [WorkflowSignal("MachineFaulted")] + Task SignalMachineFaultedAsync(int machineId, string faultMessage); + + [WorkflowQuery("GetSystemStatus")] + SystemStatusDto GetSystemStatus(); + + [WorkflowQuery("GetMoverPositions")] + IReadOnlyList GetMoverPositions(); +} + +public record PrimeMoverInput( + int MoverCount, + IReadOnlyList MachineIds, + double SimulationSpeedFactor +); + +public record PartReadyFromMachineSignal( + int MachineId, + string PartTrackingNumber, + bool HasDefect, + string PartStatus +); diff --git a/src/shared/Contracts/XtsContracts.csproj b/src/shared/Contracts/XtsContracts.csproj new file mode 100644 index 0000000..0d5ca25 --- /dev/null +++ b/src/shared/Contracts/XtsContracts.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/shared/Database/V1__initial_schema.sql b/src/shared/Database/V1__initial_schema.sql new file mode 100644 index 0000000..82267d9 --- /dev/null +++ b/src/shared/Database/V1__initial_schema.sql @@ -0,0 +1,96 @@ +-- XTS Prime Mover System — Initial PostgreSQL Schema +-- Replaces SQLite-based SimulationDataLogger + +-- Part lifecycle (supplements Temporal workflow history with queryable records) +CREATE TABLE IF NOT EXISTS parts ( + part_id UUID PRIMARY KEY, + tracking_number VARCHAR(32) UNIQUE NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'BaseLayer', + has_defect BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + entered_at TIMESTAMPTZ, + exited_at TIMESTAMPTZ, + machine_route INTEGER[] NOT NULL DEFAULT '{}', + temporal_workflow_id VARCHAR(128) +); + +CREATE INDEX ON parts(tracking_number); +CREATE INDEX ON parts(created_at DESC); +CREATE INDEX ON parts(status); + +-- Station events (per part, per station) +CREATE TABLE IF NOT EXISTS station_events ( + id BIGSERIAL PRIMARY KEY, + part_id UUID REFERENCES parts(part_id), + machine_id INTEGER NOT NULL, + station_id INTEGER NOT NULL, + station_name VARCHAR(64) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + had_defect BOOLEAN NOT NULL DEFAULT FALSE, + elapsed_seconds DOUBLE PRECISION +); + +CREATE INDEX ON station_events(part_id); +CREATE INDEX ON station_events(machine_id, station_id); + +-- Machine runs (aggregate per machine invocation) +CREATE TABLE IF NOT EXISTS machine_runs ( + id BIGSERIAL PRIMARY KEY, + machine_id INTEGER NOT NULL, + part_id UUID REFERENCES parts(part_id), + tracking_number VARCHAR(32) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + success BOOLEAN, + fault_message VARCHAR(512) +); + +CREATE INDEX ON machine_runs(machine_id); +CREATE INDEX ON machine_runs(part_id); + +-- Production snapshots +CREATE TABLE IF NOT EXISTS production_snapshots ( + id BIGSERIAL PRIMARY KEY, + snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + total_parts INTEGER NOT NULL, + good_parts INTEGER NOT NULL, + bad_parts INTEGER NOT NULL, + movers_active INTEGER NOT NULL, + machines_running INTEGER NOT NULL +); + +-- Error logs +CREATE TABLE IF NOT EXISTS error_logs ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + category VARCHAR(64), + source VARCHAR(128), + message TEXT, + stack_trace TEXT, + recovered BOOLEAN DEFAULT FALSE +); + +-- Alarms +CREATE TABLE IF NOT EXISTS alarms ( + id BIGSERIAL PRIMARY KEY, + raised_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cleared_at TIMESTAMPTZ, + machine_id INTEGER, + alarm_code VARCHAR(64), + message TEXT, + severity VARCHAR(16) DEFAULT 'Warning' +); + +-- Recipes +CREATE TABLE IF NOT EXISTS recipes ( + recipe_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(128) NOT NULL, + machine_order INTEGER[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_active BOOLEAN DEFAULT TRUE +); + +-- Insert default recipe +INSERT INTO recipes (name, machine_order) VALUES ('Standard-LAQF', '{0,1,2,3}') ON CONFLICT DO NOTHING; diff --git a/src/temporal/machine-worker/Activities/MachineActivities.cs b/src/temporal/machine-worker/Activities/MachineActivities.cs new file mode 100644 index 0000000..40e3f1f --- /dev/null +++ b/src/temporal/machine-worker/Activities/MachineActivities.cs @@ -0,0 +1,61 @@ +using Temporalio.Activities; +using System.Net.Http.Json; + +namespace MachineWorker.Activities; + +public record StationSequenceResult(bool HasDefect, string FinalStatus, IReadOnlyList StationLog); + +public class MachineActivities +{ + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public MachineActivities(IHttpClientFactory httpFactory, ILogger logger) + { + _httpFactory = httpFactory; + _logger = logger; + } + + [Activity] + public async Task ExecuteStationSequenceAsync( + int machineId, string partId, string trackingNumber, bool initialHasDefect) + { + var client = _httpFactory.CreateClient("MachineService"); + + // Load part into machine + var loadResp = await client.PostAsJsonAsync($"/api/machine/{machineId}/load", new + { + PartId = partId, + TrackingNumber = trackingNumber, + HasDefect = initialHasDefect + }); + loadResp.EnsureSuccessStatusCode(); + + // Poll until machine completes (station sequence driven by machine service) + var timeout = DateTime.UtcNow.AddSeconds(90); + while (DateTime.UtcNow < timeout) + { + var status = await client.GetFromJsonAsync( + $"/api/machine/{machineId}/completion-status"); + + if (status?.IsComplete == true) + { + _logger.LogInformation("Machine {Id} completed part {Tracking}", machineId, trackingNumber); + return new StationSequenceResult(status.HasDefect, status.FinalPartStatus, status.StationLog); + } + + if (status?.IsFaulted == true) + { + throw new ApplicationException($"Machine {machineId} faulted: {status.FaultMessage}"); + } + + // Heartbeat so Temporal knows we're still alive (prevents timeout) + ActivityExecutionContext.Current.Heartbeat($"machine-{machineId} polling for part {trackingNumber}"); + await Task.Delay(500); + } + + throw new TimeoutException($"Machine {machineId} did not complete part {trackingNumber} within timeout"); + } +} + +public record MachineCompletionStatus(bool IsComplete, bool IsFaulted, string FaultMessage, bool HasDefect, string FinalPartStatus, IReadOnlyList StationLog); diff --git a/src/temporal/machine-worker/MachineCycleWorkflowStarter.cs b/src/temporal/machine-worker/MachineCycleWorkflowStarter.cs new file mode 100644 index 0000000..57faf84 --- /dev/null +++ b/src/temporal/machine-worker/MachineCycleWorkflowStarter.cs @@ -0,0 +1,47 @@ +using Temporalio.Client; +using XtsContracts.Workflows; +using MachineWorker.Workflows; + +namespace MachineWorker; + +/// +/// Starts or connects to the always-running MachineCycleWorkflow on worker startup. +/// Uses workflow ID collision (GetOrStartWorkflow) so restarts are idempotent. +/// +public class MachineCycleWorkflowStarter : IHostedService +{ + private readonly ITemporalClient _client; + private readonly int _machineId; + private readonly string _machineName; + private readonly string _machineType; + + public MachineCycleWorkflowStarter(ITemporalClient client, int machineId, string machineName, string machineType) + { + _client = client; + _machineId = machineId; + _machineName = machineName; + _machineType = machineType; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var workflowId = $"machine-cycle-{_machineId}"; + try + { + await _client.StartWorkflowAsync( + (MachineCycleWorkflow w) => w.RunAsync(new MachineCycleInput(_machineId, _machineName, _machineType)), + new WorkflowOptions + { + Id = workflowId, + TaskQueue = $"xts-machine-{_machineId}-queue", + IdReusePolicy = Temporalio.Api.Enums.V1.WorkflowIdReusePolicy.AllowDuplicateFailedOnly + }); + } + catch (Temporalio.Exceptions.WorkflowAlreadyStartedException) + { + // Workflow already running — this is fine (idempotent) + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/temporal/machine-worker/MachineWorker.csproj b/src/temporal/machine-worker/MachineWorker.csproj new file mode 100644 index 0000000..e45ea60 --- /dev/null +++ b/src/temporal/machine-worker/MachineWorker.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/temporal/machine-worker/Program.cs b/src/temporal/machine-worker/Program.cs new file mode 100644 index 0000000..07992b4 --- /dev/null +++ b/src/temporal/machine-worker/Program.cs @@ -0,0 +1,30 @@ +using MachineWorker.Workflows; +using MachineWorker.Activities; +using MachineWorker; +using Temporalio.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +var machineId = int.Parse(builder.Configuration["Machine:Id"] ?? "0"); +var machineName = builder.Configuration["Machine:Name"] ?? $"Machine-{machineId}"; +var machineType = builder.Configuration["Machine:Type"] ?? "LaserWelding"; +var temporalAddress = builder.Configuration["Temporal:Address"] ?? "localhost:7233"; + +builder.Services.AddTemporalClient(opts => +{ + opts.TargetHost = temporalAddress; + opts.Namespace = "xts-system"; +}); + +builder.Services.AddHostedTemporalWorker($"xts-machine-{machineId}-queue") + .AddWorkflow() + .AddScopedActivities(); + +// Auto-start the MachineCycleWorkflow when the worker starts +builder.Services.AddHostedService(sp => + new MachineCycleWorkflowStarter( + sp.GetRequiredService(), + machineId, machineName, machineType)); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/temporal/machine-worker/Workflows/MachineCycleWorkflow.cs b/src/temporal/machine-worker/Workflows/MachineCycleWorkflow.cs new file mode 100644 index 0000000..fae6045 --- /dev/null +++ b/src/temporal/machine-worker/Workflows/MachineCycleWorkflow.cs @@ -0,0 +1,125 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; +using XtsContracts.Workflows; +using MachineWorker.Activities; + +namespace MachineWorker.Workflows; + +/// +/// Always-running workflow for each physical machine. +/// Waits for parts, executes station sequences, signals prime mover on completion. +/// Temporal's retry policy replaces the custom watchdog escalation logic. +/// +[Workflow] +public class MachineCycleWorkflow : IMachineCycleWorkflow +{ + private MachineDto _machineStatus; + private readonly Queue _pendingParts = new(); + private bool _faultResetRequested; + private bool _shutdown; + + public MachineCycleWorkflow() + { + _machineStatus = new MachineDto(0, "Unknown", "Unknown", "Init", true, false, string.Empty, 0, 0, new List(), 0, false, 0); + } + + [WorkflowRun] + public async Task RunAsync(MachineCycleInput input) + { + _machineStatus = _machineStatus with + { + MachineId = input.MachineId, + Name = input.MachineName, + Type = input.MachineType, + SequencerState = "Ready" + }; + + Workflow.Logger.LogInformation("MachineCycleWorkflow started for Machine {Id} ({Name})", input.MachineId, input.MachineName); + + while (!_shutdown) + { + // Wait for a part to arrive or shutdown + await Workflow.WaitConditionAsync(() => _pendingParts.Count > 0 || _shutdown); + + if (_shutdown) break; + + var partSignal = _pendingParts.Dequeue(); + Workflow.Logger.LogInformation("Machine {Id} received part {Tracking}", input.MachineId, partSignal.TrackingNumber); + + _machineStatus = _machineStatus with { SequencerState = "Run" }; + + try + { + // Execute each station as a Temporal activity (with built-in timeout/retry) + var stationResult = await Workflow.ExecuteActivityAsync( + (MachineActivities a) => a.ExecuteStationSequenceAsync( + input.MachineId, partSignal.PartId, partSignal.TrackingNumber, partSignal.HasDefect), + new ActivityOptions + { + StartToCloseTimeout = TimeSpan.FromSeconds(120), + RetryPolicy = new Temporalio.Common.RetryPolicy + { + MaximumAttempts = 3, + InitialInterval = TimeSpan.FromSeconds(2) + } + }); + + _machineStatus = _machineStatus with + { + PartsExitedCount = _machineStatus.PartsExitedCount + 1, + SequencerState = "Ready" + }; + + // Signal the calling PartLifecycleWorkflow that this machine is done + if (!string.IsNullOrEmpty(partSignal.CallbackWorkflowId)) + { + var handle = Workflow.GetExternalWorkflowHandle(partSignal.CallbackWorkflowId); + await handle.SignalAsync(w => w.SignalUnloadedFromMachineAsync( + input.MachineId, stationResult.HasDefect, stationResult.FinalStatus)); + } + } + catch (Exception ex) + { + Workflow.Logger.LogError("Machine {Id} station sequence failed: {Error}", input.MachineId, ex.Message); + _machineStatus = _machineStatus with + { + FaultActive = true, + FaultMessage = ex.Message, + SequencerState = "Fault" + }; + + // Wait for fault reset + await Workflow.WaitConditionAsync(() => _faultResetRequested || _shutdown); + _faultResetRequested = false; + _machineStatus = _machineStatus with { FaultActive = false, FaultMessage = string.Empty, SequencerState = "Ready" }; + } + } + + Workflow.Logger.LogInformation("MachineCycleWorkflow shutting down for Machine {Id}", input.MachineId); + } + + [WorkflowSignal("PartArrived")] + public Task SignalPartArrivedAsync(PartArrivalSignal signal) + { + _pendingParts.Enqueue(signal); + _machineStatus = _machineStatus with { PartsEnteredCount = _machineStatus.PartsEnteredCount + 1 }; + return Task.CompletedTask; + } + + [WorkflowSignal("ResetFault")] + public Task SignalResetFaultAsync() + { + _faultResetRequested = true; + return Task.CompletedTask; + } + + [WorkflowSignal("Shutdown")] + public Task SignalShutdownAsync() + { + _shutdown = true; + return Task.CompletedTask; + } + + [WorkflowQuery("GetMachineStatus")] + public MachineDto GetMachineStatus() => _machineStatus; +} diff --git a/src/temporal/machine-worker/appsettings.json b/src/temporal/machine-worker/appsettings.json new file mode 100644 index 0000000..f4da91a --- /dev/null +++ b/src/temporal/machine-worker/appsettings.json @@ -0,0 +1,18 @@ +{ + "Temporal": { + "Address": "temporal:7233" + }, + "Machine": { + "Id": "0", + "Name": "LaserWelding", + "Type": "LaserWelding" + }, + "MachineService": { + "BaseUrl": "http://machine-service:8080" + }, + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/src/temporal/master-worker/Activities/PrimeMoverActivities.cs b/src/temporal/master-worker/Activities/PrimeMoverActivities.cs new file mode 100644 index 0000000..777a56a --- /dev/null +++ b/src/temporal/master-worker/Activities/PrimeMoverActivities.cs @@ -0,0 +1,92 @@ +using Temporalio.Activities; +using XtsContracts.Dtos; +using System.Net.Http.Json; + +namespace MasterWorker.Activities; + +public record TrackTickResult( + bool NewPartEntered, + Guid? NewPartId, + string? NewPartTrackingNumber, + IReadOnlyList? MachineRouteIds, + bool PartExited, + bool ExitedPartGood, + IReadOnlyList? UpdatedMovers +); + +public class PrimeMoverActivities +{ + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public PrimeMoverActivities(IHttpClientFactory httpFactory, ILogger logger) + { + _httpFactory = httpFactory; + _logger = logger; + } + + [Activity] + public async Task TickTrackEngineAsync(double speedFactor) + { + var client = _httpFactory.CreateClient("PrimeMoverApi"); + var response = await client.PostAsync($"/api/track/tick?speedFactor={speedFactor}", null); + if (!response.IsSuccessStatusCode) + { + throw new ApplicationException($"Track tick failed: {response.StatusCode}"); + } + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Null tick result"); + return result; + } + + [Activity] + public async Task AssignMoverForPickupAsync(int machineId, string partTrackingNumber) + { + var client = _httpFactory.CreateClient("PrimeMoverApi"); + var response = await client.PostAsync($"/api/movers/assign-pickup?machineId={machineId}&tracking={partTrackingNumber}", null); + response.EnsureSuccessStatusCode(); + _logger.LogInformation("Mover assigned for pickup from Machine {MachineId}, Part {Tracking}", machineId, partTrackingNumber); + } + + [Activity] + public async Task RobotPickFromMoverAsync(int robotId, int moverId, string partTracking) + { + await Task.Delay(TimeSpan.FromMilliseconds(800)); + _logger.LogInformation("Robot {Robot} picked from Mover {Mover}, Part={Part}", robotId, moverId, partTracking); + } + + [Activity] + public async Task RobotMoveToMachineAsync(int robotId, int machineId) + { + await Task.Delay(TimeSpan.FromMilliseconds(600)); + _logger.LogInformation("Robot {Robot} moving to Machine {Machine}", robotId, machineId); + } + + [Activity] + public async Task RobotPlaceInMachineAsync(int robotId, int machineId, string partTracking) + { + await Task.Delay(TimeSpan.FromMilliseconds(800)); + _logger.LogInformation("Robot {Robot} placed Part {Part} in Machine {Machine}", robotId, partTracking, machineId); + } + + [Activity] + public async Task RobotPickFromMachineAsync(int robotId, int machineId, string partTracking) + { + await Task.Delay(TimeSpan.FromMilliseconds(800)); + _logger.LogInformation("Robot {Robot} picked Part {Part} from Machine {Machine}", robotId, partTracking, machineId); + } + + [Activity] + public async Task RobotMoveToMoverAsync(int robotId, int moverId) + { + await Task.Delay(TimeSpan.FromMilliseconds(600)); + _logger.LogInformation("Robot {Robot} moving to Mover {Mover}", robotId, moverId); + } + + [Activity] + public async Task RobotPlaceOnMoverAsync(int robotId, int moverId, string partTracking) + { + await Task.Delay(TimeSpan.FromMilliseconds(800)); + _logger.LogInformation("Robot {Robot} placed Part {Part} on Mover {Mover}", robotId, partTracking, moverId); + } +} diff --git a/src/temporal/master-worker/MasterWorker.csproj b/src/temporal/master-worker/MasterWorker.csproj new file mode 100644 index 0000000..9aabec1 --- /dev/null +++ b/src/temporal/master-worker/MasterWorker.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/temporal/master-worker/Program.cs b/src/temporal/master-worker/Program.cs new file mode 100644 index 0000000..eaefa12 --- /dev/null +++ b/src/temporal/master-worker/Program.cs @@ -0,0 +1,28 @@ +using MasterWorker.Workflows; +using MasterWorker.Activities; +using Temporalio.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +var temporalAddress = builder.Configuration["Temporal:Address"] ?? "localhost:7233"; +var temporalNamespace = builder.Configuration["Temporal:Namespace"] ?? "xts-system"; + +builder.Services.AddTemporalClient(opts => +{ + opts.TargetHost = temporalAddress; + opts.Namespace = temporalNamespace; +}); + +builder.Services.AddHostedTemporalWorker("xts-prime-mover-queue") + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddScopedActivities(); + +builder.Services.AddHttpClient("MachineApi", client => +{ + client.BaseAddress = new Uri(builder.Configuration["MachineApi:BaseUrl"] ?? "http://machine-api:8080"); +}); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs new file mode 100644 index 0000000..a06ee00 --- /dev/null +++ b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs @@ -0,0 +1,145 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; +using XtsContracts.Workflows; +using MasterWorker.Activities; + +namespace MasterWorker.Workflows; + +/// +/// One workflow instance per part. The Temporal event history IS the part's audit trail. +/// No separate database table needed for part events — query this workflow directly. +/// +[Workflow] +public class PartLifecycleWorkflow : IPartLifecycleWorkflow +{ + private PartDto _currentStatus; + private readonly List _stationHistory = new(); + private readonly DateTime _startedAt = DateTime.UtcNow; + private bool _exited; + + // Awaitable signals (using TaskCompletionSource-equivalent pattern in Temporal .NET SDK) + private int? _assignedMoverId; + private int? _loadedMachineId; + private bool _stationCompleted; + private int? _unloadedMachineId; + private bool _unloadedHasDefect; + private string? _unloadedPartStatus; + private bool _exitedGood; + private StationEventRecord? _pendingStationEvent; + + public PartLifecycleWorkflow() + { + _currentStatus = new PartDto(Guid.Empty, string.Empty, "Empty", DateTime.UtcNow, false, 0, 0, "Entry", new List()); + } + + [WorkflowRun] + public async Task RunAsync(PartLifecycleInput input) + { + _currentStatus = _currentStatus with + { + PartId = input.PartId, + TrackingNumber = input.TrackingNumber, + Status = "BaseLayer", + CreatedAt = DateTime.UtcNow, + CurrentLocation = "Entry" + }; + + Workflow.Logger.LogInformation("Part {Tracking} lifecycle started. Route: [{Route}]", + input.TrackingNumber, string.Join("->", input.MachineRouteIds)); + + // Route through each machine + foreach (var machineId in input.MachineRouteIds) + { + // Wait for mover assignment + await Workflow.WaitConditionAsync(() => _assignedMoverId.HasValue); + _currentStatus = _currentStatus with { CurrentLocation = $"Mover-{_assignedMoverId}" }; + + // Wait for load into machine + await Workflow.WaitConditionAsync(() => _loadedMachineId.HasValue); + _currentStatus = _currentStatus with { CurrentLocation = $"Machine-{machineId}" }; + + // Wait for station processing + unload + await Workflow.WaitConditionAsync(() => _unloadedMachineId.HasValue); + + var history = (_currentStatus.ProcessHistory as List ?? new List()); + history.Add($"{DateTime.UtcNow:HH:mm:ss.fff} - Machine-{machineId} complete, defect={_unloadedHasDefect}"); + + _currentStatus = _currentStatus with + { + HasDefect = _currentStatus.HasDefect || _unloadedHasDefect, + Status = _unloadedPartStatus ?? _currentStatus.Status, + NextMachineIndex = _currentStatus.NextMachineIndex + 1, + ProcessHistory = history.AsReadOnly(), + CurrentLocation = $"Mover-{_assignedMoverId}" + }; + + // Reset for next machine + _assignedMoverId = null; + _loadedMachineId = null; + _unloadedMachineId = null; + } + + // Wait for exit signal + await Workflow.WaitConditionAsync(() => _exited); + + var cycleTime = DateTime.UtcNow - _startedAt; + var good = !_currentStatus.HasDefect; + + Workflow.Logger.LogInformation("Part {Tracking} exited. Good={Good}, CycleTime={Time:F2}s", + input.TrackingNumber, good, cycleTime.TotalSeconds); + + return new PartLifecycleResult(input.TrackingNumber, good, _currentStatus.CompletedStations, cycleTime); + } + + [WorkflowSignal("MoverAssigned")] + public Task SignalMoverAssignedAsync(int moverId) + { + _assignedMoverId = moverId; + return Task.CompletedTask; + } + + [WorkflowSignal("LoadedToMachine")] + public Task SignalLoadedToMachineAsync(int machineId) + { + _loadedMachineId = machineId; + return Task.CompletedTask; + } + + [WorkflowSignal("StationComplete")] + public Task SignalStationCompleteAsync(int stationId, string stationName, bool hadDefect) + { + _stationHistory.Add(new StationEventRecord( + _loadedMachineId ?? 0, stationId, stationName, + DateTime.UtcNow.AddSeconds(-1), DateTime.UtcNow, hadDefect)); + _currentStatus = _currentStatus with { CompletedStations = _currentStatus.CompletedStations + 1 }; + return Task.CompletedTask; + } + + [WorkflowSignal("UnloadedFromMachine")] + public Task SignalUnloadedFromMachineAsync(int machineId, bool hasDefect, string partStatus) + { + _unloadedMachineId = machineId; + _unloadedHasDefect = hasDefect; + _unloadedPartStatus = partStatus; + return Task.CompletedTask; + } + + [WorkflowSignal("Exited")] + public Task SignalExitedAsync(bool good) + { + _exited = true; + _exitedGood = good; + _currentStatus = _currentStatus with + { + Status = good ? "Good" : "Bad", + CurrentLocation = "Exit" + }; + return Task.CompletedTask; + } + + [WorkflowQuery("GetCurrentStatus")] + public PartDto GetCurrentStatus() => _currentStatus; + + [WorkflowQuery("GetStationHistory")] + public IReadOnlyList GetStationHistory() => _stationHistory.AsReadOnly(); +} diff --git a/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs b/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs new file mode 100644 index 0000000..bc3a85a --- /dev/null +++ b/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs @@ -0,0 +1,66 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using MasterWorker.Activities; + +namespace MasterWorker.Workflows; + +/// +/// Short-lived child workflow for each robot pick-and-place cycle. +/// Replaces the robot state machine in XTSSimulationEngine. +/// +[Workflow] +public class RobotTransferWorkflow : IRobotTransferWorkflow +{ + [WorkflowRun] + public async Task RunAsync(RobotTransferInput input) + { + var started = DateTime.UtcNow; + Workflow.Logger.LogInformation("RobotTransfer: Robot={Robot} {Direction} Part={Part}", + input.RobotId, input.Direction, input.PartTrackingNumber); + + try + { + if (input.Direction == TransferDirection.MoverToMachine) + { + // Step 1: Pick from mover + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotPickFromMoverAsync(input.RobotId, input.MoverId, input.PartTrackingNumber), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + // Step 2: Move to machine + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotMoveToMachineAsync(input.RobotId, input.MachineId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + // Step 3: Place in machine + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotPlaceInMachineAsync(input.RobotId, input.MachineId, input.PartTrackingNumber), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + } + else + { + // Step 1: Pick from machine + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotPickFromMachineAsync(input.RobotId, input.MachineId, input.PartTrackingNumber), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + // Step 2: Move to mover + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotMoveToMoverAsync(input.RobotId, input.MoverId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + // Step 3: Place on mover + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.RobotPlaceOnMoverAsync(input.RobotId, input.MoverId, input.PartTrackingNumber), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + } + + return new RobotTransferResult(true, null, DateTime.UtcNow - started); + } + catch (Exception ex) + { + Workflow.Logger.LogError("RobotTransfer failed: Robot={Robot}, Error={Error}", input.RobotId, ex.Message); + return new RobotTransferResult(false, ex.Message, DateTime.UtcNow - started); + } + } +} diff --git a/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs b/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs new file mode 100644 index 0000000..5722115 --- /dev/null +++ b/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs @@ -0,0 +1,157 @@ +using Temporalio.Workflows; +using XtsContracts.Dtos; +using XtsContracts.Workflows; +using MasterWorker.Activities; + +namespace MasterWorker.Workflows; + +[Workflow] +public class XTSPrimeMoverWorkflow : IXTSPrimeMoverWorkflow +{ + // Mutable workflow state — safe because Temporal guarantees single-threaded execution + private readonly List _movers = new(); + private readonly List _machines = new(); + private readonly List _robots = new(); + private bool _running; + private double _speedFactor = 1.0; + private int _totalParts; + private int _goodParts; + private int _badParts; + private int _entered; + private bool _shouldStop; + + // Signals queue (CAS-safe via Temporal's single-threaded model) + private readonly Queue _pendingMachineReadySignals = new(); + private readonly Dictionary _machineFaults = new(); + + [WorkflowRun] + public async Task RunAsync(PrimeMoverInput input) + { + _speedFactor = input.SimulationSpeedFactor; + + // Initialize movers + for (int i = 0; i < input.MoverCount; i++) + { + _movers.Add(new MoverDto(i, i * (360.0 / input.MoverCount), 30.0, "Idle", null, -1)); + } + + // Initialize machine stubs + foreach (var machineId in input.MachineIds) + { + _machines.Add(new MachineDto(machineId, $"Machine-{machineId}", "Unknown", "Init", true, false, string.Empty, 0, 0, new List(), 0, false, 0)); + } + + Workflow.Logger.LogInformation("XTSPrimeMoverWorkflow started. Movers={Count}, Machines={Machines}", + input.MoverCount, string.Join(",", input.MachineIds)); + + // Main production loop — continues until shutdown signal + while (!_shouldStop) + { + if (_running) + { + // Process any machine-ready signals + while (_pendingMachineReadySignals.TryDequeue(out var readySignal)) + { + await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.AssignMoverForPickupAsync(readySignal.MachineId, readySignal.PartTrackingNumber), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + } + + // Tick the track engine via activity (side-effecting simulation step) + var tick = await Workflow.ExecuteActivityAsync( + (PrimeMoverActivities a) => a.TickTrackEngineAsync(_speedFactor), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(5) }); + + if (tick.NewPartEntered) + { + _entered++; + // Spawn a PartLifecycleWorkflow for this part + await Workflow.StartChildWorkflowAsync( + (PartLifecycleWorkflow w) => w.RunAsync(new PartLifecycleInput( + tick.NewPartId!.Value, + tick.NewPartTrackingNumber!, + tick.MachineRouteIds ?? new List())), + new ChildWorkflowOptions + { + Id = $"part-{tick.NewPartTrackingNumber}", + TaskQueue = "xts-prime-mover-queue" + }); + } + + if (tick.PartExited) + { + _totalParts++; + if (tick.ExitedPartGood) _goodParts++; + else _badParts++; + } + + // Update mover states from tick result + if (tick.UpdatedMovers != null) + { + _movers.Clear(); + _movers.AddRange(tick.UpdatedMovers); + } + } + + // Wait ~100ms scaled by speed before next tick + await Workflow.DelayAsync(TimeSpan.FromMilliseconds(100)); + } + + Workflow.Logger.LogInformation("XTSPrimeMoverWorkflow stopped. TotalParts={Total}", _totalParts); + } + + [WorkflowSignal("StartProduction")] + public Task SignalStartProductionAsync() + { + _running = true; + return Task.CompletedTask; + } + + [WorkflowSignal("StopProduction")] + public Task SignalStopProductionAsync() + { + _running = false; + return Task.CompletedTask; + } + + [WorkflowSignal("SetSpeed")] + public Task SignalSetSpeedAsync(double speedFactor) + { + _speedFactor = speedFactor; + return Task.CompletedTask; + } + + [WorkflowSignal("MachinePartReady")] + public Task SignalMachinePartReadyAsync(PartReadyFromMachineSignal signal) + { + _pendingMachineReadySignals.Enqueue(signal); + return Task.CompletedTask; + } + + [WorkflowSignal("MachineFaulted")] + public Task SignalMachineFaultedAsync(int machineId, string faultMessage) + { + _machineFaults[machineId] = faultMessage; + Workflow.Logger.LogWarning("Machine {MachineId} faulted: {Fault}", machineId, faultMessage); + return Task.CompletedTask; + } + + [WorkflowSignal("Shutdown")] + public Task SignalShutdownAsync() + { + _shouldStop = true; + _running = false; + return Task.CompletedTask; + } + + [WorkflowQuery("GetSystemStatus")] + public SystemStatusDto GetSystemStatus() => + new SystemStatusDto( + _running, _totalParts, _goodParts, _badParts, _entered, + _goodParts + _badParts, + _movers.AsReadOnly(), _machines.AsReadOnly(), _robots.AsReadOnly(), + DateTime.UtcNow); + + [WorkflowQuery("GetMoverPositions")] + public IReadOnlyList GetMoverPositions() => _movers.AsReadOnly(); +} diff --git a/src/temporal/master-worker/appsettings.json b/src/temporal/master-worker/appsettings.json new file mode 100644 index 0000000..8bec929 --- /dev/null +++ b/src/temporal/master-worker/appsettings.json @@ -0,0 +1,18 @@ +{ + "Temporal": { + "Address": "temporal:7233", + "Namespace": "xts-system" + }, + "MachineApi": { + "BaseUrl": "http://machine-api:8080" + }, + "PrimeMoverApi": { + "BaseUrl": "http://prime-mover-api:8080" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} From 6315074d5021f757ba2c3d6db7ff10dc3f39ea5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:36:51 +0000 Subject: [PATCH 02/10] Apply remaining changes --- docker/machine.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/machine.Dockerfile b/docker/machine.Dockerfile index d31bc84..29f3850 100644 --- a/docker/machine.Dockerfile +++ b/docker/machine.Dockerfile @@ -29,7 +29,7 @@ COPY src/machines/MachineApi/ /build/machines/MachineApi/ WORKDIR /build/machines/MachineApi RUN dotnet restore && dotnet publish -c Release -o /publish WORKDIR /app -COPY --from=0 /publish . +COPY --from=build /publish . ENV ASPNETCORE_URLS=http://+:8081 EXPOSE 8081 ENTRYPOINT ["dotnet", "MachineApi.dll"] From c0c4f844e4664b6018229878c49b7a1ca71c34dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:56:15 +0000 Subject: [PATCH 03/10] Complete remaining implementations: HTTP clients, workflow starter, gRPC methods, health checks, typed broadcasts, proxy endpoints --- src/machines/MachineApi/Hubs/MachineHub.cs | 19 +++-- .../GrpcServices/MachineGrpcService.cs | 33 ++++++++ src/machines/MachineService/Program.cs | 2 + .../Controllers/SystemController.cs | 39 ++++++++++ .../PrimeMoverApi/Hubs/SystemHub.cs | 45 ++++++++++- .../Controllers/TrackController.cs | 3 +- .../GrpcServices/PrimeMoverGrpcService.cs | 19 ++++- src/prime-mover/PrimeMoverService/Program.cs | 2 + .../TrackEngine/XtsTrackEngine.cs | 47 ++++++++++++ .../prime-mover-hmi/vite.config.ts | 6 +- src/temporal/machine-worker/Program.cs | 7 ++ src/temporal/master-worker/Program.cs | 21 +++++- .../XTSPrimeMoverWorkflowStarter.cs | 75 +++++++++++++++++++ src/temporal/master-worker/appsettings.json | 7 +- 14 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 src/temporal/master-worker/XTSPrimeMoverWorkflowStarter.cs diff --git a/src/machines/MachineApi/Hubs/MachineHub.cs b/src/machines/MachineApi/Hubs/MachineHub.cs index 504974d..acd24c2 100644 --- a/src/machines/MachineApi/Hubs/MachineHub.cs +++ b/src/machines/MachineApi/Hubs/MachineHub.cs @@ -24,12 +24,22 @@ public class MachineStatusBroadcaster : BackgroundService private readonly IHubContext _hub; private readonly IHttpClientFactory _factory; private readonly ILogger _logger; + private readonly int _machineId; - public MachineStatusBroadcaster(IHubContext hub, IHttpClientFactory factory, ILogger logger) + public MachineStatusBroadcaster(IHubContext hub, IHttpClientFactory factory, ILogger logger, IConfiguration configuration) { _hub = hub; _factory = factory; _logger = logger; + + // Require an explicit Machine:Id configuration so we never silently broadcast as machine 0 + // when the environment variable was simply forgotten. + if (configuration["Machine:Id"] is null) + { + _logger.LogWarning( + "Machine:Id is not configured — defaulting to 0. Set Machine__Id environment variable to the correct machine ID."); + } + _machineId = configuration.GetValue("Machine:Id", 0); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -40,16 +50,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - var machineId = 0; // set from config in real deployment - var status = await client.GetFromJsonAsync($"/api/machine/{machineId}/status", stoppingToken); + var status = await client.GetFromJsonAsync($"/api/machine/{_machineId}/status", stoppingToken); if (status != null) { - await _hub.Clients.Group($"machine-{machineId}").SendAsync("MachineStatusUpdate", status, stoppingToken); + await _hub.Clients.Group($"machine-{_machineId}").SendAsync("MachineStatusUpdate", status, stoppingToken); } } catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { - _logger.LogWarning("Status broadcast error: {Error}", ex.Message); + _logger.LogWarning("Status broadcast error for Machine {MachineId}: {Error}", _machineId, ex.Message); } await Task.Delay(100, stoppingToken); diff --git a/src/machines/MachineService/GrpcServices/MachineGrpcService.cs b/src/machines/MachineService/GrpcServices/MachineGrpcService.cs index 6fd03cb..a03a95d 100644 --- a/src/machines/MachineService/GrpcServices/MachineGrpcService.cs +++ b/src/machines/MachineService/GrpcServices/MachineGrpcService.cs @@ -41,6 +41,39 @@ public override Task ResetMachine(ResetMachineRequest request return Task.FromResult(new ResetMachineReply { Success = true }); } + public override Task UnloadPart(UnloadPartRequest request, ServerCallContext context) + { + var part = _engine.UnloadPart(); + if (part == null) + { + return Task.FromResult(new UnloadPartReply + { + Success = false, + PartId = string.Empty, + TrackingNumber = string.Empty, + HasDefect = false, + PartStatus = string.Empty + }); + } + + string finalStatus = part.HasDefect ? "Defect" : "Processed"; + return Task.FromResult(new UnloadPartReply + { + Success = true, + PartId = part.PartId, + TrackingNumber = part.TrackingNumber, + HasDefect = part.HasDefect, + PartStatus = finalStatus + }); + } + + public override Task AcknowledgeFault(AcknowledgeFaultRequest request, ServerCallContext context) + { + _engine.ResetFault(); + _logger.LogInformation("Fault acknowledged on Machine {MachineId}", request.MachineId); + return Task.FromResult(new AcknowledgeFaultReply { Success = true }); + } + private MachineStatusReply BuildReply() { var reply = new MachineStatusReply diff --git a/src/machines/MachineService/Program.cs b/src/machines/MachineService/Program.cs index 14218cb..cb2b378 100644 --- a/src/machines/MachineService/Program.cs +++ b/src/machines/MachineService/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddSingleton(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHealthChecks(); var app = builder.Build(); @@ -21,4 +22,5 @@ app.MapGrpcService(); app.MapControllers(); app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); app.Run(); diff --git a/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs index 0891f65..a75196b 100644 --- a/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs +++ b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs @@ -68,3 +68,42 @@ public async Task SetSpeed([FromQuery] double factor) return Ok(new { success = true }); } } + +/// +/// Proxy controller that forwards track-engine tick calls from the Temporal master-worker +/// to PrimeMoverService running in the same pod. The master-worker only holds the +/// PrimeMoverApi base URL, so tick requests arrive here and are forwarded over the +/// pod-local loopback to the track engine. +/// +[ApiController] +[Route("api/track")] +public class TrackProxyController : ControllerBase +{ + private readonly IHttpClientFactory _factory; + + public TrackProxyController(IHttpClientFactory factory) => _factory = factory; + + /// Advance the XTS track engine by one simulation step. + [HttpPost("tick")] + public async Task Tick([FromQuery] double speedFactor = 1.0) + { + var client = _factory.CreateClient("PrimeMoverService"); + var response = await client.PostAsync($"/api/track/tick?speedFactor={speedFactor:F4}", null); + if (!response.IsSuccessStatusCode) + return StatusCode((int)response.StatusCode, await response.Content.ReadAsStringAsync()); + var body = await response.Content.ReadAsStringAsync(); + return Content(body, "application/json"); + } + + /// Get raw track status (complements /api/system/status). + [HttpGet("status")] + public async Task Status() + { + var client = _factory.CreateClient("PrimeMoverService"); + var response = await client.GetAsync("/api/track/status"); + if (!response.IsSuccessStatusCode) + return StatusCode((int)response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + return Content(body, "application/json"); + } +} diff --git a/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs index d563808..5bfc92e 100644 --- a/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs +++ b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.SignalR; +using PrimeMoverApi.Services; +using System.Text.Json; using XtsContracts.Dtos; namespace PrimeMoverApi.Hubs; @@ -16,25 +18,51 @@ public class SystemStatusBroadcaster : BackgroundService { private readonly IHubContext _hub; private readonly IHttpClientFactory _factory; + private readonly MachineAggregatorService _aggregator; private readonly ILogger _logger; - public SystemStatusBroadcaster(IHubContext hub, IHttpClientFactory factory, ILogger logger) + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + public SystemStatusBroadcaster( + IHubContext hub, + IHttpClientFactory factory, + MachineAggregatorService aggregator, + ILogger logger) { _hub = hub; _factory = factory; + _aggregator = aggregator; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var client = _factory.CreateClient("PrimeMoverService"); + var pmClient = _factory.CreateClient("PrimeMoverService"); while (!stoppingToken.IsCancellationRequested) { try { - var status = await client.GetFromJsonAsync("/api/track/status", stoppingToken); - await _hub.Clients.Group("system").SendAsync("SystemStatusUpdate", status, stoppingToken); + // Fetch track status from PrimeMoverService + var trackJson = await pmClient.GetStringAsync("/api/track/status", stoppingToken); + var track = JsonSerializer.Deserialize(trackJson, _jsonOptions); + + // Fetch all machine statuses + var machines = await _aggregator.GetAllMachineStatusesAsync(); + + var statusDto = new SystemStatusDto( + IsRunning: track?.IsRunning ?? false, + TotalPartsProduced: track?.TotalParts ?? 0, + GoodPartsCount: track?.GoodParts ?? 0, + BadPartsCount: track?.BadParts ?? 0, + PrimeMoverEnteredCount: track?.Entered ?? 0, + PrimeMoverExitedCount: (track?.GoodParts ?? 0) + (track?.BadParts ?? 0), + Movers: Array.Empty(), + Machines: machines, + Robots: Array.Empty(), + Timestamp: DateTime.UtcNow); + + await _hub.Clients.Group("system").SendAsync("SystemStatusUpdate", statusDto, stoppingToken); } catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { @@ -44,4 +72,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(100, stoppingToken); } } + + private sealed class TrackStatusPayload + { + public bool IsRunning { get; set; } + public int TotalParts { get; set; } + public int GoodParts { get; set; } + public int BadParts { get; set; } + public int Entered { get; set; } + } } diff --git a/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs index ab0b644..3c40ed6 100644 --- a/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs +++ b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs @@ -33,6 +33,7 @@ public IActionResult GetStatus() => Ok(new IsRunning = _engine.IsRunning, TotalParts = _engine.TotalParts, GoodParts = _engine.GoodParts, - BadParts = _engine.BadParts + BadParts = _engine.BadParts, + Entered = _engine.Entered }); } diff --git a/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs index 7ca02be..d08236b 100644 --- a/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs +++ b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs @@ -55,4 +55,21 @@ public override Task SendCommand(SystemCommandRequest reques } return Task.FromResult(new SystemCommandReply { Success = true, Message = $"Command '{request.Command}' executed" }); } -} + + public override Task NotifyMoverArrival(MoverArrivalNotification request, ServerCallContext context) + { + // A mover has arrived at a machine station. The track engine acknowledges the arrival + // and updates the mover's state. In the distributed architecture the machine service + // sends this notification; in response the engine locks the mover at the station. + _engine.NotifyMoverArrivalAtMachine(request.MoverId, request.MachineId); + return Task.FromResult(new MoverArrivalReply { Accepted = true }); + } + + public override Task NotifyPartReady(PartReadyNotification request, ServerCallContext context) + { + // A machine has finished processing a part and it is ready for pickup. + // The track engine schedules an available mover to go to that machine. + _engine.NotifyPartReadyForPickup(request.MachineId, request.PartTracking); + return Task.FromResult(new PartReadyReply { Acknowledged = true }); + } +} \ No newline at end of file diff --git a/src/prime-mover/PrimeMoverService/Program.cs b/src/prime-mover/PrimeMoverService/Program.cs index c3d3427..5e6700e 100644 --- a/src/prime-mover/PrimeMoverService/Program.cs +++ b/src/prime-mover/PrimeMoverService/Program.cs @@ -9,6 +9,7 @@ builder.Services.AddSingleton(_ => new XtsTrackEngine(10, new[] { 0, 1, 2, 3 })); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHealthChecks(); var app = builder.Build(); @@ -17,4 +18,5 @@ app.MapGrpcService(); app.MapControllers(); app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); app.Run(); diff --git a/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs index 45b35eb..bc99f25 100644 --- a/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs +++ b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs @@ -41,6 +41,53 @@ public XtsTrackEngine(int moverCount, IReadOnlyList machineIds) public void Stop() { lock (_lock) _running = false; } public void SetSpeed(double factor) { lock (_lock) _speedFactor = factor; } + /// + /// Called by the machine gRPC service when a mover has arrived at the machine's load angle. + /// Updates the mover state to AtMachine so the machine service knows it can load the part. + /// + public void NotifyMoverArrivalAtMachine(int moverId, int machineId) + { + lock (_lock) + { + var mover = _movers.FirstOrDefault(m => m.Id == moverId); + if (mover != null) + mover.State = TrackMoverState.AtMachine; + } + } + + /// + /// Called by the machine gRPC service when a part has finished processing and is ready + /// for pickup. Marks the nearest available idle mover to head to this machine's angle. + /// + public void NotifyPartReadyForPickup(int machineId, string partTracking) + { + if (machineId < 0 || machineId >= MachineAngles.Length) + { + // Reject invalid machine IDs explicitly so callers can detect misconfiguration + Console.Error.WriteLine( + $"[XtsTrackEngine] NotifyPartReadyForPickup: machineId={machineId} is out of range (0..{MachineAngles.Length - 1}). Request ignored."); + return; + } + + lock (_lock) + { + // Find a free mover that is not currently assigned to any machine + var candidate = _movers + .Where(m => m.State == TrackMoverState.Moving && m.CurrentPart == null && m.TargetMachineIndex < 0) + .OrderBy(m => AngularDistance(m.Position, MachineAngles[machineId])) + .FirstOrDefault(); + + if (candidate != null) + candidate.TargetMachineIndex = machineId; + } + } + + private static double AngularDistance(double fromDeg, double toDeg) + { + double diff = (toDeg - fromDeg + 360.0) % 360.0; + return diff <= 180.0 ? diff : 360.0 - diff; + } + public TrackTickResult Tick(double deltaSeconds) { lock (_lock) diff --git a/src/prime-mover/prime-mover-hmi/vite.config.ts b/src/prime-mover/prime-mover-hmi/vite.config.ts index 3edfee3..3bff8af 100644 --- a/src/prime-mover/prime-mover-hmi/vite.config.ts +++ b/src/prime-mover/prime-mover-hmi/vite.config.ts @@ -6,8 +6,10 @@ export default defineConfig({ server: { port: 3000, proxy: { - '/api': { target: 'http://localhost:8080', changeOrigin: true }, - '/hubs': { target: 'http://localhost:8080', ws: true, changeOrigin: true } + // All /api/* calls go to PrimeMoverApi (aggregating layer on port 8082) + '/api': { target: 'http://localhost:8082', changeOrigin: true }, + // SignalR WebSocket hub lives on PrimeMoverApi as well + '/hubs': { target: 'http://localhost:8082', ws: true, changeOrigin: true } } } }) diff --git a/src/temporal/machine-worker/Program.cs b/src/temporal/machine-worker/Program.cs index 07992b4..31f6f89 100644 --- a/src/temporal/machine-worker/Program.cs +++ b/src/temporal/machine-worker/Program.cs @@ -16,6 +16,13 @@ opts.Namespace = "xts-system"; }); +// Named HTTP client used by MachineActivities to call the MachineService REST API +builder.Services.AddHttpClient("MachineService", client => +{ + client.BaseAddress = new Uri(builder.Configuration["MachineService:BaseUrl"] ?? "http://machine-service:8080"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + builder.Services.AddHostedTemporalWorker($"xts-machine-{machineId}-queue") .AddWorkflow() .AddScopedActivities(); diff --git a/src/temporal/master-worker/Program.cs b/src/temporal/master-worker/Program.cs index eaefa12..9d329b7 100644 --- a/src/temporal/master-worker/Program.cs +++ b/src/temporal/master-worker/Program.cs @@ -1,5 +1,6 @@ using MasterWorker.Workflows; using MasterWorker.Activities; +using MasterWorker; using Temporalio.Extensions.Hosting; var builder = Host.CreateApplicationBuilder(args); @@ -13,16 +14,28 @@ opts.Namespace = temporalNamespace; }); +// Named HTTP client used by PrimeMoverActivities to call the Prime Mover API +builder.Services.AddHttpClient("PrimeMoverApi", client => +{ + client.BaseAddress = new Uri(builder.Configuration["PrimeMoverApi:BaseUrl"] ?? "http://prime-mover-api:8082"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Named HTTP client for direct MachineService callbacks (used by robot/mover assignment activities) +builder.Services.AddHttpClient("MachineApi", client => +{ + client.BaseAddress = new Uri(builder.Configuration["MachineApi:BaseUrl"] ?? "http://machine-api:8080"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + builder.Services.AddHostedTemporalWorker("xts-prime-mover-queue") .AddWorkflow() .AddWorkflow() .AddWorkflow() .AddScopedActivities(); -builder.Services.AddHttpClient("MachineApi", client => -{ - client.BaseAddress = new Uri(builder.Configuration["MachineApi:BaseUrl"] ?? "http://machine-api:8080"); -}); +// Auto-start the XTSPrimeMoverWorkflow on startup (idempotent — safe to restart) +builder.Services.AddHostedService(); var host = builder.Build(); await host.RunAsync(); diff --git a/src/temporal/master-worker/XTSPrimeMoverWorkflowStarter.cs b/src/temporal/master-worker/XTSPrimeMoverWorkflowStarter.cs new file mode 100644 index 0000000..49166da --- /dev/null +++ b/src/temporal/master-worker/XTSPrimeMoverWorkflowStarter.cs @@ -0,0 +1,75 @@ +using Temporalio.Client; +using Temporalio.Exceptions; +using XtsContracts.Workflows; +using MasterWorker.Workflows; + +namespace MasterWorker; + +/// +/// Hosted service that starts (or reconnects to) the single long-running +/// XTSPrimeMoverWorkflow on master-worker startup. +/// Uses workflow ID collision policy AllowDuplicateFailedOnly so that normal +/// restarts of the worker pod do NOT create a duplicate workflow instance. +/// +public sealed class XTSPrimeMoverWorkflowStarter : IHostedService +{ + private const string WorkflowId = "xts-prime-mover-main"; + private const string TaskQueue = "xts-prime-mover-queue"; + + private readonly ITemporalClient _client; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public XTSPrimeMoverWorkflowStarter( + ITemporalClient client, + IConfiguration configuration, + ILogger logger) + { + _client = client; + _configuration = configuration; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + int moverCount = _configuration.GetValue("PrimeMover:MoverCount", 10); + double speedFactor = _configuration.GetValue("PrimeMover:SimulationSpeedFactor", 1.0); + var machineIds = _configuration + .GetSection("PrimeMover:MachineIds") + .Get() ?? new[] { 0, 1, 2, 3 }; + + var input = new PrimeMoverInput(moverCount, machineIds.ToList().AsReadOnly(), speedFactor); + + try + { + await _client.StartWorkflowAsync( + (XTSPrimeMoverWorkflow w) => w.RunAsync(input), + new WorkflowOptions + { + Id = WorkflowId, + TaskQueue = TaskQueue, + IdReusePolicy = Temporalio.Api.Enums.V1.WorkflowIdReusePolicy.AllowDuplicateFailedOnly + }); + + _logger.LogInformation( + "XTSPrimeMoverWorkflow started. WorkflowId={Id}, Movers={Count}, Machines=[{Machines}]", + WorkflowId, moverCount, string.Join(",", machineIds)); + } + catch (WorkflowAlreadyStartedException) + { + _logger.LogInformation( + "XTSPrimeMoverWorkflow already running (WorkflowId={Id}) — worker reconnected.", + WorkflowId); + } + catch (Exception ex) + { + // Log but do not crash the host — the workflow may already be running under a + // different execution, and activities will still be dispatched correctly. + _logger.LogError(ex, + "Failed to start XTSPrimeMoverWorkflow (WorkflowId={Id}). Check Temporal connectivity.", + WorkflowId); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/temporal/master-worker/appsettings.json b/src/temporal/master-worker/appsettings.json index 8bec929..d5e1565 100644 --- a/src/temporal/master-worker/appsettings.json +++ b/src/temporal/master-worker/appsettings.json @@ -7,7 +7,12 @@ "BaseUrl": "http://machine-api:8080" }, "PrimeMoverApi": { - "BaseUrl": "http://prime-mover-api:8080" + "BaseUrl": "http://prime-mover-svc:8082" + }, + "PrimeMover": { + "MoverCount": 10, + "SimulationSpeedFactor": 1.0, + "MachineIds": [ 0, 1, 2, 3 ] }, "Logging": { "LogLevel": { From f2174adde5bc4364c4ce2692ba65204510211775 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:12:04 +0000 Subject: [PATCH 04/10] Add detailed full-stack testing and demo runbook to README --- README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 486bb53..9fd6373 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Tables currently used: - `ErrorLogs` - `Alarms` -## Build / Run +## Build / Run (Desktop WPF Simulation) 1. Open solution in Visual Studio 2026+. 2. Restore/build (`Debug | Any CPU`). @@ -132,6 +132,104 @@ Tables currently used: - `RESET` - Speed slider (`0.1x` .. `5.0x`) +## Full-Stack Test Guide (Temporal + APIs + HMIs) + +This verifies the distributed stack end-to-end: infrastructure, orchestration, machine services, APIs, HMIs, and observability. + +### 1) Prerequisites + +- Docker Desktop or Docker Engine with Compose v2 +- At least 8 CPU cores / 16 GB RAM recommended +- Ports available: `3000-3004`, `5432`, `6379`, `7233`, `8080`, `8082`, `8088`, `8090-8097`, `9200`, `9999`, `16686` + +### 2) Start the full stack + +From repository root: + +```bash +cd docker +docker compose -f docker-compose.dev.yml up -d --build +docker compose -f docker-compose.dev.yml ps +``` + +Expected: containers for Temporal, PostgreSQL, Redis, prime mover service/API/HMI, 4 machine service/API/HMI sets, workers, and observability are `Up`. + +### 3) Smoke-check core endpoints + +Open and confirm these load without errors: + +- Prime mover HMI: `http://localhost:3000` +- Machine HMIs: `http://localhost:3001`, `3002`, `3003`, `3004` +- Prime mover API Swagger: `http://localhost:8082/swagger` +- Machine API Swagger examples: `http://localhost:8091/swagger`, `http://localhost:8093/swagger` +- Temporal UI: `http://localhost:8088` +- Prometheus: `http://localhost:9999` +- Grafana: `http://localhost:3100` (`admin / xts_grafana`) +- Jaeger: `http://localhost:16686` + +### 4) Functional end-to-end validation + +1. In Prime mover HMI, start production flow. +2. Verify parts enter, route machine-by-machine (`M0 -> M1 -> M2 -> M3 -> Exit`), and exit as Good/Bad. +3. Open each machine HMI and confirm live station progression + ET/PT style runtime changes. +4. In Temporal UI: + - Confirm active workflow execution for prime mover orchestration. + - Confirm per-part lifecycle workflow creation and completion. + - Confirm machine workflow activity transitions while parts are processed. +5. In APIs (Swagger), call read/status endpoints and verify responses update as runtime state changes. +6. Validate alarms/watchdog behavior by observing fault and recovery events in HMIs/log streams (if a stall/fault is triggered). + +### 5) Data and observability validation + +- Verify metrics appear in Prometheus targets and queries. +- Verify Grafana connects to Prometheus and dashboards update over time. +- Verify traces/events are visible in Jaeger for workflow/service operations. +- Verify database-backed runtime records continue updating while production runs. + +### 6) Pass/Fail checklist + +Pass when all are true: + +- All required containers stay healthy and do not crash-loop. +- Prime mover + all machine HMIs load and show live-changing runtime state. +- Temporal workflows are created, progress, and complete without repeated failure. +- APIs remain responsive during active production. +- Observability tools (Prometheus/Grafana/Jaeger) show current runtime signals. + +Fail if any service is unavailable, state is not progressing, or workflow retries/failures persist without recovery. + +### 7) Stop and clean up + +```bash +cd docker +docker compose -f docker-compose.dev.yml down +``` + +For full reset (including local volumes/data): + +```bash +cd docker +docker compose -f docker-compose.dev.yml down -v +``` + +## Demo Runbook for Larger Audience + +Use this sequence for team demos, leadership reviews, or stakeholder walkthroughs: + +1. **Context (2-3 min):** Explain architecture boundaries (machine runtime, HMI runtime, data/observability). +2. **Live startup (2 min):** Show stack is already running (`docker compose ... ps`) and all major endpoints are reachable. +3. **Production flow (5-7 min):** Start line in Prime mover HMI and narrate part journey from entry to exit. +4. **Machine deep-dive (4-5 min):** Open one machine HMI and explain station-level ET/PT progression. +5. **Orchestration proof (3-4 min):** Show Temporal workflows for prime mover + parts + machines. +6. **Reliability proof (3-4 min):** Show alarms/watchdog/fault visibility and recovery behavior. +7. **Observability proof (3-4 min):** Show Prometheus metrics, Grafana dashboard updates, and Jaeger traces. +8. **Q&A ready artifacts:** Keep links/ports list, screenshots, and one short recording ready for follow-up sharing. + +Recommended presenter roles for larger crowd: +- **Narrator:** explains business flow and success criteria. +- **Operator:** drives HMI interactions. +- **Observer:** watches Temporal/monitoring tabs and calls out evidence in real time. + ## Continue Development on Another Laptop (Copilot-friendly) 1. Clone repo: From 0b957c811cc023790cafa177d70f0c89c7fd14b4 Mon Sep 17 00:00:00 2001 From: VISWA ANOOP NERELLA <21349508+anoop6543@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:46:33 -0700 Subject: [PATCH 05/10] Add SQLitePCLRaw.lib.e_sqlite3 and update launch settings Added SQLitePCLRaw.lib.e_sqlite3 (v3.53.3) to all main projects for native SQLite support. Excluded src/**/*.cs from XTSPrimeMoverProject compilation. Added/updated launchSettings.json for MachineApi, MachineService, PrimeMoverApi, and PrimeMoverService with dev profiles and URLs. --- XTSPrimeMoverProject.csproj | 5 +++++ src/machines/MachineApi/MachineApi.csproj | 1 + .../MachineApi/Properties/launchSettings.json | 12 ++++++++++++ src/machines/MachineService/MachineService.csproj | 1 + .../MachineService/Properties/launchSettings.json | 12 ++++++++++++ src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj | 1 + .../PrimeMoverApi/Properties/launchSettings.json | 12 ++++++++++++ .../PrimeMoverService/PrimeMoverService.csproj | 1 + .../PrimeMoverService/Properties/launchSettings.json | 12 ++++++++++++ src/shared/Contracts/XtsContracts.csproj | 1 + src/temporal/machine-worker/MachineWorker.csproj | 1 + src/temporal/master-worker/MasterWorker.csproj | 1 + 12 files changed, 60 insertions(+) create mode 100644 src/machines/MachineApi/Properties/launchSettings.json create mode 100644 src/machines/MachineService/Properties/launchSettings.json create mode 100644 src/prime-mover/PrimeMoverApi/Properties/launchSettings.json create mode 100644 src/prime-mover/PrimeMoverService/Properties/launchSettings.json diff --git a/XTSPrimeMoverProject.csproj b/XTSPrimeMoverProject.csproj index d5968c6..bf8ff23 100644 --- a/XTSPrimeMoverProject.csproj +++ b/XTSPrimeMoverProject.csproj @@ -8,6 +8,10 @@ true + + + + @@ -16,6 +20,7 @@ + diff --git a/src/machines/MachineApi/MachineApi.csproj b/src/machines/MachineApi/MachineApi.csproj index 56749bb..0bbd286 100644 --- a/src/machines/MachineApi/MachineApi.csproj +++ b/src/machines/MachineApi/MachineApi.csproj @@ -6,6 +6,7 @@ + diff --git a/src/machines/MachineApi/Properties/launchSettings.json b/src/machines/MachineApi/Properties/launchSettings.json new file mode 100644 index 0000000..0785552 --- /dev/null +++ b/src/machines/MachineApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MachineApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64530;http://localhost:64531" + } + } +} \ No newline at end of file diff --git a/src/machines/MachineService/MachineService.csproj b/src/machines/MachineService/MachineService.csproj index c111c1c..e71084f 100644 --- a/src/machines/MachineService/MachineService.csproj +++ b/src/machines/MachineService/MachineService.csproj @@ -10,5 +10,6 @@ + diff --git a/src/machines/MachineService/Properties/launchSettings.json b/src/machines/MachineService/Properties/launchSettings.json new file mode 100644 index 0000000..3594d20 --- /dev/null +++ b/src/machines/MachineService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MachineService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64533;http://localhost:64534" + } + } +} \ No newline at end of file diff --git a/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj b/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj index a2b5dad..e243c65 100644 --- a/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj +++ b/src/prime-mover/PrimeMoverApi/PrimeMoverApi.csproj @@ -6,6 +6,7 @@ + diff --git a/src/prime-mover/PrimeMoverApi/Properties/launchSettings.json b/src/prime-mover/PrimeMoverApi/Properties/launchSettings.json new file mode 100644 index 0000000..3f6da8c --- /dev/null +++ b/src/prime-mover/PrimeMoverApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "PrimeMoverApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64532;http://localhost:64535" + } + } +} \ No newline at end of file diff --git a/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj b/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj index 173b15e..0628fb3 100644 --- a/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj +++ b/src/prime-mover/PrimeMoverService/PrimeMoverService.csproj @@ -8,6 +8,7 @@ + diff --git a/src/prime-mover/PrimeMoverService/Properties/launchSettings.json b/src/prime-mover/PrimeMoverService/Properties/launchSettings.json new file mode 100644 index 0000000..bf6c48f --- /dev/null +++ b/src/prime-mover/PrimeMoverService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "PrimeMoverService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64536;http://localhost:64537" + } + } +} \ No newline at end of file diff --git a/src/shared/Contracts/XtsContracts.csproj b/src/shared/Contracts/XtsContracts.csproj index 0d5ca25..a9720bd 100644 --- a/src/shared/Contracts/XtsContracts.csproj +++ b/src/shared/Contracts/XtsContracts.csproj @@ -5,6 +5,7 @@ enable + diff --git a/src/temporal/machine-worker/MachineWorker.csproj b/src/temporal/machine-worker/MachineWorker.csproj index e45ea60..656e01a 100644 --- a/src/temporal/machine-worker/MachineWorker.csproj +++ b/src/temporal/machine-worker/MachineWorker.csproj @@ -6,6 +6,7 @@ + diff --git a/src/temporal/master-worker/MasterWorker.csproj b/src/temporal/master-worker/MasterWorker.csproj index 9aabec1..5c10bf2 100644 --- a/src/temporal/master-worker/MasterWorker.csproj +++ b/src/temporal/master-worker/MasterWorker.csproj @@ -6,6 +6,7 @@ + From 766db05f73e968c57522c1139aa4ceb21e333ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:02:55 +0000 Subject: [PATCH 06/10] Implement follow-up fixes for temporal architecture branch --- src/desktop-hmi/RemoteRestGateway.cs | 2 +- .../Controllers/SystemController.cs | 52 +- .../PrimeMoverApi/Hubs/SystemHub.cs | 3 +- src/prime-mover/PrimeMoverApi/Program.cs | 5 +- .../PrimeMoverApi/appsettings.json | 8 +- .../Controllers/TrackController.cs | 10 +- .../GrpcServices/PrimeMoverGrpcService.cs | 21 +- .../TrackEngine/XtsTrackEngine.cs | 87 +- .../prime-mover-hmi/package-lock.json | 2778 +++++++++++++++++ .../prime-mover-hmi/src/signalr/systemHub.ts | 2 +- .../Workflows/IXTSPrimeMoverWorkflow.cs | 3 + .../Workflows/PartLifecycleWorkflow.cs | 15 +- .../Workflows/RobotTransferWorkflow.cs | 6 +- .../Workflows/XTSPrimeMoverWorkflow.cs | 52 +- 14 files changed, 2985 insertions(+), 59 deletions(-) create mode 100644 src/prime-mover/prime-mover-hmi/package-lock.json diff --git a/src/desktop-hmi/RemoteRestGateway.cs b/src/desktop-hmi/RemoteRestGateway.cs index af53952..e846330 100644 --- a/src/desktop-hmi/RemoteRestGateway.cs +++ b/src/desktop-hmi/RemoteRestGateway.cs @@ -51,7 +51,7 @@ public sealed class RemoteRestGateway : IMachineGatewayService, IDataGatewayServ public int PrimeMoverEnteredCount => _entered; public int PrimeMoverExitedCount => _goodParts + _badParts; - public RemoteRestGateway(string baseUrl = "http://localhost:8080") + public RemoteRestGateway(string baseUrl = "http://localhost:8082") { _http = new HttpClient { BaseAddress = new Uri(baseUrl) }; diff --git a/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs index a75196b..619ba66 100644 --- a/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs +++ b/src/prime-mover/PrimeMoverApi/Controllers/SystemController.cs @@ -22,20 +22,28 @@ public SystemController(IHttpClientFactory factory, ITemporalClient temporal, Ma } [HttpGet("status")] - public async Task GetStatus() + public async Task GetStatus() => Ok(await BuildSystemStatusAsync()); + + [HttpGet("status/live")] + public async Task GetLiveStatus() => Ok(await BuildSystemStatusAsync()); + + private async Task BuildSystemStatusAsync() { var pmClient = _factory.CreateClient("PrimeMoverService"); - var trackStatus = await pmClient.GetFromJsonAsync("/api/track/status"); + var trackStatus = await pmClient.GetFromJsonAsync("/api/track/status"); var machines = await _aggregator.GetAllMachineStatusesAsync(); - return Ok(new { Track = trackStatus, Machines = machines, Timestamp = DateTime.UtcNow }); - } - [HttpGet("status/live")] - public async Task GetLiveStatus() - { - var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); - var status = await handle.QueryAsync(w => w.GetSystemStatus()); - return Ok(status); + return new SystemStatusDto( + IsRunning: trackStatus?.IsRunning ?? false, + TotalPartsProduced: trackStatus?.TotalParts ?? 0, + GoodPartsCount: trackStatus?.GoodParts ?? 0, + BadPartsCount: trackStatus?.BadParts ?? 0, + PrimeMoverEnteredCount: trackStatus?.Entered ?? 0, + PrimeMoverExitedCount: (trackStatus?.GoodParts ?? 0) + (trackStatus?.BadParts ?? 0), + Movers: trackStatus?.Movers ?? Array.Empty(), + Machines: machines, + Robots: Array.Empty(), + Timestamp: DateTime.UtcNow); } [HttpPost("start")] @@ -63,10 +71,24 @@ public async Task Stop() [HttpPost("set-speed")] public async Task SetSpeed([FromQuery] double factor) { + var pmClient = _factory.CreateClient("PrimeMoverService"); + await pmClient.PostAsync($"/api/track/set-speed?factor={factor}", null); + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); await handle.SignalAsync(w => w.SignalSetSpeedAsync(factor)); return Ok(new { success = true }); } + + [HttpPost("reset")] + public async Task Reset() + { + var pmClient = _factory.CreateClient("PrimeMoverService"); + await pmClient.PostAsync("/api/track/reset", null); + + var handle = _temporal.GetWorkflowHandle("xts-prime-mover-main"); + await handle.SignalAsync(w => w.SignalResetAsync()); + return Ok(new { success = true }); + } } /// @@ -107,3 +129,13 @@ public async Task Status() return Content(body, "application/json"); } } + +public sealed class TrackStatusPayload +{ + public bool IsRunning { get; set; } + public int TotalParts { get; set; } + public int GoodParts { get; set; } + public int BadParts { get; set; } + public int Entered { get; set; } + public IReadOnlyList? Movers { get; set; } +} diff --git a/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs index 5bfc92e..392aaf6 100644 --- a/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs +++ b/src/prime-mover/PrimeMoverApi/Hubs/SystemHub.cs @@ -57,7 +57,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) BadPartsCount: track?.BadParts ?? 0, PrimeMoverEnteredCount: track?.Entered ?? 0, PrimeMoverExitedCount: (track?.GoodParts ?? 0) + (track?.BadParts ?? 0), - Movers: Array.Empty(), + Movers: track?.Movers ?? Array.Empty(), Machines: machines, Robots: Array.Empty(), Timestamp: DateTime.UtcNow); @@ -80,5 +80,6 @@ private sealed class TrackStatusPayload public int GoodParts { get; set; } public int BadParts { get; set; } public int Entered { get; set; } + public IReadOnlyList? Movers { get; set; } } } diff --git a/src/prime-mover/PrimeMoverApi/Program.cs b/src/prime-mover/PrimeMoverApi/Program.cs index 8a790c7..a8a6d83 100644 --- a/src/prime-mover/PrimeMoverApi/Program.cs +++ b/src/prime-mover/PrimeMoverApi/Program.cs @@ -9,6 +9,7 @@ builder.Services.AddControllers(); builder.Services.AddSignalR(); +builder.Services.AddHealthChecks(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Prime Mover API", Version = "v1" })); @@ -29,9 +30,8 @@ // One HTTP client per machine for (int i = 0; i < 4; i++) { - var machineIdx = i; builder.Services.AddHttpClient($"MachineApi-{i}", c => - c.BaseAddress = new Uri(builder.Configuration[$"MachineApis:Machine{i}"] ?? $"http://machine-api-{i}:8080")); + c.BaseAddress = new Uri(builder.Configuration[$"MachineApis:Machine{i}"] ?? $"http://machine-api-{i}:8081")); } builder.Services.AddSingleton(); @@ -46,4 +46,5 @@ app.MapControllers(); app.MapHub("/hubs/system"); app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); app.Run(); diff --git a/src/prime-mover/PrimeMoverApi/appsettings.json b/src/prime-mover/PrimeMoverApi/appsettings.json index 67725e5..f3323fb 100644 --- a/src/prime-mover/PrimeMoverApi/appsettings.json +++ b/src/prime-mover/PrimeMoverApi/appsettings.json @@ -6,10 +6,10 @@ "BaseUrl": "http://prime-mover-service:8080" }, "MachineApis": { - "Machine0": "http://machine-api-0:8080", - "Machine1": "http://machine-api-1:8080", - "Machine2": "http://machine-api-2:8080", - "Machine3": "http://machine-api-3:8080" + "Machine0": "http://machine-api-0:8081", + "Machine1": "http://machine-api-1:8081", + "Machine2": "http://machine-api-2:8081", + "Machine3": "http://machine-api-3:8081" }, "Redis": { "Connection": "redis:6379" diff --git a/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs index 3c40ed6..f52d60f 100644 --- a/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs +++ b/src/prime-mover/PrimeMoverService/Controllers/TrackController.cs @@ -27,6 +27,13 @@ public IActionResult Tick([FromQuery] double speedFactor = 1.0) [HttpPost("set-speed")] public IActionResult SetSpeed([FromQuery] double factor) { _engine.SetSpeed(factor); return Ok(); } + [HttpPost("reset")] + public IActionResult Reset() + { + _engine.Reset(); + return Ok(new { success = true }); + } + [HttpGet("status")] public IActionResult GetStatus() => Ok(new { @@ -34,6 +41,7 @@ public IActionResult GetStatus() => Ok(new TotalParts = _engine.TotalParts, GoodParts = _engine.GoodParts, BadParts = _engine.BadParts, - Entered = _engine.Entered + Entered = _engine.Entered, + Movers = _engine.GetMoverStatuses() }); } diff --git a/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs index d08236b..b146b9b 100644 --- a/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs +++ b/src/prime-mover/PrimeMoverService/GrpcServices/PrimeMoverGrpcService.cs @@ -20,6 +20,14 @@ public override Task GetSystemStatus(SystemStatusRequest requ BadParts = _engine.BadParts, TimestampUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + reply.Movers.AddRange(_engine.GetMoverStatuses().Select(m => new XtsContracts.Grpc.MoverStatus + { + MoverId = m.MoverId, + Position = m.Position, + Velocity = m.Velocity, + State = m.State, + LoadedPartTracking = m.LoadedPartTrackingNumber ?? string.Empty + })); return Task.FromResult(reply); } @@ -27,9 +35,8 @@ public override async Task StreamMoverPositions(SystemStatusRequest request, ISe { while (!context.CancellationToken.IsCancellationRequested) { - var tick = _engine.Tick(0.1); var update = new MoverPositionUpdate { TimestampUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; - foreach (var m in tick.UpdatedMovers) + foreach (var m in _engine.GetMoverStatuses()) { update.Movers.Add(new XtsContracts.Grpc.MoverStatus { @@ -47,11 +54,19 @@ public override async Task StreamMoverPositions(SystemStatusRequest request, ISe public override Task SendCommand(SystemCommandRequest request, ServerCallContext context) { - switch (request.Command.ToLower()) + var command = request.Command.Trim().ToLowerInvariant(); + switch (command) { case "start": _engine.Start(); break; case "stop": _engine.Stop(); break; + case "reset": _engine.Reset(); break; case "setspeed": _engine.SetSpeed(request.SpeedFactor); break; + default: + return Task.FromResult(new SystemCommandReply + { + Success = false, + Message = $"Unsupported command '{request.Command}'. Supported commands: start, stop, reset, setspeed." + }); } return Task.FromResult(new SystemCommandReply { Success = true, Message = $"Command '{request.Command}' executed" }); } diff --git a/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs index bc99f25..33137f0 100644 --- a/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs +++ b/src/prime-mover/PrimeMoverService/TrackEngine/XtsTrackEngine.cs @@ -13,6 +13,7 @@ public class XtsTrackEngine private readonly Random _random = new(); private readonly List _movers; private readonly List _machineIds; + private readonly int _moverCount; private double _speedFactor = 1.0; private int _trackingCounter; private bool _running; @@ -21,6 +22,7 @@ public class XtsTrackEngine private const double MoverMinGapDegrees = 16.0; private const double EntryAngle = 205.0; private const double ExitAngle = 0.0; + private const int ExitTargetIndex = int.MaxValue; private static readonly double[] MachineAngles = { 45, 135, 225, 315 }; public int TotalParts { get; private set; } @@ -31,10 +33,10 @@ public class XtsTrackEngine public XtsTrackEngine(int moverCount, IReadOnlyList machineIds) { + _moverCount = moverCount; _machineIds = machineIds.ToList(); - _movers = Enumerable.Range(0, moverCount) - .Select(i => new TrackMover(i, i * (360.0 / moverCount))) - .ToList(); + _movers = new List(moverCount); + Reset(); } public void Start() { lock (_lock) _running = true; } @@ -47,11 +49,24 @@ public XtsTrackEngine(int moverCount, IReadOnlyList machineIds) /// public void NotifyMoverArrivalAtMachine(int moverId, int machineId) { + if (machineId < 0 || machineId >= MachineAngles.Length) + { + Console.Error.WriteLine( + $"[XtsTrackEngine] NotifyMoverArrivalAtMachine: machineId={machineId} is out of range (0..{MachineAngles.Length - 1}). Request ignored."); + return; + } + lock (_lock) { var mover = _movers.FirstOrDefault(m => m.Id == moverId); - if (mover != null) - mover.State = TrackMoverState.AtMachine; + if (mover == null) + { + Console.Error.WriteLine($"[XtsTrackEngine] NotifyMoverArrivalAtMachine: moverId={moverId} was not found. Request ignored."); + return; + } + + mover.TargetMachineIndex = machineId; + mover.State = mover.CurrentPart == null ? TrackMoverState.Moving : TrackMoverState.Loaded; } } @@ -73,12 +88,33 @@ public void NotifyPartReadyForPickup(int machineId, string partTracking) { // Find a free mover that is not currently assigned to any machine var candidate = _movers - .Where(m => m.State == TrackMoverState.Moving && m.CurrentPart == null && m.TargetMachineIndex < 0) + .Where(m => m.CurrentPart == null && m.TargetMachineIndex < 0 && m.State != TrackMoverState.AtMachine) .OrderBy(m => AngularDistance(m.Position, MachineAngles[machineId])) .FirstOrDefault(); if (candidate != null) + { candidate.TargetMachineIndex = machineId; + candidate.State = TrackMoverState.Moving; + } + } + } + + public void Reset() + { + lock (_lock) + { + _speedFactor = 1.0; + _trackingCounter = 0; + _running = false; + TotalParts = 0; + GoodParts = 0; + BadParts = 0; + Entered = 0; + + _movers.Clear(); + _movers.AddRange(Enumerable.Range(0, _moverCount) + .Select(i => new TrackMover(i, i * (360.0 / _moverCount)))); } } @@ -107,13 +143,32 @@ public TrackTickResult Tick(double deltaSeconds) // Update mover positions foreach (var m in _movers) { - if (m.State == TrackMoverState.Moving) + if (m.State is TrackMoverState.Moving or TrackMoverState.Loaded) m.Position = (m.Position + m.Velocity * deltaSeconds) % 360.0; } - // Entry zone: assign parts to eligible idle movers + foreach (var mover in _movers.Where(m => m.TargetMachineIndex >= 0 && m.TargetMachineIndex != ExitTargetIndex)) + { + if (!IsAtAngle(mover.Position, MachineAngles[mover.TargetMachineIndex], 5.0)) + continue; + + if (mover.CurrentPart == null) + { + mover.TargetMachineIndex = -1; + mover.State = TrackMoverState.Moving; + continue; + } + + var currentRouteIndex = _machineIds.IndexOf(mover.TargetMachineIndex); + mover.TargetMachineIndex = currentRouteIndex >= 0 && currentRouteIndex + 1 < _machineIds.Count + ? _machineIds[currentRouteIndex + 1] + : ExitTargetIndex; + mover.State = TrackMoverState.Loaded; + } + + // Entry zone: assign parts to eligible free movers var eligibleForEntry = _movers - .Where(m => m.State == TrackMoverState.Idle && m.CurrentPart == null) + .Where(m => m.CurrentPart == null && m.TargetMachineIndex < 0) .Where(m => IsAtAngle(m.Position, EntryAngle, 5.0)) .FirstOrDefault(); @@ -123,7 +178,7 @@ public TrackTickResult Tick(double deltaSeconds) var tracking = $"PART-{_trackingCounter:D6}"; eligibleForEntry.CurrentPart = new TrackPart(Guid.NewGuid(), tracking); eligibleForEntry.State = TrackMoverState.Loaded; - eligibleForEntry.TargetMachineIndex = 0; + eligibleForEntry.TargetMachineIndex = _machineIds[0]; partEntered = true; newPartId = eligibleForEntry.CurrentPart.PartId; newTracking = tracking; @@ -134,14 +189,14 @@ public TrackTickResult Tick(double deltaSeconds) // Exit zone: collect completed parts var atExit = _movers.FirstOrDefault(m => m.CurrentPart != null && - m.TargetMachineIndex >= _machineIds.Count && + m.TargetMachineIndex == ExitTargetIndex && IsAtAngle(m.Position, ExitAngle, 5.0)); if (atExit != null) { bool good = !atExit.CurrentPart!.HasDefect; atExit.CurrentPart = null; - atExit.State = TrackMoverState.Idle; + atExit.State = TrackMoverState.Moving; atExit.TargetMachineIndex = -1; partExited = true; exitedGood = good; @@ -153,6 +208,14 @@ public TrackTickResult Tick(double deltaSeconds) } } + public IReadOnlyList GetMoverStatuses() + { + lock (_lock) + { + return GetMoverDtos(); + } + } + private IReadOnlyList GetMoverDtos() => _movers.Select(m => new MoverDto( m.Id, m.Position, m.Velocity, m.State.ToString(), diff --git a/src/prime-mover/prime-mover-hmi/package-lock.json b/src/prime-mover/prime-mover-hmi/package-lock.json new file mode 100644 index 0000000..b917115 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/package-lock.json @@ -0,0 +1,2778 @@ +{ + "name": "prime-mover-hmi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prime-mover-hmi", + "version": "1.0.0", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", + "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts b/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts index bc03f0d..88dcf8c 100644 --- a/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts +++ b/src/prime-mover/prime-mover-hmi/src/signalr/systemHub.ts @@ -70,7 +70,7 @@ export function useSystemSignalR() { // Poll REST as fallback at 2s const pollInterval = setInterval(async () => { try { - const resp = await fetch('/api/system/status') + const resp = await fetch('/api/system/status/live') if (resp.ok) { const data = await resp.json() setStatus(data) diff --git a/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs b/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs index f3925fb..8206e97 100644 --- a/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs +++ b/src/shared/Contracts/Workflows/IXTSPrimeMoverWorkflow.cs @@ -28,6 +28,9 @@ public interface IXTSPrimeMoverWorkflow [WorkflowSignal("MachineFaulted")] Task SignalMachineFaultedAsync(int machineId, string faultMessage); + [WorkflowSignal("Reset")] + Task SignalResetAsync(); + [WorkflowQuery("GetSystemStatus")] SystemStatusDto GetSystemStatus(); diff --git a/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs index a06ee00..3194ebb 100644 --- a/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs +++ b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs @@ -14,7 +14,7 @@ public class PartLifecycleWorkflow : IPartLifecycleWorkflow { private PartDto _currentStatus; private readonly List _stationHistory = new(); - private readonly DateTime _startedAt = DateTime.UtcNow; + private DateTime _startedAt; private bool _exited; // Awaitable signals (using TaskCompletionSource-equivalent pattern in Temporal .NET SDK) @@ -29,18 +29,19 @@ public class PartLifecycleWorkflow : IPartLifecycleWorkflow public PartLifecycleWorkflow() { - _currentStatus = new PartDto(Guid.Empty, string.Empty, "Empty", DateTime.UtcNow, false, 0, 0, "Entry", new List()); + _currentStatus = new PartDto(Guid.Empty, string.Empty, "Empty", DateTime.MinValue, false, 0, 0, "Entry", Array.Empty()); } [WorkflowRun] public async Task RunAsync(PartLifecycleInput input) { + _startedAt = Workflow.UtcNow; _currentStatus = _currentStatus with { PartId = input.PartId, TrackingNumber = input.TrackingNumber, Status = "BaseLayer", - CreatedAt = DateTime.UtcNow, + CreatedAt = _startedAt, CurrentLocation = "Entry" }; @@ -61,8 +62,8 @@ public async Task RunAsync(PartLifecycleInput input) // Wait for station processing + unload await Workflow.WaitConditionAsync(() => _unloadedMachineId.HasValue); - var history = (_currentStatus.ProcessHistory as List ?? new List()); - history.Add($"{DateTime.UtcNow:HH:mm:ss.fff} - Machine-{machineId} complete, defect={_unloadedHasDefect}"); + var history = _currentStatus.ProcessHistory.ToList(); + history.Add($"{Workflow.UtcNow:HH:mm:ss.fff} - Machine-{machineId} complete, defect={_unloadedHasDefect}"); _currentStatus = _currentStatus with { @@ -82,7 +83,7 @@ public async Task RunAsync(PartLifecycleInput input) // Wait for exit signal await Workflow.WaitConditionAsync(() => _exited); - var cycleTime = DateTime.UtcNow - _startedAt; + var cycleTime = Workflow.UtcNow - _startedAt; var good = !_currentStatus.HasDefect; Workflow.Logger.LogInformation("Part {Tracking} exited. Good={Good}, CycleTime={Time:F2}s", @@ -110,7 +111,7 @@ public Task SignalStationCompleteAsync(int stationId, string stationName, bool h { _stationHistory.Add(new StationEventRecord( _loadedMachineId ?? 0, stationId, stationName, - DateTime.UtcNow.AddSeconds(-1), DateTime.UtcNow, hadDefect)); + Workflow.UtcNow.AddSeconds(-1), Workflow.UtcNow, hadDefect)); _currentStatus = _currentStatus with { CompletedStations = _currentStatus.CompletedStations + 1 }; return Task.CompletedTask; } diff --git a/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs b/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs index bc3a85a..6d50a38 100644 --- a/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs +++ b/src/temporal/master-worker/Workflows/RobotTransferWorkflow.cs @@ -14,7 +14,7 @@ public class RobotTransferWorkflow : IRobotTransferWorkflow [WorkflowRun] public async Task RunAsync(RobotTransferInput input) { - var started = DateTime.UtcNow; + var started = Workflow.UtcNow; Workflow.Logger.LogInformation("RobotTransfer: Robot={Robot} {Direction} Part={Part}", input.RobotId, input.Direction, input.PartTrackingNumber); @@ -55,12 +55,12 @@ await Workflow.ExecuteActivityAsync( new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); } - return new RobotTransferResult(true, null, DateTime.UtcNow - started); + return new RobotTransferResult(true, null, Workflow.UtcNow - started); } catch (Exception ex) { Workflow.Logger.LogError("RobotTransfer failed: Robot={Robot}, Error={Error}", input.RobotId, ex.Message); - return new RobotTransferResult(false, ex.Message, DateTime.UtcNow - started); + return new RobotTransferResult(false, ex.Message, Workflow.UtcNow - started); } } } diff --git a/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs b/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs index 5722115..1ba8273 100644 --- a/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs +++ b/src/temporal/master-worker/Workflows/XTSPrimeMoverWorkflow.cs @@ -12,6 +12,7 @@ public class XTSPrimeMoverWorkflow : IXTSPrimeMoverWorkflow private readonly List _movers = new(); private readonly List _machines = new(); private readonly List _robots = new(); + private readonly List _machineIds = new(); private bool _running; private double _speedFactor = 1.0; private int _totalParts; @@ -19,6 +20,7 @@ public class XTSPrimeMoverWorkflow : IXTSPrimeMoverWorkflow private int _badParts; private int _entered; private bool _shouldStop; + private int _moverCount; // Signals queue (CAS-safe via Temporal's single-threaded model) private readonly Queue _pendingMachineReadySignals = new(); @@ -27,19 +29,10 @@ public class XTSPrimeMoverWorkflow : IXTSPrimeMoverWorkflow [WorkflowRun] public async Task RunAsync(PrimeMoverInput input) { - _speedFactor = input.SimulationSpeedFactor; - - // Initialize movers - for (int i = 0; i < input.MoverCount; i++) - { - _movers.Add(new MoverDto(i, i * (360.0 / input.MoverCount), 30.0, "Idle", null, -1)); - } - - // Initialize machine stubs - foreach (var machineId in input.MachineIds) - { - _machines.Add(new MachineDto(machineId, $"Machine-{machineId}", "Unknown", "Init", true, false, string.Empty, 0, 0, new List(), 0, false, 0)); - } + _moverCount = input.MoverCount; + _machineIds.Clear(); + _machineIds.AddRange(input.MachineIds); + ResetWorkflowState(input.SimulationSpeedFactor); Workflow.Logger.LogInformation("XTSPrimeMoverWorkflow started. Movers={Count}, Machines={Machines}", input.MoverCount, string.Join(",", input.MachineIds)); @@ -136,6 +129,13 @@ public Task SignalMachineFaultedAsync(int machineId, string faultMessage) return Task.CompletedTask; } + [WorkflowSignal("Reset")] + public Task SignalResetAsync() + { + ResetWorkflowState(1.0); + return Task.CompletedTask; + } + [WorkflowSignal("Shutdown")] public Task SignalShutdownAsync() { @@ -150,8 +150,32 @@ public SystemStatusDto GetSystemStatus() => _running, _totalParts, _goodParts, _badParts, _entered, _goodParts + _badParts, _movers.AsReadOnly(), _machines.AsReadOnly(), _robots.AsReadOnly(), - DateTime.UtcNow); + Workflow.UtcNow); [WorkflowQuery("GetMoverPositions")] public IReadOnlyList GetMoverPositions() => _movers.AsReadOnly(); + + private void ResetWorkflowState(double speedFactor) + { + _running = false; + _speedFactor = speedFactor; + _totalParts = 0; + _goodParts = 0; + _badParts = 0; + _entered = 0; + _pendingMachineReadySignals.Clear(); + _machineFaults.Clear(); + + _movers.Clear(); + for (int i = 0; i < _moverCount; i++) + { + _movers.Add(new MoverDto(i, i * (360.0 / _moverCount), 30.0, "Moving", null, -1)); + } + + _machines.Clear(); + foreach (var machineId in _machineIds) + { + _machines.Add(new MachineDto(machineId, $"Machine-{machineId}", "Unknown", "Init", true, false, string.Empty, 0, 0, new List(), 0, false, 0)); + } + } } From 2ab5fe67115fc7d94804e7ed5734573d2a90422f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:04:18 +0000 Subject: [PATCH 07/10] Finalize temporal architecture follow-up fixes --- .../dist/assets/index-BLbjQ6wb.js | 40 +++++++++++++++++++ .../prime-mover-hmi/dist/index.html | 16 ++++++++ .../prime-mover-hmi/src/vite-env.d.ts | 1 + .../Workflows/PartLifecycleWorkflow.cs | 4 -- 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 src/prime-mover/prime-mover-hmi/dist/assets/index-BLbjQ6wb.js create mode 100644 src/prime-mover/prime-mover-hmi/dist/index.html create mode 100644 src/prime-mover/prime-mover-hmi/src/vite-env.d.ts diff --git a/src/prime-mover/prime-mover-hmi/dist/assets/index-BLbjQ6wb.js b/src/prime-mover/prime-mover-hmi/dist/assets/index-BLbjQ6wb.js new file mode 100644 index 0000000..444e335 --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/dist/assets/index-BLbjQ6wb.js @@ -0,0 +1,40 @@ +(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))c(d);new MutationObserver(d=>{for(const g of d)if(g.type==="childList")for(const w of g.addedNodes)w.tagName==="LINK"&&w.rel==="modulepreload"&&c(w)}).observe(document,{childList:!0,subtree:!0});function s(d){const g={};return d.integrity&&(g.integrity=d.integrity),d.referrerPolicy&&(g.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?g.credentials="include":d.crossOrigin==="anonymous"?g.credentials="omit":g.credentials="same-origin",g}function c(d){if(d.ep)return;d.ep=!0;const g=s(d);fetch(d.href,g)}})();function mc(f){return f&&f.__esModule&&Object.prototype.hasOwnProperty.call(f,"default")?f.default:f}var Vl={exports:{}},zr={},Ql={exports:{}},Q={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var ec;function md(){if(ec)return Q;ec=1;var f=Symbol.for("react.element"),l=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),c=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),g=Symbol.for("react.provider"),w=Symbol.for("react.context"),x=Symbol.for("react.forward_ref"),T=Symbol.for("react.suspense"),$=Symbol.for("react.memo"),U=Symbol.for("react.lazy"),X=Symbol.iterator;function ee(m){return m===null||typeof m!="object"?null:(m=X&&m[X]||m["@@iterator"],typeof m=="function"?m:null)}var Je={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ze=Object.assign,le={};function te(m,C,V){this.props=m,this.context=C,this.refs=le,this.updater=V||Je}te.prototype.isReactComponent={},te.prototype.setState=function(m,C){if(typeof m!="object"&&typeof m!="function"&&m!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,m,C,"setState")},te.prototype.forceUpdate=function(m){this.updater.enqueueForceUpdate(this,m,"forceUpdate")};function xt(){}xt.prototype=te.prototype;function vt(m,C,V){this.props=m,this.context=C,this.refs=le,this.updater=V||Je}var lt=vt.prototype=new xt;lt.constructor=vt,Ze(lt,te.prototype),lt.isPureReactComponent=!0;var Te=Array.isArray,st=Object.prototype.hasOwnProperty,Le={current:null},Oe={key:!0,ref:!0,__self:!0,__source:!0};function be(m,C,V){var K,q={},G=null,ne=null;if(C!=null)for(K in C.ref!==void 0&&(ne=C.ref),C.key!==void 0&&(G=""+C.key),C)st.call(C,K)&&!Oe.hasOwnProperty(K)&&(q[K]=C[K]);var Z=arguments.length-2;if(Z===1)q.children=V;else if(1>>1,C=R[m];if(0>>1;md(q,L))Gd(ne,q)?(R[m]=ne,R[G]=L,m=G):(R[m]=q,R[K]=L,m=K);else if(Gd(ne,L))R[m]=ne,R[G]=L,m=G;else break e}}return H}function d(R,H){var L=R.sortIndex-H.sortIndex;return L!==0?L:R.id-H.id}if(typeof performance=="object"&&typeof performance.now=="function"){var g=performance;f.unstable_now=function(){return g.now()}}else{var w=Date,x=w.now();f.unstable_now=function(){return w.now()-x}}var T=[],$=[],U=1,X=null,ee=3,Je=!1,Ze=!1,le=!1,te=typeof setTimeout=="function"?setTimeout:null,xt=typeof clearTimeout=="function"?clearTimeout:null,vt=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function lt(R){for(var H=s($);H!==null;){if(H.callback===null)c($);else if(H.startTime<=R)c($),H.sortIndex=H.expirationTime,l(T,H);else break;H=s($)}}function Te(R){if(le=!1,lt(R),!Ze)if(s(T)!==null)Ze=!0,$e(st);else{var H=s($);H!==null&&he(Te,H.startTime-R)}}function st(R,H){Ze=!1,le&&(le=!1,xt(be),be=-1),Je=!0;var L=ee;try{for(lt(H),X=s(T);X!==null&&(!(X.expirationTime>H)||R&&!nn());){var m=X.callback;if(typeof m=="function"){X.callback=null,ee=X.priorityLevel;var C=m(X.expirationTime<=H);H=f.unstable_now(),typeof C=="function"?X.callback=C:X===s(T)&&c(T),lt(H)}else c(T);X=s(T)}if(X!==null)var V=!0;else{var K=s($);K!==null&&he(Te,K.startTime-H),V=!1}return V}finally{X=null,ee=L,Je=!1}}var Le=!1,Oe=null,be=-1,jt=5,Pt=-1;function nn(){return!(f.unstable_now()-PtR||125m?(R.sortIndex=L,l($,R),s(T)===null&&R===s($)&&(le?(xt(be),be=-1):le=!0,he(Te,L-m))):(R.sortIndex=C,l(T,R),Ze||Je||(Ze=!0,$e(st))),R},f.unstable_shouldYield=nn,f.unstable_wrapCallback=function(R){var H=ee;return function(){var L=ee;ee=H;try{return R.apply(this,arguments)}finally{ee=L}}}})(Yl)),Yl}var ic;function Sd(){return ic||(ic=1,Xl.exports=wd()),Xl.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var lc;function kd(){if(lc)return Be;lc=1;var f=ns(),l=Sd();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),T=Object.prototype.hasOwnProperty,$=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,U={},X={};function ee(e){return T.call(X,e)?!0:T.call(U,e)?!1:$.test(e)?X[e]=!0:(U[e]=!0,!1)}function Je(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Ze(e,t,n,r){if(t===null||typeof t>"u"||Je(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function le(e,t,n,r,o,i,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new le(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new le(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new le(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new le(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new le(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){te[e]=new le(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){te[e]=new le(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){te[e]=new le(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){te[e]=new le(e,5,!1,e.toLowerCase(),null,!1,!1)});var xt=/[\-:]([a-z])/g;function vt(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(xt,vt);te[t]=new le(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(xt,vt);te[t]=new le(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(xt,vt);te[t]=new le(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){te[e]=new le(e,1,!1,e.toLowerCase(),null,!1,!1)}),te.xlinkHref=new le("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){te[e]=new le(e,1,!1,e.toLowerCase(),null,!0,!0)});function lt(e,t,n,r){var o=te.hasOwnProperty(t)?te[t]:null;(o!==null?o.type!==0:r||!(2a||o[u]!==i[a]){var h=` +`+o[u].replace(" at new "," at ");return e.displayName&&h.includes("")&&(h=h.replace("",e.displayName)),h}while(1<=u&&0<=a);break}}}finally{V=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?C(e):""}function q(e){switch(e.tag){case 5:return C(e.type);case 16:return C("Lazy");case 13:return C("Suspense");case 19:return C("SuspenseList");case 0:case 2:case 15:return e=K(e.type,!1),e;case 11:return e=K(e.type.render,!1),e;case 1:return e=K(e.type,!0),e;default:return""}}function G(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Oe:return"Fragment";case Le:return"Portal";case jt:return"Profiler";case be:return"StrictMode";case Ve:return"Suspense";case ut:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case nn:return(e.displayName||"Context")+".Consumer";case Pt:return(e._context.displayName||"Context")+".Provider";case yt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case _t:return t=e.displayName||null,t!==null?t:G(e.type)||"Memo";case $e:t=e._payload,e=e._init;try{return G(e(t))}catch{}}return null}function ne(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return G(t);case 8:return t===be?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Z(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function se(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Qe(e){var t=se(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(u){r=""+u,i.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Or(e){e._valueTracker||(e._valueTracker=Qe(e))}function ls(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=se(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function $r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Jo(e,t){var n=t.checked;return L({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ss(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Z(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function us(e,t){t=t.checked,t!=null&<(e,"checked",t,!1)}function Zo(e,t){us(e,t);var n=Z(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?bo(e,t.type,n):t.hasOwnProperty("defaultValue")&&bo(e,t.type,Z(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function as(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function bo(e,t,n){(t!=="number"||$r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Xn=Array.isArray;function wn(e,t,n,r){if(e=e.options,t){t={};for(var o=0;o"+t.valueOf().toString()+"",t=Ar.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Yn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var qn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},_c=["Webkit","ms","Moz","O"];Object.keys(qn).forEach(function(e){_c.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),qn[t]=qn[e]})});function gs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||qn.hasOwnProperty(e)&&qn[e]?(""+t).trim():t+"px"}function ms(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,o=gs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,o):e[n]=o}}var wc=L({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ni(e,t){if(t){if(wc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function ri(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var oi=null;function ii(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var li=null,Sn=null,kn=null;function vs(e){if(e=vr(e)){if(typeof li!="function")throw Error(s(280));var t=e.stateNode;t&&(t=so(t),li(e.stateNode,e.type,t))}}function ys(e){Sn?kn?kn.push(e):kn=[e]:Sn=e}function _s(){if(Sn){var e=Sn,t=kn;if(kn=Sn=null,vs(e),t)for(e=0;e>>=0,e===0?32:31-(Dc(e)/zc|0)|0}var Br=64,Vr=4194304;function bn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Qr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,o=e.suspendedLanes,i=e.pingedLanes,u=n&268435455;if(u!==0){var a=u&~o;a!==0?r=bn(a):(i&=u,i!==0&&(r=bn(i)))}else u=n&~o,u!==0?r=bn(u):i!==0&&(r=bn(i));if(r===0)return 0;if(t!==0&&t!==r&&(t&o)===0&&(o=r&-r,i=t&-t,o>=i||o===16&&(i&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function er(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-at(t),e[t]=n}function Oc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=ur),Xs=" ",Ys=!1;function qs(e,t){switch(e){case"keyup":return ff.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Gs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var xn=!1;function hf(e,t){switch(e){case"compositionend":return Gs(t);case"keypress":return t.which!==32?null:(Ys=!0,Xs);case"textInput":return e=t.data,e===Xs&&Ys?null:e;default:return null}}function pf(e,t){if(xn)return e==="compositionend"||!Ei&&qs(e,t)?(e=Hs(),Gr=yi=Ut=null,xn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ru(n)}}function iu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?iu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function lu(){for(var e=window,t=$r();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=$r(e.document)}return t}function Ti(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Cf(e){var t=lu(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&iu(n.ownerDocument.documentElement,n)){if(r!==null&&Ti(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=n.textContent.length,i=Math.min(r.start,o);r=r.end===void 0?i:Math.min(r.end,o),!e.extend&&i>r&&(o=r,r=i,i=o),o=ou(n,i);var u=ou(n,r);o&&u&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Pn=null,Ii=null,dr=null,Ri=!1;function su(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ri||Pn==null||Pn!==$r(r)||(r=Pn,"selectionStart"in r&&Ti(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),dr&&fr(dr,r)||(dr=r,r=oo(Ii,"onSelect"),0Dn||(e.current=Hi[Dn],Hi[Dn]=null,Dn--)}function re(e,t){Dn++,Hi[Dn]=e.current,e.current=t}var Vt={},Ie=Bt(Vt),Ae=Bt(!1),ln=Vt;function zn(e,t){var n=e.type.contextTypes;if(!n)return Vt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var o={},i;for(i in n)o[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Fe(e){return e=e.childContextTypes,e!=null}function uo(){ie(Ae),ie(Ie)}function ku(e,t,n){if(Ie.current!==Vt)throw Error(s(168));re(Ie,t),re(Ae,n)}function Cu(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var o in r)if(!(o in t))throw Error(s(108,ne(e)||"Unknown",o));return L({},n,r)}function ao(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Vt,ln=Ie.current,re(Ie,e),re(Ae,Ae.current),!0}function Eu(e,t,n){var r=e.stateNode;if(!r)throw Error(s(169));n?(e=Cu(e,t,ln),r.__reactInternalMemoizedMergedChildContext=e,ie(Ae),ie(Ie),re(Ie,e)):ie(Ae),re(Ae,n)}var It=null,co=!1,Wi=!1;function xu(e){It===null?It=[e]:It.push(e)}function jf(e){co=!0,xu(e)}function Qt(){if(!Wi&&It!==null){Wi=!0;var e=0,t=b;try{var n=It;for(b=1;e>=u,o-=u,Rt=1<<32-at(t)+o|n<W?(Ee=F,F=null):Ee=F.sibling;var J=k(v,F,y[W],I);if(J===null){F===null&&(F=Ee);break}e&&F&&J.alternate===null&&t(v,F),p=i(J,p,W),A===null?O=J:A.sibling=J,A=J,F=Ee}if(W===y.length)return n(v,F),ue&&un(v,W),O;if(F===null){for(;WW?(Ee=F,F=null):Ee=F.sibling;var en=k(v,F,J.value,I);if(en===null){F===null&&(F=Ee);break}e&&F&&en.alternate===null&&t(v,F),p=i(en,p,W),A===null?O=en:A.sibling=en,A=en,F=Ee}if(J.done)return n(v,F),ue&&un(v,W),O;if(F===null){for(;!J.done;W++,J=y.next())J=P(v,J.value,I),J!==null&&(p=i(J,p,W),A===null?O=J:A.sibling=J,A=J);return ue&&un(v,W),O}for(F=r(v,F);!J.done;W++,J=y.next())J=N(F,v,W,J.value,I),J!==null&&(e&&J.alternate!==null&&F.delete(J.key===null?W:J.key),p=i(J,p,W),A===null?O=J:A.sibling=J,A=J);return e&&F.forEach(function(gd){return t(v,gd)}),ue&&un(v,W),O}function me(v,p,y,I){if(typeof y=="object"&&y!==null&&y.type===Oe&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case st:e:{for(var O=y.key,A=p;A!==null;){if(A.key===O){if(O=y.type,O===Oe){if(A.tag===7){n(v,A.sibling),p=o(A,y.props.children),p.return=v,v=p;break e}}else if(A.elementType===O||typeof O=="object"&&O!==null&&O.$$typeof===$e&&Du(O)===A.type){n(v,A.sibling),p=o(A,y.props),p.ref=yr(v,A,y),p.return=v,v=p;break e}n(v,A);break}else t(v,A);A=A.sibling}y.type===Oe?(p=mn(y.props.children,v.mode,I,y.key),p.return=v,v=p):(I=Fo(y.type,y.key,y.props,null,v.mode,I),I.ref=yr(v,p,y),I.return=v,v=I)}return u(v);case Le:e:{for(A=y.key;p!==null;){if(p.key===A)if(p.tag===4&&p.stateNode.containerInfo===y.containerInfo&&p.stateNode.implementation===y.implementation){n(v,p.sibling),p=o(p,y.children||[]),p.return=v,v=p;break e}else{n(v,p);break}else t(v,p);p=p.sibling}p=Fl(y,v.mode,I),p.return=v,v=p}return u(v);case $e:return A=y._init,me(v,p,A(y._payload),I)}if(Xn(y))return M(v,p,y,I);if(H(y))return j(v,p,y,I);go(v,y)}return typeof y=="string"&&y!==""||typeof y=="number"?(y=""+y,p!==null&&p.tag===6?(n(v,p.sibling),p=o(p,y),p.return=v,v=p):(n(v,p),p=Al(y,v.mode,I),p.return=v,v=p),u(v)):n(v,p)}return me}var On=zu(!0),Lu=zu(!1),mo=Bt(null),vo=null,$n=null,Yi=null;function qi(){Yi=$n=vo=null}function Gi(e){var t=mo.current;ie(mo),e._currentValue=t}function Ji(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function An(e,t){vo=e,Yi=$n=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ue=!0),e.firstContext=null)}function nt(e){var t=e._currentValue;if(Yi!==e)if(e={context:e,memoizedValue:t,next:null},$n===null){if(vo===null)throw Error(s(308));$n=e,vo.dependencies={lanes:0,firstContext:e}}else $n=$n.next=e;return t}var an=null;function Zi(e){an===null?an=[e]:an.push(e)}function Mu(e,t,n,r){var o=t.interleaved;return o===null?(n.next=n,Zi(t)):(n.next=o.next,o.next=n),t.interleaved=n,Dt(e,r)}function Dt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Kt=!1;function bi(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ju(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function zt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Xt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(Y&2)!==0){var o=r.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),r.pending=t,Dt(e,n)}return o=r.interleaved,o===null?(t.next=t,Zi(r)):(t.next=o.next,o.next=t),r.interleaved=t,Dt(e,n)}function yo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,hi(e,n)}}function Ou(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var o=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?o=i=u:i=i.next=u,n=n.next}while(n!==null);i===null?o=i=t:i=i.next=t}else o=i=t;n={baseState:r.baseState,firstBaseUpdate:o,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function _o(e,t,n,r){var o=e.updateQueue;Kt=!1;var i=o.firstBaseUpdate,u=o.lastBaseUpdate,a=o.shared.pending;if(a!==null){o.shared.pending=null;var h=a,_=h.next;h.next=null,u===null?i=_:u.next=_,u=h;var E=e.alternate;E!==null&&(E=E.updateQueue,a=E.lastBaseUpdate,a!==u&&(a===null?E.firstBaseUpdate=_:a.next=_,E.lastBaseUpdate=h))}if(i!==null){var P=o.baseState;u=0,E=_=h=null,a=i;do{var k=a.lane,N=a.eventTime;if((r&k)===k){E!==null&&(E=E.next={eventTime:N,lane:0,tag:a.tag,payload:a.payload,callback:a.callback,next:null});e:{var M=e,j=a;switch(k=t,N=n,j.tag){case 1:if(M=j.payload,typeof M=="function"){P=M.call(N,P,k);break e}P=M;break e;case 3:M.flags=M.flags&-65537|128;case 0:if(M=j.payload,k=typeof M=="function"?M.call(N,P,k):M,k==null)break e;P=L({},P,k);break e;case 2:Kt=!0}}a.callback!==null&&a.lane!==0&&(e.flags|=64,k=o.effects,k===null?o.effects=[a]:k.push(a))}else N={eventTime:N,lane:k,tag:a.tag,payload:a.payload,callback:a.callback,next:null},E===null?(_=E=N,h=P):E=E.next=N,u|=k;if(a=a.next,a===null){if(a=o.shared.pending,a===null)break;k=a,a=k.next,k.next=null,o.lastBaseUpdate=k,o.shared.pending=null}}while(!0);if(E===null&&(h=P),o.baseState=h,o.firstBaseUpdate=_,o.lastBaseUpdate=E,t=o.shared.interleaved,t!==null){o=t;do u|=o.lane,o=o.next;while(o!==t)}else i===null&&(o.shared.lanes=0);dn|=u,e.lanes=u,e.memoizedState=P}}function $u(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=ol.transition;ol.transition={};try{e(!1),t()}finally{b=n,ol.transition=r}}function na(){return rt().memoizedState}function Ff(e,t,n){var r=Jt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ra(e))oa(t,n);else if(n=Mu(e,t,n,r),n!==null){var o=je();gt(n,e,r,o),ia(n,t,r)}}function Uf(e,t,n){var r=Jt(e),o={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ra(e))oa(t,o);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var u=t.lastRenderedState,a=i(u,n);if(o.hasEagerState=!0,o.eagerState=a,ct(a,u)){var h=t.interleaved;h===null?(o.next=o,Zi(t)):(o.next=h.next,h.next=o),t.interleaved=o;return}}catch{}finally{}n=Mu(e,t,o,r),n!==null&&(o=je(),gt(n,e,r,o),ia(n,t,r))}}function ra(e){var t=e.alternate;return e===fe||t!==null&&t===fe}function oa(e,t){kr=ko=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ia(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,hi(e,n)}}var xo={readContext:nt,useCallback:Re,useContext:Re,useEffect:Re,useImperativeHandle:Re,useInsertionEffect:Re,useLayoutEffect:Re,useMemo:Re,useReducer:Re,useRef:Re,useState:Re,useDebugValue:Re,useDeferredValue:Re,useTransition:Re,useMutableSource:Re,useSyncExternalStore:Re,useId:Re,unstable_isNewReconciler:!1},Hf={readContext:nt,useCallback:function(e,t){return Ct().memoizedState=[e,t===void 0?null:t],e},useContext:nt,useEffect:Yu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Co(4194308,4,Ju.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Co(4194308,4,e,t)},useInsertionEffect:function(e,t){return Co(4,2,e,t)},useMemo:function(e,t){var n=Ct();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ct();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Ff.bind(null,fe,e),[r.memoizedState,e]},useRef:function(e){var t=Ct();return e={current:e},t.memoizedState=e},useState:Ku,useDebugValue:fl,useDeferredValue:function(e){return Ct().memoizedState=e},useTransition:function(){var e=Ku(!1),t=e[0];return e=Af.bind(null,e[1]),Ct().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=fe,o=Ct();if(ue){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Ce===null)throw Error(s(349));(fn&30)!==0||Hu(r,t,n)}o.memoizedState=n;var i={value:n,getSnapshot:t};return o.queue=i,Yu(Bu.bind(null,r,i,e),[e]),r.flags|=2048,xr(9,Wu.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ct(),t=Ce.identifierPrefix;if(ue){var n=Nt,r=Rt;n=(r&~(1<<32-at(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Cr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[St]=t,e[mr]=r,xa(e,t,!1,!1),t.stateNode=e;e:{switch(u=ri(n,r),n){case"dialog":oe("cancel",e),oe("close",e),o=r;break;case"iframe":case"object":case"embed":oe("load",e),o=r;break;case"video":case"audio":for(o=0;oBn&&(t.flags|=128,r=!0,Pr(i,!1),t.lanes=4194304)}else{if(!r)if(e=wo(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Pr(i,!0),i.tail===null&&i.tailMode==="hidden"&&!u.alternate&&!ue)return Ne(t),null}else 2*ge()-i.renderingStartTime>Bn&&n!==1073741824&&(t.flags|=128,r=!0,Pr(i,!1),t.lanes=4194304);i.isBackwards?(u.sibling=t.child,t.child=u):(n=i.last,n!==null?n.sibling=u:t.child=u,i.last=u)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=ge(),t.sibling=null,n=ce.current,re(ce,r?n&1|2:n&1),t):(Ne(t),null);case 22:case 23:return jl(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(qe&1073741824)!==0&&(Ne(t),t.subtreeFlags&6&&(t.flags|=8192)):Ne(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function qf(e,t){switch(Vi(t),t.tag){case 1:return Fe(t.type)&&uo(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Fn(),ie(Ae),ie(Ie),rl(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return tl(t),null;case 13:if(ie(ce),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));jn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ie(ce),null;case 4:return Fn(),null;case 10:return Gi(t.type._context),null;case 22:case 23:return jl(),null;case 24:return null;default:return null}}var Ro=!1,De=!1,Gf=typeof WeakSet=="function"?WeakSet:Set,z=null;function Hn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){pe(e,t,r)}else n.current=null}function Cl(e,t,n){try{n()}catch(r){pe(e,t,r)}}var Ia=!1;function Jf(e,t){if(ji=Yr,e=lu(),Ti(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var o=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var u=0,a=-1,h=-1,_=0,E=0,P=e,k=null;t:for(;;){for(var N;P!==n||o!==0&&P.nodeType!==3||(a=u+o),P!==i||r!==0&&P.nodeType!==3||(h=u+r),P.nodeType===3&&(u+=P.nodeValue.length),(N=P.firstChild)!==null;)k=P,P=N;for(;;){if(P===e)break t;if(k===n&&++_===o&&(a=u),k===i&&++E===r&&(h=u),(N=P.nextSibling)!==null)break;P=k,k=P.parentNode}P=N}n=a===-1||h===-1?null:{start:a,end:h}}else n=null}n=n||{start:0,end:0}}else n=null;for(Oi={focusedElem:e,selectionRange:n},Yr=!1,z=t;z!==null;)if(t=z,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,z=e;else for(;z!==null;){t=z;try{var M=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(M!==null){var j=M.memoizedProps,me=M.memoizedState,v=t.stateNode,p=v.getSnapshotBeforeUpdate(t.elementType===t.type?j:dt(t.type,j),me);v.__reactInternalSnapshotBeforeUpdate=p}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent="":y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(I){pe(t,t.return,I)}if(e=t.sibling,e!==null){e.return=t.return,z=e;break}z=t.return}return M=Ia,Ia=!1,M}function Tr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var o=r=r.next;do{if((o.tag&e)===e){var i=o.destroy;o.destroy=void 0,i!==void 0&&Cl(t,n,i)}o=o.next}while(o!==r)}}function No(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function El(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ra(e){var t=e.alternate;t!==null&&(e.alternate=null,Ra(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[St],delete t[mr],delete t[Ui],delete t[Lf],delete t[Mf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Na(e){return e.tag===5||e.tag===3||e.tag===4}function Da(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Na(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function xl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=lo));else if(r!==4&&(e=e.child,e!==null))for(xl(e,t,n),e=e.sibling;e!==null;)xl(e,t,n),e=e.sibling}function Pl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Pl(e,t,n),e=e.sibling;e!==null;)Pl(e,t,n),e=e.sibling}var xe=null,ht=!1;function Yt(e,t,n){for(n=n.child;n!==null;)za(e,t,n),n=n.sibling}function za(e,t,n){if(wt&&typeof wt.onCommitFiberUnmount=="function")try{wt.onCommitFiberUnmount(Wr,n)}catch{}switch(n.tag){case 5:De||Hn(n,t);case 6:var r=xe,o=ht;xe=null,Yt(e,t,n),xe=r,ht=o,xe!==null&&(ht?(e=xe,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):xe.removeChild(n.stateNode));break;case 18:xe!==null&&(ht?(e=xe,n=n.stateNode,e.nodeType===8?Fi(e.parentNode,n):e.nodeType===1&&Fi(e,n),ir(e)):Fi(xe,n.stateNode));break;case 4:r=xe,o=ht,xe=n.stateNode.containerInfo,ht=!0,Yt(e,t,n),xe=r,ht=o;break;case 0:case 11:case 14:case 15:if(!De&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){o=r=r.next;do{var i=o,u=i.destroy;i=i.tag,u!==void 0&&((i&2)!==0||(i&4)!==0)&&Cl(n,t,u),o=o.next}while(o!==r)}Yt(e,t,n);break;case 1:if(!De&&(Hn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(a){pe(n,t,a)}Yt(e,t,n);break;case 21:Yt(e,t,n);break;case 22:n.mode&1?(De=(r=De)||n.memoizedState!==null,Yt(e,t,n),De=r):Yt(e,t,n);break;default:Yt(e,t,n)}}function La(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Gf),t.forEach(function(r){var o=ld.bind(null,e,r);n.has(r)||(n.add(r),r.then(o,o))})}}function pt(e,t){var n=t.deletions;if(n!==null)for(var r=0;ro&&(o=u),r&=~i}if(r=o,r=ge()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*bf(r/1960))-r,10e?16:e,Gt===null)var r=!1;else{if(e=Gt,Gt=null,jo=0,(Y&6)!==0)throw Error(s(331));var o=Y;for(Y|=4,z=e.current;z!==null;){var i=z,u=i.child;if((z.flags&16)!==0){var a=i.deletions;if(a!==null){for(var h=0;hge()-Rl?pn(e,0):Il|=n),We(e,t)}function Ka(e,t){t===0&&((e.mode&1)===0?t=1:(t=Vr,Vr<<=1,(Vr&130023424)===0&&(Vr=4194304)));var n=je();e=Dt(e,t),e!==null&&(er(e,t,n),We(e,n))}function id(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ka(e,n)}function ld(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,o=e.memoizedState;o!==null&&(n=o.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(s(314))}r!==null&&r.delete(t),Ka(e,n)}var Xa;Xa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ae.current)Ue=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ue=!1,Xf(e,t,n);Ue=(e.flags&131072)!==0}else Ue=!1,ue&&(t.flags&1048576)!==0&&Pu(t,ho,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Io(e,t),e=t.pendingProps;var o=zn(t,Ie.current);An(t,n),o=ll(null,t,r,e,o,n);var i=sl();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Fe(r)?(i=!0,ao(t)):i=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,bi(t),o.updater=Po,t.stateNode=o,o._reactInternals=t,hl(t,r,e,n),t=vl(null,t,r,!0,i,n)):(t.tag=0,ue&&i&&Bi(t),Me(null,t,o,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Io(e,t),e=t.pendingProps,o=r._init,r=o(r._payload),t.type=r,o=t.tag=ud(r),e=dt(r,e),o){case 0:t=ml(null,t,r,e,n);break e;case 1:t=_a(null,t,r,e,n);break e;case 11:t=pa(null,t,r,e,n);break e;case 14:t=ga(null,t,r,dt(r.type,e),n);break e}throw Error(s(306,r,""))}return t;case 0:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:dt(r,o),ml(e,t,r,o,n);case 1:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:dt(r,o),_a(e,t,r,o,n);case 3:e:{if(wa(t),e===null)throw Error(s(387));r=t.pendingProps,i=t.memoizedState,o=i.element,ju(e,t),_o(t,r,null,n);var u=t.memoizedState;if(r=u.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){o=Un(Error(s(423)),t),t=Sa(e,t,r,n,o);break e}else if(r!==o){o=Un(Error(s(424)),t),t=Sa(e,t,r,n,o);break e}else for(Ye=Wt(t.stateNode.containerInfo.firstChild),Xe=t,ue=!0,ft=null,n=Lu(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(jn(),r===o){t=Lt(e,t,n);break e}Me(e,t,r,n)}t=t.child}return t;case 5:return Au(t),e===null&&Ki(t),r=t.type,o=t.pendingProps,i=e!==null?e.memoizedProps:null,u=o.children,$i(r,o)?u=null:i!==null&&$i(r,i)&&(t.flags|=32),ya(e,t),Me(e,t,u,n),t.child;case 6:return e===null&&Ki(t),null;case 13:return ka(e,t,n);case 4:return el(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=On(t,null,r,n):Me(e,t,r,n),t.child;case 11:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:dt(r,o),pa(e,t,r,o,n);case 7:return Me(e,t,t.pendingProps,n),t.child;case 8:return Me(e,t,t.pendingProps.children,n),t.child;case 12:return Me(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,o=t.pendingProps,i=t.memoizedProps,u=o.value,re(mo,r._currentValue),r._currentValue=u,i!==null)if(ct(i.value,u)){if(i.children===o.children&&!Ae.current){t=Lt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var a=i.dependencies;if(a!==null){u=i.child;for(var h=a.firstContext;h!==null;){if(h.context===r){if(i.tag===1){h=zt(-1,n&-n),h.tag=2;var _=i.updateQueue;if(_!==null){_=_.shared;var E=_.pending;E===null?h.next=h:(h.next=E.next,E.next=h),_.pending=h}}i.lanes|=n,h=i.alternate,h!==null&&(h.lanes|=n),Ji(i.return,n,t),a.lanes|=n;break}h=h.next}}else if(i.tag===10)u=i.type===t.type?null:i.child;else if(i.tag===18){if(u=i.return,u===null)throw Error(s(341));u.lanes|=n,a=u.alternate,a!==null&&(a.lanes|=n),Ji(u,n,t),u=i.sibling}else u=i.child;if(u!==null)u.return=i;else for(u=i;u!==null;){if(u===t){u=null;break}if(i=u.sibling,i!==null){i.return=u.return,u=i;break}u=u.return}i=u}Me(e,t,o.children,n),t=t.child}return t;case 9:return o=t.type,r=t.pendingProps.children,An(t,n),o=nt(o),r=r(o),t.flags|=1,Me(e,t,r,n),t.child;case 14:return r=t.type,o=dt(r,t.pendingProps),o=dt(r.type,o),ga(e,t,r,o,n);case 15:return ma(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:dt(r,o),Io(e,t),t.tag=1,Fe(r)?(e=!0,ao(t)):e=!1,An(t,n),sa(t,r,o),hl(t,r,o,n),vl(null,t,r,!0,e,n);case 19:return Ea(e,t,n);case 22:return va(e,t,n)}throw Error(s(156,t.tag))};function Ya(e,t){return Ts(e,t)}function sd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function it(e,t,n,r){return new sd(e,t,n,r)}function $l(e){return e=e.prototype,!(!e||!e.isReactComponent)}function ud(e){if(typeof e=="function")return $l(e)?1:0;if(e!=null){if(e=e.$$typeof,e===yt)return 11;if(e===_t)return 14}return 2}function bt(e,t){var n=e.alternate;return n===null?(n=it(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Fo(e,t,n,r,o,i){var u=2;if(r=e,typeof e=="function")$l(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Oe:return mn(n.children,o,i,t);case be:u=8,o|=8;break;case jt:return e=it(12,n,t,o|2),e.elementType=jt,e.lanes=i,e;case Ve:return e=it(13,n,t,o),e.elementType=Ve,e.lanes=i,e;case ut:return e=it(19,n,t,o),e.elementType=ut,e.lanes=i,e;case he:return Uo(n,o,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Pt:u=10;break e;case nn:u=9;break e;case yt:u=11;break e;case _t:u=14;break e;case $e:u=16,r=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=it(u,n,t,o),t.elementType=e,t.type=r,t.lanes=i,t}function mn(e,t,n,r){return e=it(7,e,r,t),e.lanes=n,e}function Uo(e,t,n,r){return e=it(22,e,r,t),e.elementType=he,e.lanes=n,e.stateNode={isHidden:!1},e}function Al(e,t,n){return e=it(6,e,null,t),e.lanes=n,e}function Fl(e,t,n){return t=it(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function ad(e,t,n,r,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=di(0),this.expirationTimes=di(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=di(0),this.identifierPrefix=r,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function Ul(e,t,n,r,o,i,u,a,h){return e=new ad(e,t,n,a,h),t===1?(t=1,i===!0&&(t|=8)):t=0,i=it(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},bi(i),e}function cd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(f)}catch(l){console.error(l)}}return f(),Kl.exports=kd(),Kl.exports}var uc;function Ed(){if(uc)return Xo;uc=1;var f=Cd();return Xo.createRoot=f.createRoot,Xo.hydrateRoot=f.hydrateRoot,Xo}var xd=Ed();const Pd=mc(xd),Gl=580,Jl=320,Zl=Gl/2,bl=Jl/2,es=240,ts=120;function Qn(f){const l=(f-90)*Math.PI/180;return{x:Zl+es*Math.cos(l),y:bl+ts*Math.sin(l)}}const Td=[45,135,225,315],Id=["M0 Laser","M1 Assembly","M2 Quality","M3 Test"],ac=205,cc=0;function Rd({movers:f,machines:l}){return D.jsxs("div",{style:{background:"#0d1526",borderRadius:10,padding:12},children:[D.jsx("div",{style:{fontSize:10,color:"#475569",marginBottom:8,letterSpacing:1},children:"XTS OVAL TRACK"}),D.jsxs("svg",{width:Gl,height:Jl,viewBox:`0 0 ${Gl} ${Jl}`,children:[D.jsx("ellipse",{cx:Zl,cy:bl,rx:es+18,ry:ts+18,fill:"none",stroke:"#1e3a5f",strokeWidth:36}),D.jsx("ellipse",{cx:Zl,cy:bl,rx:es,ry:ts,fill:"none",stroke:"#0f2040",strokeWidth:2}),(()=>{const s=Qn(ac);return D.jsx("circle",{cx:s.x,cy:s.y,r:14,fill:"#065f46",stroke:"#4ade80",strokeWidth:1.5})})(),(()=>{const s=Qn(ac);return D.jsx("text",{x:s.x,y:s.y+24,textAnchor:"middle",fontSize:8,fill:"#4ade80",children:"ENTRY"})})(),(()=>{const s=Qn(cc);return D.jsx("circle",{cx:s.x,cy:s.y,r:14,fill:"#7f1d1d",stroke:"#f87171",strokeWidth:1.5})})(),(()=>{const s=Qn(cc);return D.jsx("text",{x:s.x,y:s.y+24,textAnchor:"middle",fontSize:8,fill:"#f87171",children:"EXIT"})})(),Td.map((s,c)=>{const d=Qn(s),g=l[c],w=g?g.faultActive?"#7f1d1d":g.sequencerState==="Run"?"#1e3a5f":"#065f46":"#374151",x=g?g.faultActive?"#ef4444":g.sequencerState==="Run"?"#3b82f6":"#22c55e":"#4b5563";return D.jsxs("g",{children:[D.jsx("rect",{x:d.x-36,y:d.y-18,width:72,height:36,rx:4,fill:w,stroke:x,strokeWidth:1.5}),D.jsx("text",{x:d.x,y:d.y-4,textAnchor:"middle",fontSize:8,fill:"#e2e8f0",fontWeight:700,children:Id[c]}),D.jsx("text",{x:d.x,y:d.y+8,textAnchor:"middle",fontSize:7,fill:x,children:(g==null?void 0:g.sequencerState)??"OFFLINE"})]},c)}),f.map(s=>{const c=Qn(s.position),d=!!s.loadedPartTrackingNumber,g=s.state==="AtMachine"?"#7c3aed":d?"#1d4ed8":"#374151";return D.jsxs("g",{children:[D.jsx("circle",{cx:c.x,cy:c.y,r:9,fill:g,stroke:d?"#60a5fa":"#6b7280",strokeWidth:1.5}),D.jsx("text",{x:c.x,y:c.y+1,textAnchor:"middle",dominantBaseline:"middle",fontSize:7,fill:"#fff",fontWeight:700,children:s.moverId}),d&&D.jsx("circle",{cx:c.x+7,cy:c.y-7,r:4,fill:"#f59e0b"})]},s.moverId)})]}),D.jsxs("div",{style:{display:"flex",gap:16,marginTop:8,fontSize:10,color:"#475569"},children:[D.jsxs("span",{children:[D.jsx("span",{style:{color:"#374151"},children:"● "}),"Empty"]}),D.jsxs("span",{children:[D.jsx("span",{style:{color:"#1d4ed8"},children:"● "}),"Loaded"]}),D.jsxs("span",{children:[D.jsx("span",{style:{color:"#7c3aed"},children:"● "}),"At Machine"]}),D.jsxs("span",{children:[D.jsx("span",{style:{color:"#f59e0b"},children:"◆ "}),"Part"]})]})]})}function Nd({status:f}){const l=f&&f.totalPartsProduced>0?(f.goodPartsCount/f.totalPartsProduced*100).toFixed(1):"0.0";return D.jsxs("div",{style:{background:"#0d1526",borderRadius:8,padding:12},children:[D.jsx("div",{style:{fontSize:10,color:"#475569",marginBottom:10,letterSpacing:1},children:"PRODUCTION KPIs"}),D.jsx("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8},children:[{label:"TOTAL",value:(f==null?void 0:f.totalPartsProduced)??0,color:"#60a5fa"},{label:"GOOD",value:(f==null?void 0:f.goodPartsCount)??0,color:"#4ade80"},{label:"BAD",value:(f==null?void 0:f.badPartsCount)??0,color:"#f87171"},{label:"OEE",value:`${l}%`,color:"#a78bfa"},{label:"ENTERED",value:(f==null?void 0:f.primeMoverEnteredCount)??0,color:"#fbbf24"},{label:"EXITED",value:(f==null?void 0:f.primeMoverExitedCount)??0,color:"#34d399"}].map(s=>D.jsxs("div",{style:{background:"#111827",borderRadius:6,padding:"8px 10px"},children:[D.jsx("div",{style:{fontSize:9,color:"#6b7280"},children:s.label}),D.jsx("div",{style:{fontSize:22,fontWeight:700,color:s.color},children:s.value})]},s.label))})]})}const Dd=["Laser Welding","Precision Assembly","Quality Inspection","Functional Testing"];function zd({machineId:f,machine:l}){const s=l?l.faultActive?"#ef4444":l.sequencerState==="Run"?"#3b82f6":"#22c55e":"#374151";return D.jsxs("div",{style:{background:"#111827",border:`1px solid ${s}`,borderRadius:6,padding:"8px 10px",marginBottom:8},children:[D.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:4},children:[D.jsxs("span",{style:{fontSize:11,fontWeight:600,color:"#e2e8f0"},children:["M",f," — ",Dd[f]]}),D.jsx("span",{style:{fontSize:9,color:s,fontWeight:700},children:(l==null?void 0:l.sequencerState)??"OFFLINE"})]}),l?D.jsxs("div",{style:{display:"flex",gap:10,fontSize:10,color:"#6b7280"},children:[D.jsxs("span",{children:["IN:",l.partsEnteredCount]}),D.jsxs("span",{children:["OUT:",l.partsExitedCount]}),D.jsxs("span",{children:["STA:",l.currentStationIndex]}),l.isIndexing&&D.jsx("span",{style:{color:"#f59e0b"},children:"↻"})]}):D.jsx("div",{style:{fontSize:10,color:"#6b7280"},children:"Connecting…"}),(l==null?void 0:l.faultActive)&&D.jsxs("div",{style:{fontSize:9,color:"#fca5a5",marginTop:3},children:["⚠ ",l.faultMessage]}),D.jsx("a",{href:`/machine/${f}`,target:"_blank",rel:"noreferrer",style:{display:"block",fontSize:9,color:"#3b82f6",marginTop:4,textDecoration:"none"},children:"Open Full HMI →"})]})}function Ld({isRunning:f,speed:l,onSpeedChange:s}){const c=(d,g)=>fetch(d,{method:"POST",headers:{"Content-Type":"application/json"},body:void 0});return D.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12,padding:"8px 16px",background:"#0a1020",borderBottom:"1px solid #1e293b"},children:[D.jsx("button",{onClick:()=>c(f?"/api/system/stop":"/api/system/start"),style:{padding:"5px 16px",borderRadius:4,border:"none",cursor:"pointer",fontWeight:700,fontSize:12,background:f?"#7f1d1d":"#065f46",color:"#fff"},children:f?"⏹ STOP":"▶ START"}),D.jsx("button",{onClick:()=>c("/api/system/reset"),style:{padding:"5px 12px",borderRadius:4,border:"1px solid #374151",cursor:"pointer",background:"#1f2937",color:"#9ca3af",fontSize:12},children:"↺ RESET"}),D.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,marginLeft:"auto"},children:[D.jsx("span",{style:{fontSize:11,color:"#475569"},children:"SPEED"}),D.jsx("input",{type:"range",min:.1,max:5,step:.1,value:l,onChange:d=>{const g=parseFloat(d.target.value);s(g),c(`/api/system/set-speed?factor=${g}`)},style:{width:100}}),D.jsxs("span",{style:{fontSize:11,color:"#60a5fa",minWidth:30},children:[l.toFixed(1),"×"]})]})]})}class vn extends Error{constructor(l,s){const c=new.target.prototype;super(`${l}: Status code '${s}'`),this.statusCode=s,this.__proto__=c}}class rs extends Error{constructor(l="A timeout occurred."){const s=new.target.prototype;super(l),this.__proto__=s}}class mt extends Error{constructor(l="An abort occurred."){const s=new.target.prototype;super(l),this.__proto__=s}}class Md extends Error{constructor(l,s){const c=new.target.prototype;super(l),this.transport=s,this.errorType="UnsupportedTransportError",this.__proto__=c}}class jd extends Error{constructor(l,s){const c=new.target.prototype;super(l),this.transport=s,this.errorType="DisabledTransportError",this.__proto__=c}}class Od extends Error{constructor(l,s){const c=new.target.prototype;super(l),this.transport=s,this.errorType="FailedToStartTransportError",this.__proto__=c}}class fc extends Error{constructor(l){const s=new.target.prototype;super(l),this.errorType="FailedToNegotiateWithServerError",this.__proto__=s}}class $d extends Error{constructor(l,s){const c=new.target.prototype;super(l),this.innerErrors=s,this.__proto__=c}}class vc{constructor(l,s,c){this.statusCode=l,this.statusText=s,this.content=c}}class Go{get(l,s){return this.send({...s,method:"GET",url:l})}post(l,s){return this.send({...s,method:"POST",url:l})}delete(l,s){return this.send({...s,method:"DELETE",url:l})}getCookieString(l){return""}}var S;(function(f){f[f.Trace=0]="Trace",f[f.Debug=1]="Debug",f[f.Information=2]="Information",f[f.Warning=3]="Warning",f[f.Error=4]="Error",f[f.Critical=5]="Critical",f[f.None=6]="None"})(S||(S={}));class Mr{constructor(){}log(l,s){}}Mr.instance=new Mr;const Ad="8.0.17";class ye{static isRequired(l,s){if(l==null)throw new Error(`The '${s}' argument is required.`)}static isNotEmpty(l,s){if(!l||l.match(/^\s*$/))throw new Error(`The '${s}' argument should not be empty.`)}static isIn(l,s,c){if(!(l in s))throw new Error(`Unknown ${c} value: ${l}.`)}}class de{static get isBrowser(){return!de.isNode&&typeof window=="object"&&typeof window.document=="object"}static get isWebWorker(){return!de.isNode&&typeof self=="object"&&"importScripts"in self}static get isReactNative(){return!de.isNode&&typeof window=="object"&&typeof window.document>"u"}static get isNode(){return typeof process<"u"&&process.release&&process.release.name==="node"}}function jr(f,l){let s="";return _n(f)?(s=`Binary data of length ${f.byteLength}`,l&&(s+=`. Content: '${Fd(f)}'`)):typeof f=="string"&&(s=`String data of length ${f.length}`,l&&(s+=`. Content: '${f}'`)),s}function Fd(f){const l=new Uint8Array(f);let s="";return l.forEach(c=>{const d=c<16?"0":"";s+=`0x${d}${c.toString(16)} `}),s.substr(0,s.length-1)}function _n(f){return f&&typeof ArrayBuffer<"u"&&(f instanceof ArrayBuffer||f.constructor&&f.constructor.name==="ArrayBuffer")}async function yc(f,l,s,c,d,g){const w={},[x,T]=Kn();w[x]=T,f.log(S.Trace,`(${l} transport) sending data. ${jr(d,g.logMessageContent)}.`);const $=_n(d)?"arraybuffer":"text",U=await s.post(c,{content:d,headers:{...w,...g.headers},responseType:$,timeout:g.timeout,withCredentials:g.withCredentials});f.log(S.Trace,`(${l} transport) request complete. Response status: ${U.statusCode}.`)}function Ud(f){return f===void 0?new qo(S.Information):f===null?Mr.instance:f.log!==void 0?f:new qo(f)}class Hd{constructor(l,s){this._subject=l,this._observer=s}dispose(){const l=this._subject.observers.indexOf(this._observer);l>-1&&this._subject.observers.splice(l,1),this._subject.observers.length===0&&this._subject.cancelCallback&&this._subject.cancelCallback().catch(s=>{})}}class qo{constructor(l){this._minLevel=l,this.out=console}log(l,s){if(l>=this._minLevel){const c=`[${new Date().toISOString()}] ${S[l]}: ${s}`;switch(l){case S.Critical:case S.Error:this.out.error(c);break;case S.Warning:this.out.warn(c);break;case S.Information:this.out.info(c);break;default:this.out.log(c);break}}}}function Kn(){let f="X-SignalR-User-Agent";return de.isNode&&(f="User-Agent"),[f,Wd(Ad,Bd(),Qd(),Vd())]}function Wd(f,l,s,c){let d="Microsoft SignalR/";const g=f.split(".");return d+=`${g[0]}.${g[1]}`,d+=` (${f}; `,l&&l!==""?d+=`${l}; `:d+="Unknown OS; ",d+=`${s}`,c?d+=`; ${c}`:d+="; Unknown Runtime Version",d+=")",d}function Bd(){if(de.isNode)switch(process.platform){case"win32":return"Windows NT";case"darwin":return"macOS";case"linux":return"Linux";default:return process.platform}else return""}function Vd(){if(de.isNode)return process.versions.node}function Qd(){return de.isNode?"NodeJS":"Browser"}function ql(f){return f.stack?f.stack:f.message?f.message:`${f}`}function Kd(){if(typeof globalThis<"u")return globalThis;if(typeof self<"u")return self;if(typeof window<"u")return window;if(typeof global<"u")return global;throw new Error("could not find global")}class Xd extends Go{constructor(l){if(super(),this._logger=l,typeof fetch>"u"||de.isNode){const s=typeof __webpack_require__=="function"?__non_webpack_require__:require;this._jar=new(s("tough-cookie")).CookieJar,typeof fetch>"u"?this._fetchType=s("node-fetch"):this._fetchType=fetch,this._fetchType=s("fetch-cookie")(this._fetchType,this._jar)}else this._fetchType=fetch.bind(Kd());if(typeof AbortController>"u"){const s=typeof __webpack_require__=="function"?__non_webpack_require__:require;this._abortControllerType=s("abort-controller")}else this._abortControllerType=AbortController}async send(l){if(l.abortSignal&&l.abortSignal.aborted)throw new mt;if(!l.method)throw new Error("No method defined.");if(!l.url)throw new Error("No url defined.");const s=new this._abortControllerType;let c;l.abortSignal&&(l.abortSignal.onabort=()=>{s.abort(),c=new mt});let d=null;if(l.timeout){const T=l.timeout;d=setTimeout(()=>{s.abort(),this._logger.log(S.Warning,"Timeout from HTTP request."),c=new rs},T)}l.content===""&&(l.content=void 0),l.content&&(l.headers=l.headers||{},_n(l.content)?l.headers["Content-Type"]="application/octet-stream":l.headers["Content-Type"]="text/plain;charset=UTF-8");let g;try{g=await this._fetchType(l.url,{body:l.content,cache:"no-cache",credentials:l.withCredentials===!0?"include":"same-origin",headers:{"X-Requested-With":"XMLHttpRequest",...l.headers},method:l.method,mode:"cors",redirect:"follow",signal:s.signal})}catch(T){throw c||(this._logger.log(S.Warning,`Error from HTTP request. ${T}.`),T)}finally{d&&clearTimeout(d),l.abortSignal&&(l.abortSignal.onabort=null)}if(!g.ok){const T=await dc(g,"text");throw new vn(T||g.statusText,g.status)}const x=await dc(g,l.responseType);return new vc(g.status,g.statusText,x)}getCookieString(l){let s="";return de.isNode&&this._jar&&this._jar.getCookies(l,(c,d)=>s=d.join("; ")),s}}function dc(f,l){let s;switch(l){case"arraybuffer":s=f.arrayBuffer();break;case"text":s=f.text();break;case"blob":case"document":case"json":throw new Error(`${l} is not supported.`);default:s=f.text();break}return s}class Yd extends Go{constructor(l){super(),this._logger=l}send(l){return l.abortSignal&&l.abortSignal.aborted?Promise.reject(new mt):l.method?l.url?new Promise((s,c)=>{const d=new XMLHttpRequest;d.open(l.method,l.url,!0),d.withCredentials=l.withCredentials===void 0?!0:l.withCredentials,d.setRequestHeader("X-Requested-With","XMLHttpRequest"),l.content===""&&(l.content=void 0),l.content&&(_n(l.content)?d.setRequestHeader("Content-Type","application/octet-stream"):d.setRequestHeader("Content-Type","text/plain;charset=UTF-8"));const g=l.headers;g&&Object.keys(g).forEach(w=>{d.setRequestHeader(w,g[w])}),l.responseType&&(d.responseType=l.responseType),l.abortSignal&&(l.abortSignal.onabort=()=>{d.abort(),c(new mt)}),l.timeout&&(d.timeout=l.timeout),d.onload=()=>{l.abortSignal&&(l.abortSignal.onabort=null),d.status>=200&&d.status<300?s(new vc(d.status,d.statusText,d.response||d.responseText)):c(new vn(d.response||d.responseText||d.statusText,d.status))},d.onerror=()=>{this._logger.log(S.Warning,`Error from HTTP request. ${d.status}: ${d.statusText}.`),c(new vn(d.statusText,d.status))},d.ontimeout=()=>{this._logger.log(S.Warning,"Timeout from HTTP request."),c(new rs)},d.send(l.content)}):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}}class qd extends Go{constructor(l){if(super(),typeof fetch<"u"||de.isNode)this._httpClient=new Xd(l);else if(typeof XMLHttpRequest<"u")this._httpClient=new Yd(l);else throw new Error("No usable HttpClient found.")}send(l){return l.abortSignal&&l.abortSignal.aborted?Promise.reject(new mt):l.method?l.url?this._httpClient.send(l):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}getCookieString(l){return this._httpClient.getCookieString(l)}}class Ge{static write(l){return`${l}${Ge.RecordSeparator}`}static parse(l){if(l[l.length-1]!==Ge.RecordSeparator)throw new Error("Message is incomplete.");const s=l.split(Ge.RecordSeparator);return s.pop(),s}}Ge.RecordSeparatorCode=30;Ge.RecordSeparator=String.fromCharCode(Ge.RecordSeparatorCode);class Gd{writeHandshakeRequest(l){return Ge.write(JSON.stringify(l))}parseHandshakeResponse(l){let s,c;if(_n(l)){const x=new Uint8Array(l),T=x.indexOf(Ge.RecordSeparatorCode);if(T===-1)throw new Error("Message is incomplete.");const $=T+1;s=String.fromCharCode.apply(null,Array.prototype.slice.call(x.slice(0,$))),c=x.byteLength>$?x.slice($).buffer:null}else{const x=l,T=x.indexOf(Ge.RecordSeparator);if(T===-1)throw new Error("Message is incomplete.");const $=T+1;s=x.substring(0,$),c=x.length>$?x.substring($):null}const d=Ge.parse(s),g=JSON.parse(d[0]);if(g.type)throw new Error("Expected a handshake response from the server.");return[c,g]}}var B;(function(f){f[f.Invocation=1]="Invocation",f[f.StreamItem=2]="StreamItem",f[f.Completion=3]="Completion",f[f.StreamInvocation=4]="StreamInvocation",f[f.CancelInvocation=5]="CancelInvocation",f[f.Ping=6]="Ping",f[f.Close=7]="Close",f[f.Ack=8]="Ack",f[f.Sequence=9]="Sequence"})(B||(B={}));class Jd{constructor(){this.observers=[]}next(l){for(const s of this.observers)s.next(l)}error(l){for(const s of this.observers)s.error&&s.error(l)}complete(){for(const l of this.observers)l.complete&&l.complete()}subscribe(l){return this.observers.push(l),new Hd(this,l)}}class Zd{constructor(l,s,c){this._bufferSize=1e5,this._messages=[],this._totalMessageCount=0,this._waitForSequenceMessage=!1,this._nextReceivingSequenceId=1,this._latestReceivedSequenceId=0,this._bufferedByteCount=0,this._reconnectInProgress=!1,this._protocol=l,this._connection=s,this._bufferSize=c}async _send(l){const s=this._protocol.writeMessage(l);let c=Promise.resolve();if(this._isInvocationMessage(l)){this._totalMessageCount++;let d=()=>{},g=()=>{};_n(s)?this._bufferedByteCount+=s.byteLength:this._bufferedByteCount+=s.length,this._bufferedByteCount>=this._bufferSize&&(c=new Promise((w,x)=>{d=w,g=x})),this._messages.push(new bd(s,this._totalMessageCount,d,g))}try{this._reconnectInProgress||await this._connection.send(s)}catch{this._disconnected()}await c}_ack(l){let s=-1;for(let c=0;cthis._nextReceivingSequenceId){this._connection.stop(new Error("Sequence ID greater than amount of messages we've received."));return}this._nextReceivingSequenceId=l.sequenceId}_disconnected(){this._reconnectInProgress=!0,this._waitForSequenceMessage=!0}async _resend(){const l=this._messages.length!==0?this._messages[0]._id:this._totalMessageCount+1;await this._connection.send(this._protocol.writeMessage({type:B.Sequence,sequenceId:l}));const s=this._messages;for(const c of s)await this._connection.send(c._message);this._reconnectInProgress=!1}_dispose(l){l??(l=new Error("Unable to reconnect to server."));for(const s of this._messages)s._rejector(l)}_isInvocationMessage(l){switch(l.type){case B.Invocation:case B.StreamItem:case B.Completion:case B.StreamInvocation:case B.CancelInvocation:return!0;case B.Close:case B.Sequence:case B.Ping:case B.Ack:return!1}}_ackTimer(){this._ackTimerHandle===void 0&&(this._ackTimerHandle=setTimeout(async()=>{try{this._reconnectInProgress||await this._connection.send(this._protocol.writeMessage({type:B.Ack,sequenceId:this._latestReceivedSequenceId}))}catch{}clearTimeout(this._ackTimerHandle),this._ackTimerHandle=void 0},1e3))}}class bd{constructor(l,s,c,d){this._message=l,this._id=s,this._resolver=c,this._rejector=d}}const eh=30*1e3,th=15*1e3,nh=1e5;var ae;(function(f){f.Disconnected="Disconnected",f.Connecting="Connecting",f.Connected="Connected",f.Disconnecting="Disconnecting",f.Reconnecting="Reconnecting"})(ae||(ae={}));class os{static create(l,s,c,d,g,w,x){return new os(l,s,c,d,g,w,x)}constructor(l,s,c,d,g,w,x){this._nextKeepAlive=0,this._freezeEventListener=()=>{this._logger.log(S.Warning,"The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep")},ye.isRequired(l,"connection"),ye.isRequired(s,"logger"),ye.isRequired(c,"protocol"),this.serverTimeoutInMilliseconds=g??eh,this.keepAliveIntervalInMilliseconds=w??th,this._statefulReconnectBufferSize=x??nh,this._logger=s,this._protocol=c,this.connection=l,this._reconnectPolicy=d,this._handshakeProtocol=new Gd,this.connection.onreceive=T=>this._processIncomingData(T),this.connection.onclose=T=>this._connectionClosed(T),this._callbacks={},this._methods={},this._closedCallbacks=[],this._reconnectingCallbacks=[],this._reconnectedCallbacks=[],this._invocationId=0,this._receivedHandshakeResponse=!1,this._connectionState=ae.Disconnected,this._connectionStarted=!1,this._cachedPingMessage=this._protocol.writeMessage({type:B.Ping})}get state(){return this._connectionState}get connectionId(){return this.connection&&this.connection.connectionId||null}get baseUrl(){return this.connection.baseUrl||""}set baseUrl(l){if(this._connectionState!==ae.Disconnected&&this._connectionState!==ae.Reconnecting)throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url.");if(!l)throw new Error("The HubConnection url must be a valid url.");this.connection.baseUrl=l}start(){return this._startPromise=this._startWithStateTransitions(),this._startPromise}async _startWithStateTransitions(){if(this._connectionState!==ae.Disconnected)return Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state."));this._connectionState=ae.Connecting,this._logger.log(S.Debug,"Starting HubConnection.");try{await this._startInternal(),de.isBrowser&&window.document.addEventListener("freeze",this._freezeEventListener),this._connectionState=ae.Connected,this._connectionStarted=!0,this._logger.log(S.Debug,"HubConnection connected successfully.")}catch(l){return this._connectionState=ae.Disconnected,this._logger.log(S.Debug,`HubConnection failed to start successfully because of error '${l}'.`),Promise.reject(l)}}async _startInternal(){this._stopDuringStartError=void 0,this._receivedHandshakeResponse=!1;const l=new Promise((s,c)=>{this._handshakeResolver=s,this._handshakeRejecter=c});await this.connection.start(this._protocol.transferFormat);try{let s=this._protocol.version;this.connection.features.reconnect||(s=1);const c={protocol:this._protocol.name,version:s};if(this._logger.log(S.Debug,"Sending handshake request."),await this._sendMessage(this._handshakeProtocol.writeHandshakeRequest(c)),this._logger.log(S.Information,`Using HubProtocol '${this._protocol.name}'.`),this._cleanupTimeout(),this._resetTimeoutPeriod(),this._resetKeepAliveInterval(),await l,this._stopDuringStartError)throw this._stopDuringStartError;(this.connection.features.reconnect||!1)&&(this._messageBuffer=new Zd(this._protocol,this.connection,this._statefulReconnectBufferSize),this.connection.features.disconnected=this._messageBuffer._disconnected.bind(this._messageBuffer),this.connection.features.resend=()=>{if(this._messageBuffer)return this._messageBuffer._resend()}),this.connection.features.inherentKeepAlive||await this._sendMessage(this._cachedPingMessage)}catch(s){throw this._logger.log(S.Debug,`Hub handshake failed with error '${s}' during start(). Stopping HubConnection.`),this._cleanupTimeout(),this._cleanupPingTimer(),await this.connection.stop(s),s}}async stop(){const l=this._startPromise;this.connection.features.reconnect=!1,this._stopPromise=this._stopInternal(),await this._stopPromise;try{await l}catch{}}_stopInternal(l){if(this._connectionState===ae.Disconnected)return this._logger.log(S.Debug,`Call to HubConnection.stop(${l}) ignored because it is already in the disconnected state.`),Promise.resolve();if(this._connectionState===ae.Disconnecting)return this._logger.log(S.Debug,`Call to HttpConnection.stop(${l}) ignored because the connection is already in the disconnecting state.`),this._stopPromise;const s=this._connectionState;return this._connectionState=ae.Disconnecting,this._logger.log(S.Debug,"Stopping HubConnection."),this._reconnectDelayHandle?(this._logger.log(S.Debug,"Connection stopped during reconnect delay. Done reconnecting."),clearTimeout(this._reconnectDelayHandle),this._reconnectDelayHandle=void 0,this._completeClose(),Promise.resolve()):(s===ae.Connected&&this._sendCloseMessage(),this._cleanupTimeout(),this._cleanupPingTimer(),this._stopDuringStartError=l||new mt("The connection was stopped before the hub handshake could complete."),this.connection.stop(l))}async _sendCloseMessage(){try{await this._sendWithProtocol(this._createCloseMessage())}catch{}}stream(l,...s){const[c,d]=this._replaceStreamingParams(s),g=this._createStreamInvocation(l,s,d);let w;const x=new Jd;return x.cancelCallback=()=>{const T=this._createCancelInvocation(g.invocationId);return delete this._callbacks[g.invocationId],w.then(()=>this._sendWithProtocol(T))},this._callbacks[g.invocationId]=(T,$)=>{if($){x.error($);return}else T&&(T.type===B.Completion?T.error?x.error(new Error(T.error)):x.complete():x.next(T.item))},w=this._sendWithProtocol(g).catch(T=>{x.error(T),delete this._callbacks[g.invocationId]}),this._launchStreams(c,w),x}_sendMessage(l){return this._resetKeepAliveInterval(),this.connection.send(l)}_sendWithProtocol(l){return this._messageBuffer?this._messageBuffer._send(l):this._sendMessage(this._protocol.writeMessage(l))}send(l,...s){const[c,d]=this._replaceStreamingParams(s),g=this._sendWithProtocol(this._createInvocation(l,s,!0,d));return this._launchStreams(c,g),g}invoke(l,...s){const[c,d]=this._replaceStreamingParams(s),g=this._createInvocation(l,s,!1,d);return new Promise((x,T)=>{this._callbacks[g.invocationId]=(U,X)=>{if(X){T(X);return}else U&&(U.type===B.Completion?U.error?T(new Error(U.error)):x(U.result):T(new Error(`Unexpected message type: ${U.type}`)))};const $=this._sendWithProtocol(g).catch(U=>{T(U),delete this._callbacks[g.invocationId]});this._launchStreams(c,$)})}on(l,s){!l||!s||(l=l.toLowerCase(),this._methods[l]||(this._methods[l]=[]),this._methods[l].indexOf(s)===-1&&this._methods[l].push(s))}off(l,s){if(!l)return;l=l.toLowerCase();const c=this._methods[l];if(c)if(s){const d=c.indexOf(s);d!==-1&&(c.splice(d,1),c.length===0&&delete this._methods[l])}else delete this._methods[l]}onclose(l){l&&this._closedCallbacks.push(l)}onreconnecting(l){l&&this._reconnectingCallbacks.push(l)}onreconnected(l){l&&this._reconnectedCallbacks.push(l)}_processIncomingData(l){if(this._cleanupTimeout(),this._receivedHandshakeResponse||(l=this._processHandshakeResponse(l),this._receivedHandshakeResponse=!0),l){const s=this._protocol.parseMessages(l,this._logger);for(const c of s)if(!(this._messageBuffer&&!this._messageBuffer._shouldProcessMessage(c)))switch(c.type){case B.Invocation:this._invokeClientMethod(c).catch(d=>{this._logger.log(S.Error,`Invoke client method threw error: ${ql(d)}`)});break;case B.StreamItem:case B.Completion:{const d=this._callbacks[c.invocationId];if(d){c.type===B.Completion&&delete this._callbacks[c.invocationId];try{d(c)}catch(g){this._logger.log(S.Error,`Stream callback threw error: ${ql(g)}`)}}break}case B.Ping:break;case B.Close:{this._logger.log(S.Information,"Close message received from server.");const d=c.error?new Error("Server returned an error on close: "+c.error):void 0;c.allowReconnect===!0?this.connection.stop(d):this._stopPromise=this._stopInternal(d);break}case B.Ack:this._messageBuffer&&this._messageBuffer._ack(c);break;case B.Sequence:this._messageBuffer&&this._messageBuffer._resetSequence(c);break;default:this._logger.log(S.Warning,`Invalid message type: ${c.type}.`);break}}this._resetTimeoutPeriod()}_processHandshakeResponse(l){let s,c;try{[c,s]=this._handshakeProtocol.parseHandshakeResponse(l)}catch(d){const g="Error parsing handshake response: "+d;this._logger.log(S.Error,g);const w=new Error(g);throw this._handshakeRejecter(w),w}if(s.error){const d="Server returned handshake error: "+s.error;this._logger.log(S.Error,d);const g=new Error(d);throw this._handshakeRejecter(g),g}else this._logger.log(S.Debug,"Server handshake complete.");return this._handshakeResolver(),c}_resetKeepAliveInterval(){this.connection.features.inherentKeepAlive||(this._nextKeepAlive=new Date().getTime()+this.keepAliveIntervalInMilliseconds,this._cleanupPingTimer())}_resetTimeoutPeriod(){if((!this.connection.features||!this.connection.features.inherentKeepAlive)&&(this._timeoutHandle=setTimeout(()=>this.serverTimeout(),this.serverTimeoutInMilliseconds),this._pingServerHandle===void 0)){let l=this._nextKeepAlive-new Date().getTime();l<0&&(l=0),this._pingServerHandle=setTimeout(async()=>{if(this._connectionState===ae.Connected)try{await this._sendMessage(this._cachedPingMessage)}catch{this._cleanupPingTimer()}},l)}}serverTimeout(){this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server."))}async _invokeClientMethod(l){const s=l.target.toLowerCase(),c=this._methods[s];if(!c){this._logger.log(S.Warning,`No client method with the name '${s}' found.`),l.invocationId&&(this._logger.log(S.Warning,`No result given for '${s}' method and invocation ID '${l.invocationId}'.`),await this._sendWithProtocol(this._createCompletionMessage(l.invocationId,"Client didn't provide a result.",null)));return}const d=c.slice(),g=!!l.invocationId;let w,x,T;for(const $ of d)try{const U=w;w=await $.apply(this,l.arguments),g&&w&&U&&(this._logger.log(S.Error,`Multiple results provided for '${s}'. Sending error to server.`),T=this._createCompletionMessage(l.invocationId,"Client provided multiple results.",null)),x=void 0}catch(U){x=U,this._logger.log(S.Error,`A callback for the method '${s}' threw error '${U}'.`)}T?await this._sendWithProtocol(T):g?(x?T=this._createCompletionMessage(l.invocationId,`${x}`,null):w!==void 0?T=this._createCompletionMessage(l.invocationId,null,w):(this._logger.log(S.Warning,`No result given for '${s}' method and invocation ID '${l.invocationId}'.`),T=this._createCompletionMessage(l.invocationId,"Client didn't provide a result.",null)),await this._sendWithProtocol(T)):w&&this._logger.log(S.Error,`Result given for '${s}' method but server is not expecting a result.`)}_connectionClosed(l){this._logger.log(S.Debug,`HubConnection.connectionClosed(${l}) called while in state ${this._connectionState}.`),this._stopDuringStartError=this._stopDuringStartError||l||new mt("The underlying connection was closed before the hub handshake could complete."),this._handshakeResolver&&this._handshakeResolver(),this._cancelCallbacksWithError(l||new Error("Invocation canceled due to the underlying connection being closed.")),this._cleanupTimeout(),this._cleanupPingTimer(),this._connectionState===ae.Disconnecting?this._completeClose(l):this._connectionState===ae.Connected&&this._reconnectPolicy?this._reconnect(l):this._connectionState===ae.Connected&&this._completeClose(l)}_completeClose(l){if(this._connectionStarted){this._connectionState=ae.Disconnected,this._connectionStarted=!1,this._messageBuffer&&(this._messageBuffer._dispose(l??new Error("Connection closed.")),this._messageBuffer=void 0),de.isBrowser&&window.document.removeEventListener("freeze",this._freezeEventListener);try{this._closedCallbacks.forEach(s=>s.apply(this,[l]))}catch(s){this._logger.log(S.Error,`An onclose callback called with error '${l}' threw error '${s}'.`)}}}async _reconnect(l){const s=Date.now();let c=0,d=l!==void 0?l:new Error("Attempting to reconnect due to a unknown error."),g=this._getNextRetryDelay(c++,0,d);if(g===null){this._logger.log(S.Debug,"Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt."),this._completeClose(l);return}if(this._connectionState=ae.Reconnecting,l?this._logger.log(S.Information,`Connection reconnecting because of error '${l}'.`):this._logger.log(S.Information,"Connection reconnecting."),this._reconnectingCallbacks.length!==0){try{this._reconnectingCallbacks.forEach(w=>w.apply(this,[l]))}catch(w){this._logger.log(S.Error,`An onreconnecting callback called with error '${l}' threw error '${w}'.`)}if(this._connectionState!==ae.Reconnecting){this._logger.log(S.Debug,"Connection left the reconnecting state in onreconnecting callback. Done reconnecting.");return}}for(;g!==null;){if(this._logger.log(S.Information,`Reconnect attempt number ${c} will start in ${g} ms.`),await new Promise(w=>{this._reconnectDelayHandle=setTimeout(w,g)}),this._reconnectDelayHandle=void 0,this._connectionState!==ae.Reconnecting){this._logger.log(S.Debug,"Connection left the reconnecting state during reconnect delay. Done reconnecting.");return}try{if(await this._startInternal(),this._connectionState=ae.Connected,this._logger.log(S.Information,"HubConnection reconnected successfully."),this._reconnectedCallbacks.length!==0)try{this._reconnectedCallbacks.forEach(w=>w.apply(this,[this.connection.connectionId]))}catch(w){this._logger.log(S.Error,`An onreconnected callback called with connectionId '${this.connection.connectionId}; threw error '${w}'.`)}return}catch(w){if(this._logger.log(S.Information,`Reconnect attempt failed because of error '${w}'.`),this._connectionState!==ae.Reconnecting){this._logger.log(S.Debug,`Connection moved to the '${this._connectionState}' from the reconnecting state during reconnect attempt. Done reconnecting.`),this._connectionState===ae.Disconnecting&&this._completeClose();return}d=w instanceof Error?w:new Error(w.toString()),g=this._getNextRetryDelay(c++,Date.now()-s,d)}}this._logger.log(S.Information,`Reconnect retries have been exhausted after ${Date.now()-s} ms and ${c} failed attempts. Connection disconnecting.`),this._completeClose()}_getNextRetryDelay(l,s,c){try{return this._reconnectPolicy.nextRetryDelayInMilliseconds({elapsedMilliseconds:s,previousRetryCount:l,retryReason:c})}catch(d){return this._logger.log(S.Error,`IRetryPolicy.nextRetryDelayInMilliseconds(${l}, ${s}) threw error '${d}'.`),null}}_cancelCallbacksWithError(l){const s=this._callbacks;this._callbacks={},Object.keys(s).forEach(c=>{const d=s[c];try{d(null,l)}catch(g){this._logger.log(S.Error,`Stream 'error' callback called with '${l}' threw error: ${ql(g)}`)}})}_cleanupPingTimer(){this._pingServerHandle&&(clearTimeout(this._pingServerHandle),this._pingServerHandle=void 0)}_cleanupTimeout(){this._timeoutHandle&&clearTimeout(this._timeoutHandle)}_createInvocation(l,s,c,d){if(c)return d.length!==0?{arguments:s,streamIds:d,target:l,type:B.Invocation}:{arguments:s,target:l,type:B.Invocation};{const g=this._invocationId;return this._invocationId++,d.length!==0?{arguments:s,invocationId:g.toString(),streamIds:d,target:l,type:B.Invocation}:{arguments:s,invocationId:g.toString(),target:l,type:B.Invocation}}}_launchStreams(l,s){if(l.length!==0){s||(s=Promise.resolve());for(const c in l)l[c].subscribe({complete:()=>{s=s.then(()=>this._sendWithProtocol(this._createCompletionMessage(c)))},error:d=>{let g;d instanceof Error?g=d.message:d&&d.toString?g=d.toString():g="Unknown error",s=s.then(()=>this._sendWithProtocol(this._createCompletionMessage(c,g)))},next:d=>{s=s.then(()=>this._sendWithProtocol(this._createStreamItemMessage(c,d)))}})}}_replaceStreamingParams(l){const s=[],c=[];for(let d=0;d0)&&(s=!1,this._accessToken=await this._accessTokenFactory()),this._setAuthorizationHeader(l);const c=await this._innerClient.send(l);return s&&c.statusCode===401&&this._accessTokenFactory?(this._accessToken=await this._accessTokenFactory(),this._setAuthorizationHeader(l),await this._innerClient.send(l)):c}_setAuthorizationHeader(l){l.headers||(l.headers={}),this._accessToken?l.headers[yn.Authorization]=`Bearer ${this._accessToken}`:this._accessTokenFactory&&l.headers[yn.Authorization]&&delete l.headers[yn.Authorization]}getCookieString(l){return this._innerClient.getCookieString(l)}}var Se;(function(f){f[f.None=0]="None",f[f.WebSockets=1]="WebSockets",f[f.ServerSentEvents=2]="ServerSentEvents",f[f.LongPolling=4]="LongPolling"})(Se||(Se={}));var ze;(function(f){f[f.Text=1]="Text",f[f.Binary=2]="Binary"})(ze||(ze={}));let ih=class{constructor(){this._isAborted=!1,this.onabort=null}abort(){this._isAborted||(this._isAborted=!0,this.onabort&&this.onabort())}get signal(){return this}get aborted(){return this._isAborted}};class pc{get pollAborted(){return this._pollAbort.aborted}constructor(l,s,c){this._httpClient=l,this._logger=s,this._pollAbort=new ih,this._options=c,this._running=!1,this.onreceive=null,this.onclose=null}async connect(l,s){if(ye.isRequired(l,"url"),ye.isRequired(s,"transferFormat"),ye.isIn(s,ze,"transferFormat"),this._url=l,this._logger.log(S.Trace,"(LongPolling transport) Connecting."),s===ze.Binary&&typeof XMLHttpRequest<"u"&&typeof new XMLHttpRequest().responseType!="string")throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");const[c,d]=Kn(),g={[c]:d,...this._options.headers},w={abortSignal:this._pollAbort.signal,headers:g,timeout:1e5,withCredentials:this._options.withCredentials};s===ze.Binary&&(w.responseType="arraybuffer");const x=`${l}&_=${Date.now()}`;this._logger.log(S.Trace,`(LongPolling transport) polling: ${x}.`);const T=await this._httpClient.get(x,w);T.statusCode!==200?(this._logger.log(S.Error,`(LongPolling transport) Unexpected response code: ${T.statusCode}.`),this._closeError=new vn(T.statusText||"",T.statusCode),this._running=!1):this._running=!0,this._receiving=this._poll(this._url,w)}async _poll(l,s){try{for(;this._running;)try{const c=`${l}&_=${Date.now()}`;this._logger.log(S.Trace,`(LongPolling transport) polling: ${c}.`);const d=await this._httpClient.get(c,s);d.statusCode===204?(this._logger.log(S.Information,"(LongPolling transport) Poll terminated by server."),this._running=!1):d.statusCode!==200?(this._logger.log(S.Error,`(LongPolling transport) Unexpected response code: ${d.statusCode}.`),this._closeError=new vn(d.statusText||"",d.statusCode),this._running=!1):d.content?(this._logger.log(S.Trace,`(LongPolling transport) data received. ${jr(d.content,this._options.logMessageContent)}.`),this.onreceive&&this.onreceive(d.content)):this._logger.log(S.Trace,"(LongPolling transport) Poll timed out, reissuing.")}catch(c){this._running?c instanceof rs?this._logger.log(S.Trace,"(LongPolling transport) Poll timed out, reissuing."):(this._closeError=c,this._running=!1):this._logger.log(S.Trace,`(LongPolling transport) Poll errored after shutdown: ${c.message}`)}}finally{this._logger.log(S.Trace,"(LongPolling transport) Polling complete."),this.pollAborted||this._raiseOnClose()}}async send(l){return this._running?yc(this._logger,"LongPolling",this._httpClient,this._url,l,this._options):Promise.reject(new Error("Cannot send until the transport is connected"))}async stop(){this._logger.log(S.Trace,"(LongPolling transport) Stopping polling."),this._running=!1,this._pollAbort.abort();try{await this._receiving,this._logger.log(S.Trace,`(LongPolling transport) sending DELETE request to ${this._url}.`);const l={},[s,c]=Kn();l[s]=c;const d={headers:{...l,...this._options.headers},timeout:this._options.timeout,withCredentials:this._options.withCredentials};let g;try{await this._httpClient.delete(this._url,d)}catch(w){g=w}g?g instanceof vn&&(g.statusCode===404?this._logger.log(S.Trace,"(LongPolling transport) A 404 response was returned from sending a DELETE request."):this._logger.log(S.Trace,`(LongPolling transport) Error sending a DELETE request: ${g}`)):this._logger.log(S.Trace,"(LongPolling transport) DELETE request accepted.")}finally{this._logger.log(S.Trace,"(LongPolling transport) Stop finished."),this._raiseOnClose()}}_raiseOnClose(){if(this.onclose){let l="(LongPolling transport) Firing onclose event.";this._closeError&&(l+=" Error: "+this._closeError),this._logger.log(S.Trace,l),this.onclose(this._closeError)}}}class lh{constructor(l,s,c,d){this._httpClient=l,this._accessToken=s,this._logger=c,this._options=d,this.onreceive=null,this.onclose=null}async connect(l,s){return ye.isRequired(l,"url"),ye.isRequired(s,"transferFormat"),ye.isIn(s,ze,"transferFormat"),this._logger.log(S.Trace,"(SSE transport) Connecting."),this._url=l,this._accessToken&&(l+=(l.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(this._accessToken)}`),new Promise((c,d)=>{let g=!1;if(s!==ze.Text){d(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"));return}let w;if(de.isBrowser||de.isWebWorker)w=new this._options.EventSource(l,{withCredentials:this._options.withCredentials});else{const x=this._httpClient.getCookieString(l),T={};T.Cookie=x;const[$,U]=Kn();T[$]=U,w=new this._options.EventSource(l,{withCredentials:this._options.withCredentials,headers:{...T,...this._options.headers}})}try{w.onmessage=x=>{if(this.onreceive)try{this._logger.log(S.Trace,`(SSE transport) data received. ${jr(x.data,this._options.logMessageContent)}.`),this.onreceive(x.data)}catch(T){this._close(T);return}},w.onerror=x=>{g?this._close():d(new Error("EventSource failed to connect. The connection could not be found on the server, either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. If you have multiple servers check that sticky sessions are enabled."))},w.onopen=()=>{this._logger.log(S.Information,`SSE connected to ${this._url}`),this._eventSource=w,g=!0,c()}}catch(x){d(x);return}})}async send(l){return this._eventSource?yc(this._logger,"SSE",this._httpClient,this._url,l,this._options):Promise.reject(new Error("Cannot send until the transport is connected"))}stop(){return this._close(),Promise.resolve()}_close(l){this._eventSource&&(this._eventSource.close(),this._eventSource=void 0,this.onclose&&this.onclose(l))}}class sh{constructor(l,s,c,d,g,w){this._logger=c,this._accessTokenFactory=s,this._logMessageContent=d,this._webSocketConstructor=g,this._httpClient=l,this.onreceive=null,this.onclose=null,this._headers=w}async connect(l,s){ye.isRequired(l,"url"),ye.isRequired(s,"transferFormat"),ye.isIn(s,ze,"transferFormat"),this._logger.log(S.Trace,"(WebSockets transport) Connecting.");let c;return this._accessTokenFactory&&(c=await this._accessTokenFactory()),new Promise((d,g)=>{l=l.replace(/^http/,"ws");let w;const x=this._httpClient.getCookieString(l);let T=!1;if(de.isNode||de.isReactNative){const $={},[U,X]=Kn();$[U]=X,c&&($[yn.Authorization]=`Bearer ${c}`),x&&($[yn.Cookie]=x),w=new this._webSocketConstructor(l,void 0,{headers:{...$,...this._headers}})}else c&&(l+=(l.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(c)}`);w||(w=new this._webSocketConstructor(l)),s===ze.Binary&&(w.binaryType="arraybuffer"),w.onopen=$=>{this._logger.log(S.Information,`WebSocket connected to ${l}.`),this._webSocket=w,T=!0,d()},w.onerror=$=>{let U=null;typeof ErrorEvent<"u"&&$ instanceof ErrorEvent?U=$.error:U="There was an error with the transport",this._logger.log(S.Information,`(WebSockets transport) ${U}.`)},w.onmessage=$=>{if(this._logger.log(S.Trace,`(WebSockets transport) data received. ${jr($.data,this._logMessageContent)}.`),this.onreceive)try{this.onreceive($.data)}catch(U){this._close(U);return}},w.onclose=$=>{if(T)this._close($);else{let U=null;typeof ErrorEvent<"u"&&$ instanceof ErrorEvent?U=$.error:U="WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.",g(new Error(U))}}})}send(l){return this._webSocket&&this._webSocket.readyState===this._webSocketConstructor.OPEN?(this._logger.log(S.Trace,`(WebSockets transport) sending data. ${jr(l,this._logMessageContent)}.`),this._webSocket.send(l),Promise.resolve()):Promise.reject("WebSocket is not in the OPEN state")}stop(){return this._webSocket&&this._close(void 0),Promise.resolve()}_close(l){this._webSocket&&(this._webSocket.onclose=()=>{},this._webSocket.onmessage=()=>{},this._webSocket.onerror=()=>{},this._webSocket.close(),this._webSocket=void 0),this._logger.log(S.Trace,"(WebSockets transport) socket closed."),this.onclose&&(this._isCloseEvent(l)&&(l.wasClean===!1||l.code!==1e3)?this.onclose(new Error(`WebSocket closed with status code: ${l.code} (${l.reason||"no reason given"}).`)):l instanceof Error?this.onclose(l):this.onclose())}_isCloseEvent(l){return l&&typeof l.wasClean=="boolean"&&typeof l.code=="number"}}const gc=100;class uh{constructor(l,s={}){if(this._stopPromiseResolver=()=>{},this.features={},this._negotiateVersion=1,ye.isRequired(l,"url"),this._logger=Ud(s.logger),this.baseUrl=this._resolveUrl(l),s=s||{},s.logMessageContent=s.logMessageContent===void 0?!1:s.logMessageContent,typeof s.withCredentials=="boolean"||s.withCredentials===void 0)s.withCredentials=s.withCredentials===void 0?!0:s.withCredentials;else throw new Error("withCredentials option was not a 'boolean' or 'undefined' value");s.timeout=s.timeout===void 0?100*1e3:s.timeout;let c=null,d=null;if(de.isNode&&typeof require<"u"){const g=typeof __webpack_require__=="function"?__non_webpack_require__:require;c=g("ws"),d=g("eventsource")}!de.isNode&&typeof WebSocket<"u"&&!s.WebSocket?s.WebSocket=WebSocket:de.isNode&&!s.WebSocket&&c&&(s.WebSocket=c),!de.isNode&&typeof EventSource<"u"&&!s.EventSource?s.EventSource=EventSource:de.isNode&&!s.EventSource&&typeof d<"u"&&(s.EventSource=d),this._httpClient=new oh(s.httpClient||new qd(this._logger),s.accessTokenFactory),this._connectionState="Disconnected",this._connectionStarted=!1,this._options=s,this.onreceive=null,this.onclose=null}async start(l){if(l=l||ze.Binary,ye.isIn(l,ze,"transferFormat"),this._logger.log(S.Debug,`Starting connection with transfer format '${ze[l]}'.`),this._connectionState!=="Disconnected")return Promise.reject(new Error("Cannot start an HttpConnection that is not in the 'Disconnected' state."));if(this._connectionState="Connecting",this._startInternalPromise=this._startInternal(l),await this._startInternalPromise,this._connectionState==="Disconnecting"){const s="Failed to start the HttpConnection before stop() was called.";return this._logger.log(S.Error,s),await this._stopPromise,Promise.reject(new mt(s))}else if(this._connectionState!=="Connected"){const s="HttpConnection.startInternal completed gracefully but didn't enter the connection into the connected state!";return this._logger.log(S.Error,s),Promise.reject(new mt(s))}this._connectionStarted=!0}send(l){return this._connectionState!=="Connected"?Promise.reject(new Error("Cannot send data if the connection is not in the 'Connected' State.")):(this._sendQueue||(this._sendQueue=new is(this.transport)),this._sendQueue.send(l))}async stop(l){if(this._connectionState==="Disconnected")return this._logger.log(S.Debug,`Call to HttpConnection.stop(${l}) ignored because the connection is already in the disconnected state.`),Promise.resolve();if(this._connectionState==="Disconnecting")return this._logger.log(S.Debug,`Call to HttpConnection.stop(${l}) ignored because the connection is already in the disconnecting state.`),this._stopPromise;this._connectionState="Disconnecting",this._stopPromise=new Promise(s=>{this._stopPromiseResolver=s}),await this._stopInternal(l),await this._stopPromise}async _stopInternal(l){this._stopError=l;try{await this._startInternalPromise}catch{}if(this.transport){try{await this.transport.stop()}catch(s){this._logger.log(S.Error,`HttpConnection.transport.stop() threw error '${s}'.`),this._stopConnection()}this.transport=void 0}else this._logger.log(S.Debug,"HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.")}async _startInternal(l){let s=this.baseUrl;this._accessTokenFactory=this._options.accessTokenFactory,this._httpClient._accessTokenFactory=this._accessTokenFactory;try{if(this._options.skipNegotiation)if(this._options.transport===Se.WebSockets)this.transport=this._constructTransport(Se.WebSockets),await this._startTransport(s,l);else throw new Error("Negotiation can only be skipped when using the WebSocket transport directly.");else{let c=null,d=0;do{if(c=await this._getNegotiationResponse(s),this._connectionState==="Disconnecting"||this._connectionState==="Disconnected")throw new mt("The connection was stopped during negotiation.");if(c.error)throw new Error(c.error);if(c.ProtocolVersion)throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");if(c.url&&(s=c.url),c.accessToken){const g=c.accessToken;this._accessTokenFactory=()=>g,this._httpClient._accessToken=g,this._httpClient._accessTokenFactory=void 0}d++}while(c.url&&d0?Promise.reject(new $d(`Unable to connect to the server with any of the available transports. ${w.join(" ")}`,w)):Promise.reject(new Error("None of the transports supported by the client are supported by the server."))}_constructTransport(l){switch(l){case Se.WebSockets:if(!this._options.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new sh(this._httpClient,this._accessTokenFactory,this._logger,this._options.logMessageContent,this._options.WebSocket,this._options.headers||{});case Se.ServerSentEvents:if(!this._options.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new lh(this._httpClient,this._httpClient._accessToken,this._logger,this._options);case Se.LongPolling:return new pc(this._httpClient,this._logger,this._options);default:throw new Error(`Unknown transport: ${l}.`)}}_startTransport(l,s){return this.transport.onreceive=this.onreceive,this.features.reconnect?this.transport.onclose=async c=>{let d=!1;if(this.features.reconnect)try{this.features.disconnected(),await this.transport.connect(l,s),await this.features.resend()}catch{d=!0}else{this._stopConnection(c);return}d&&this._stopConnection(c)}:this.transport.onclose=c=>this._stopConnection(c),this.transport.connect(l,s)}_resolveTransportOrError(l,s,c,d){const g=Se[l.transport];if(g==null)return this._logger.log(S.Debug,`Skipping transport '${l.transport}' because it is not supported by this client.`),new Error(`Skipping transport '${l.transport}' because it is not supported by this client.`);if(ah(s,g))if(l.transferFormats.map(x=>ze[x]).indexOf(c)>=0){if(g===Se.WebSockets&&!this._options.WebSocket||g===Se.ServerSentEvents&&!this._options.EventSource)return this._logger.log(S.Debug,`Skipping transport '${Se[g]}' because it is not supported in your environment.'`),new Md(`'${Se[g]}' is not supported in your environment.`,g);this._logger.log(S.Debug,`Selecting transport '${Se[g]}'.`);try{return this.features.reconnect=g===Se.WebSockets?d:void 0,this._constructTransport(g)}catch(x){return x}}else return this._logger.log(S.Debug,`Skipping transport '${Se[g]}' because it does not support the requested transfer format '${ze[c]}'.`),new Error(`'${Se[g]}' does not support ${ze[c]}.`);else return this._logger.log(S.Debug,`Skipping transport '${Se[g]}' because it was disabled by the client.`),new jd(`'${Se[g]}' is disabled by the client.`,g)}_isITransport(l){return l&&typeof l=="object"&&"connect"in l}_stopConnection(l){if(this._logger.log(S.Debug,`HttpConnection.stopConnection(${l}) called while in state ${this._connectionState}.`),this.transport=void 0,l=this._stopError||l,this._stopError=void 0,this._connectionState==="Disconnected"){this._logger.log(S.Debug,`Call to HttpConnection.stopConnection(${l}) was ignored because the connection is already in the disconnected state.`);return}if(this._connectionState==="Connecting")throw this._logger.log(S.Warning,`Call to HttpConnection.stopConnection(${l}) was ignored because the connection is still in the connecting state.`),new Error(`HttpConnection.stopConnection(${l}) was called while the connection is still in the connecting state.`);if(this._connectionState==="Disconnecting"&&this._stopPromiseResolver(),l?this._logger.log(S.Error,`Connection disconnected with error '${l}'.`):this._logger.log(S.Information,"Connection disconnected."),this._sendQueue&&(this._sendQueue.stop().catch(s=>{this._logger.log(S.Error,`TransportSendQueue.stop() threw error '${s}'.`)}),this._sendQueue=void 0),this.connectionId=void 0,this._connectionState="Disconnected",this._connectionStarted){this._connectionStarted=!1;try{this.onclose&&this.onclose(l)}catch(s){this._logger.log(S.Error,`HttpConnection.onclose(${l}) threw error '${s}'.`)}}}_resolveUrl(l){if(l.lastIndexOf("https://",0)===0||l.lastIndexOf("http://",0)===0)return l;if(!de.isBrowser)throw new Error(`Cannot resolve '${l}'.`);const s=window.document.createElement("a");return s.href=l,this._logger.log(S.Information,`Normalizing '${l}' to '${s.href}'.`),s.href}_resolveNegotiateUrl(l){const s=new URL(l);s.pathname.endsWith("/")?s.pathname+="negotiate":s.pathname+="/negotiate";const c=new URLSearchParams(s.searchParams);return c.has("negotiateVersion")||c.append("negotiateVersion",this._negotiateVersion.toString()),c.has("useStatefulReconnect")?c.get("useStatefulReconnect")==="true"&&(this._options._useStatefulReconnect=!0):this._options._useStatefulReconnect===!0&&c.append("useStatefulReconnect","true"),s.search=c.toString(),s.toString()}}function ah(f,l){return!f||(l&f)!==0}class is{constructor(l){this._transport=l,this._buffer=[],this._executing=!0,this._sendBufferedData=new Yo,this._transportResult=new Yo,this._sendLoopPromise=this._sendLoop()}send(l){return this._bufferData(l),this._transportResult||(this._transportResult=new Yo),this._transportResult.promise}stop(){return this._executing=!1,this._sendBufferedData.resolve(),this._sendLoopPromise}_bufferData(l){if(this._buffer.length&&typeof this._buffer[0]!=typeof l)throw new Error(`Expected data to be of type ${typeof this._buffer} but was of type ${typeof l}`);this._buffer.push(l),this._sendBufferedData.resolve()}async _sendLoop(){for(;;){if(await this._sendBufferedData.promise,!this._executing){this._transportResult&&this._transportResult.reject("Connection stopped.");break}this._sendBufferedData=new Yo;const l=this._transportResult;this._transportResult=void 0;const s=typeof this._buffer[0]=="string"?this._buffer.join(""):is._concatBuffers(this._buffer);this._buffer.length=0;try{await this._transport.send(s),l.resolve()}catch(c){l.reject(c)}}}static _concatBuffers(l){const s=l.map(g=>g.byteLength).reduce((g,w)=>g+w),c=new Uint8Array(s);let d=0;for(const g of l)c.set(new Uint8Array(g),d),d+=g.byteLength;return c.buffer}}class Yo{constructor(){this.promise=new Promise((l,s)=>[this._resolver,this._rejecter]=[l,s])}resolve(){this._resolver()}reject(l){this._rejecter(l)}}const ch="json";class fh{constructor(){this.name=ch,this.version=2,this.transferFormat=ze.Text}parseMessages(l,s){if(typeof l!="string")throw new Error("Invalid input for JSON hub protocol. Expected a string.");if(!l)return[];s===null&&(s=Mr.instance);const c=Ge.parse(l),d=[];for(const g of c){const w=JSON.parse(g);if(typeof w.type!="number")throw new Error("Invalid payload.");switch(w.type){case B.Invocation:this._isInvocationMessage(w);break;case B.StreamItem:this._isStreamItemMessage(w);break;case B.Completion:this._isCompletionMessage(w);break;case B.Ping:break;case B.Close:break;case B.Ack:this._isAckMessage(w);break;case B.Sequence:this._isSequenceMessage(w);break;default:s.log(S.Information,"Unknown message type '"+w.type+"' ignored.");continue}d.push(w)}return d}writeMessage(l){return Ge.write(JSON.stringify(l))}_isInvocationMessage(l){this._assertNotEmptyString(l.target,"Invalid payload for Invocation message."),l.invocationId!==void 0&&this._assertNotEmptyString(l.invocationId,"Invalid payload for Invocation message.")}_isStreamItemMessage(l){if(this._assertNotEmptyString(l.invocationId,"Invalid payload for StreamItem message."),l.item===void 0)throw new Error("Invalid payload for StreamItem message.")}_isCompletionMessage(l){if(l.result&&l.error)throw new Error("Invalid payload for Completion message.");!l.result&&l.error&&this._assertNotEmptyString(l.error,"Invalid payload for Completion message."),this._assertNotEmptyString(l.invocationId,"Invalid payload for Completion message.")}_isAckMessage(l){if(typeof l.sequenceId!="number")throw new Error("Invalid SequenceId for Ack message.")}_isSequenceMessage(l){if(typeof l.sequenceId!="number")throw new Error("Invalid SequenceId for Sequence message.")}_assertNotEmptyString(l,s){if(typeof l!="string"||l==="")throw new Error(s)}}const dh={trace:S.Trace,debug:S.Debug,info:S.Information,information:S.Information,warn:S.Warning,warning:S.Warning,error:S.Error,critical:S.Critical,none:S.None};function hh(f){const l=dh[f.toLowerCase()];if(typeof l<"u")return l;throw new Error(`Unknown log level: ${f}`)}class ph{configureLogging(l){if(ye.isRequired(l,"logging"),gh(l))this.logger=l;else if(typeof l=="string"){const s=hh(l);this.logger=new qo(s)}else this.logger=new qo(l);return this}withUrl(l,s){return ye.isRequired(l,"url"),ye.isNotEmpty(l,"url"),this.url=l,typeof s=="object"?this.httpConnectionOptions={...this.httpConnectionOptions,...s}:this.httpConnectionOptions={...this.httpConnectionOptions,transport:s},this}withHubProtocol(l){return ye.isRequired(l,"protocol"),this.protocol=l,this}withAutomaticReconnect(l){if(this.reconnectPolicy)throw new Error("A reconnectPolicy has already been set.");return l?Array.isArray(l)?this.reconnectPolicy=new hc(l):this.reconnectPolicy=l:this.reconnectPolicy=new hc,this}withServerTimeout(l){return ye.isRequired(l,"milliseconds"),this._serverTimeoutInMilliseconds=l,this}withKeepAliveInterval(l){return ye.isRequired(l,"milliseconds"),this._keepAliveIntervalInMilliseconds=l,this}withStatefulReconnect(l){return this.httpConnectionOptions===void 0&&(this.httpConnectionOptions={}),this.httpConnectionOptions._useStatefulReconnect=!0,this._statefulReconnectBufferSize=l==null?void 0:l.bufferSize,this}build(){const l=this.httpConnectionOptions||{};if(l.logger===void 0&&(l.logger=this.logger),!this.url)throw new Error("The 'HubConnectionBuilder.withUrl' method must be called before building the connection.");const s=new uh(this.url,l);return os.create(s,this.logger||Mr.instance,this.protocol||new fh,this.reconnectPolicy,this._serverTimeoutInMilliseconds,this._keepAliveIntervalInMilliseconds,this._statefulReconnectBufferSize)}}function gh(f){return f.log!==void 0}function mh(){const[f,l]=Lr.useState(null),[s,c]=Lr.useState(!1);return Lr.useEffect(()=>{const g=new ph().withUrl("/hubs/system").withAutomaticReconnect().build();g.on("SystemStatusUpdate",x=>l(x)),g.onclose(()=>c(!1)),g.onreconnecting(()=>c(!1)),g.onreconnected(()=>c(!0)),g.start().then(()=>c(!0)).catch(x=>console.error("SignalR error:",x));const w=setInterval(async()=>{try{const x=await fetch("/api/system/status/live");if(x.ok){const T=await x.json();l(T)}}catch{}},2e3);return()=>{clearInterval(w),g.stop()}},[]),{status:f,connected:s}}function vh(){const{status:f,connected:l}=mh(),[s,c]=Lr.useState(1);return D.jsxs("div",{style:tn.root,children:[D.jsxs("header",{style:tn.header,children:[D.jsx("span",{style:tn.logo,children:"⚙ XTS PRIME MOVER"}),D.jsx("span",{style:tn.subtitle,children:"Temporal Distributed Control Room"}),D.jsxs("span",{style:{marginLeft:"auto",fontSize:11,color:l?"#4ade80":"#f87171"},children:["● ",l?"LIVE":"RECONNECTING"]})]}),D.jsx(Ld,{isRunning:(f==null?void 0:f.isRunning)??!1,speed:s,onSpeedChange:c}),D.jsxs("div",{style:tn.main,children:[D.jsx("div",{style:tn.trackSection,children:D.jsx(Rd,{movers:(f==null?void 0:f.movers)??[],machines:(f==null?void 0:f.machines)??[]})}),D.jsxs("div",{style:tn.sidePanel,children:[D.jsx(Nd,{status:f}),D.jsxs("div",{style:{marginTop:12},children:[D.jsx("div",{style:tn.sectionLabel,children:"MACHINE PANELS"}),Array.from({length:4},(d,g)=>D.jsx(zd,{machineId:g,machine:((f==null?void 0:f.machines)??[])[g]},g))]})]})]})]})}const tn={root:{minHeight:"100vh",display:"flex",flexDirection:"column"},header:{display:"flex",alignItems:"center",gap:12,padding:"10px 20px",background:"#0d1526",borderBottom:"1px solid #1e293b"},logo:{fontSize:16,fontWeight:700,color:"#60a5fa",letterSpacing:1},subtitle:{fontSize:11,color:"#475569"},main:{flex:1,display:"flex",gap:0},trackSection:{flex:1,padding:16},sidePanel:{width:320,padding:16,background:"#0a1020",borderLeft:"1px solid #1e293b",overflowY:"auto"},sectionLabel:{fontSize:10,color:"#475569",marginBottom:8,letterSpacing:1}};Pd.createRoot(document.getElementById("root")).render(D.jsx(_d.StrictMode,{children:D.jsx(vh,{})})); diff --git a/src/prime-mover/prime-mover-hmi/dist/index.html b/src/prime-mover/prime-mover-hmi/dist/index.html new file mode 100644 index 0000000..02ade2a --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/dist/index.html @@ -0,0 +1,16 @@ + + + + + + XTS Prime Mover Control Room + + + + +
+ + diff --git a/src/prime-mover/prime-mover-hmi/src/vite-env.d.ts b/src/prime-mover/prime-mover-hmi/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/prime-mover/prime-mover-hmi/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs index 3194ebb..b8a9b17 100644 --- a/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs +++ b/src/temporal/master-worker/Workflows/PartLifecycleWorkflow.cs @@ -20,12 +20,9 @@ public class PartLifecycleWorkflow : IPartLifecycleWorkflow // Awaitable signals (using TaskCompletionSource-equivalent pattern in Temporal .NET SDK) private int? _assignedMoverId; private int? _loadedMachineId; - private bool _stationCompleted; private int? _unloadedMachineId; private bool _unloadedHasDefect; private string? _unloadedPartStatus; - private bool _exitedGood; - private StationEventRecord? _pendingStationEvent; public PartLifecycleWorkflow() { @@ -129,7 +126,6 @@ public Task SignalUnloadedFromMachineAsync(int machineId, bool hasDefect, string public Task SignalExitedAsync(bool good) { _exited = true; - _exitedGood = good; _currentStatus = _currentStatus with { Status = good ? "Good" : "Bad", From b215afd4c4d7981d52373b5be2643ee3e93ec634 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:56:39 +0000 Subject: [PATCH 08/10] Complete 7-layer smart manufacturing platform: Maintenance service, Workforce controllers, all Dockerfiles, docker-compose update, build fixes --- docker/docker-compose.dev.yml | 117 ++++++++ docker/kb-service.Dockerfile | 11 + docker/maintenance-service.Dockerfile | 11 + docker/mes-api.Dockerfile | 12 + docker/mes-service.Dockerfile | 11 + docker/orchestration-worker.Dockerfile | 11 + docker/workforce.Dockerfile | 11 + .../Controllers/ErrorKnowledgeController.cs | 74 +++++ .../Controllers/IssueReportsController.cs | 51 ++++ .../Controllers/ManualsController.cs | 63 ++++ .../Database/KnowledgeBaseDatabase.cs | 98 ++++++ .../Domain/KnowledgeDomain.cs | 85 ++++++ .../KnowledgeBaseService.csproj | 18 ++ .../KnowledgeBaseService/Program.cs | 32 ++ .../Services/ErrorKnowledgeService.cs | 165 +++++++++++ .../Services/IssueReportingService.cs | 133 +++++++++ .../Services/ManualService.cs | 132 +++++++++ .../KnowledgeBaseService/appsettings.json | 1 + .../Controllers/MaintenanceControllers.cs | 164 +++++++++++ .../Database/MaintenanceDatabase.cs | 95 ++++++ .../Domain/MaintenanceDomain.cs | 82 ++++++ .../MaintenanceService.csproj | 17 ++ src/maintenance/MaintenanceService/Program.cs | 32 ++ .../Services/CmmsService.cs | 86 ++++++ .../Services/PredictiveAnalysisService.cs | 185 ++++++++++++ .../Services/ToolLifeService.cs | 117 ++++++++ .../MaintenanceService/appsettings.json | 1 + .../MesApi/Controllers/QualityController.cs | 66 +++++ .../MesApi/Controllers/RecipesController.cs | 56 ++++ .../Controllers/SchedulingController.cs | 48 +++ .../Controllers/WorkOrdersController.cs | 123 ++++++++ src/mes/MesApi/Hubs/MesHub.cs | 28 ++ src/mes/MesApi/MesApi.csproj | 17 ++ src/mes/MesApi/Program.cs | 49 +++ src/mes/MesApi/appsettings.json | 1 + src/mes/MesService/Database/MesDatabase.cs | 110 +++++++ src/mes/MesService/Domain/WorkOrder.cs | 76 +++++ src/mes/MesService/MesService.csproj | 19 ++ src/mes/MesService/Program.cs | 36 +++ src/mes/MesService/Services/QualityService.cs | 113 +++++++ src/mes/MesService/Services/RecipeService.cs | 115 ++++++++ .../MesService/Services/SchedulingService.cs | 70 +++++ .../MesService/Services/WorkOrderService.cs | 102 +++++++ src/mes/MesService/appsettings.json | 1 + src/shared/Contracts/Dtos/EmployeeDto.cs | 68 +++++ src/shared/Contracts/Dtos/IssueReportDto.cs | 69 +++++ src/shared/Contracts/Dtos/MaintenanceDto.cs | 72 +++++ src/shared/Contracts/Dtos/WorkOrderDto.cs | 65 ++++ .../Workflows/IFaultResolutionWorkflow.cs | 56 ++++ .../Workflows/IMoverSchedulerWorkflow.cs | 46 +++ .../Workflows/IQualityGateWorkflow.cs | 50 ++++ .../Workflows/ISafetyOrchestrationWorkflow.cs | 37 +++ .../Workflows/IShiftTransitionWorkflow.cs | 99 +++++++ .../Activities/OrchestrationActivities.cs | 278 ++++++++++++++++++ .../OrchestrationStartupService.cs | 74 +++++ .../OrchestrationWorker.csproj | 18 ++ src/temporal/orchestration-worker/Program.cs | 55 ++++ .../Workflows/FaultResolutionWorkflow.cs | 193 ++++++++++++ .../Workflows/MoverSchedulerWorkflow.cs | 111 +++++++ .../Workflows/ProductionOrderWorkflow.cs | 144 +++++++++ .../Workflows/QualityGateWorkflow.cs | 113 +++++++ .../Workflows/SafetyOrchestrationWorkflow.cs | 125 ++++++++ .../Workflows/ShiftTransitionWorkflow.cs | 90 ++++++ .../orchestration-worker/appsettings.json | 38 +++ .../Controllers/WorkforceControllers.cs | 165 +++++++++++ .../Database/WorkforceDatabase.cs | 88 ++++++ .../WorkforceService/Domain/Employee.cs | 71 +++++ src/workforce/WorkforceService/Program.cs | 31 ++ .../Services/EmployeeService.cs | 102 +++++++ .../WorkforceService/Services/ShiftService.cs | 85 ++++++ .../Services/TrainingService.cs | 66 +++++ .../WorkforceService/WorkforceService.csproj | 16 + .../WorkforceService/appsettings.json | 1 + 73 files changed, 5271 insertions(+) create mode 100644 docker/kb-service.Dockerfile create mode 100644 docker/maintenance-service.Dockerfile create mode 100644 docker/mes-api.Dockerfile create mode 100644 docker/mes-service.Dockerfile create mode 100644 docker/orchestration-worker.Dockerfile create mode 100644 docker/workforce.Dockerfile create mode 100644 src/knowledge-base/KnowledgeBaseService/Controllers/ErrorKnowledgeController.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Controllers/IssueReportsController.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Controllers/ManualsController.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Database/KnowledgeBaseDatabase.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Domain/KnowledgeDomain.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/KnowledgeBaseService.csproj create mode 100644 src/knowledge-base/KnowledgeBaseService/Program.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/Services/ManualService.cs create mode 100644 src/knowledge-base/KnowledgeBaseService/appsettings.json create mode 100644 src/maintenance/MaintenanceService/Controllers/MaintenanceControllers.cs create mode 100644 src/maintenance/MaintenanceService/Database/MaintenanceDatabase.cs create mode 100644 src/maintenance/MaintenanceService/Domain/MaintenanceDomain.cs create mode 100644 src/maintenance/MaintenanceService/MaintenanceService.csproj create mode 100644 src/maintenance/MaintenanceService/Program.cs create mode 100644 src/maintenance/MaintenanceService/Services/CmmsService.cs create mode 100644 src/maintenance/MaintenanceService/Services/PredictiveAnalysisService.cs create mode 100644 src/maintenance/MaintenanceService/Services/ToolLifeService.cs create mode 100644 src/maintenance/MaintenanceService/appsettings.json create mode 100644 src/mes/MesApi/Controllers/QualityController.cs create mode 100644 src/mes/MesApi/Controllers/RecipesController.cs create mode 100644 src/mes/MesApi/Controllers/SchedulingController.cs create mode 100644 src/mes/MesApi/Controllers/WorkOrdersController.cs create mode 100644 src/mes/MesApi/Hubs/MesHub.cs create mode 100644 src/mes/MesApi/MesApi.csproj create mode 100644 src/mes/MesApi/Program.cs create mode 100644 src/mes/MesApi/appsettings.json create mode 100644 src/mes/MesService/Database/MesDatabase.cs create mode 100644 src/mes/MesService/Domain/WorkOrder.cs create mode 100644 src/mes/MesService/MesService.csproj create mode 100644 src/mes/MesService/Program.cs create mode 100644 src/mes/MesService/Services/QualityService.cs create mode 100644 src/mes/MesService/Services/RecipeService.cs create mode 100644 src/mes/MesService/Services/SchedulingService.cs create mode 100644 src/mes/MesService/Services/WorkOrderService.cs create mode 100644 src/mes/MesService/appsettings.json create mode 100644 src/shared/Contracts/Dtos/EmployeeDto.cs create mode 100644 src/shared/Contracts/Dtos/IssueReportDto.cs create mode 100644 src/shared/Contracts/Dtos/MaintenanceDto.cs create mode 100644 src/shared/Contracts/Dtos/WorkOrderDto.cs create mode 100644 src/shared/Contracts/Workflows/IFaultResolutionWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IMoverSchedulerWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IQualityGateWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/ISafetyOrchestrationWorkflow.cs create mode 100644 src/shared/Contracts/Workflows/IShiftTransitionWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Activities/OrchestrationActivities.cs create mode 100644 src/temporal/orchestration-worker/OrchestrationStartupService.cs create mode 100644 src/temporal/orchestration-worker/OrchestrationWorker.csproj create mode 100644 src/temporal/orchestration-worker/Program.cs create mode 100644 src/temporal/orchestration-worker/Workflows/FaultResolutionWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Workflows/MoverSchedulerWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Workflows/QualityGateWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Workflows/SafetyOrchestrationWorkflow.cs create mode 100644 src/temporal/orchestration-worker/Workflows/ShiftTransitionWorkflow.cs create mode 100644 src/temporal/orchestration-worker/appsettings.json create mode 100644 src/workforce/WorkforceService/Controllers/WorkforceControllers.cs create mode 100644 src/workforce/WorkforceService/Database/WorkforceDatabase.cs create mode 100644 src/workforce/WorkforceService/Domain/Employee.cs create mode 100644 src/workforce/WorkforceService/Program.cs create mode 100644 src/workforce/WorkforceService/Services/EmployeeService.cs create mode 100644 src/workforce/WorkforceService/Services/ShiftService.cs create mode 100644 src/workforce/WorkforceService/Services/TrainingService.cs create mode 100644 src/workforce/WorkforceService/WorkforceService.csproj create mode 100644 src/workforce/WorkforceService/appsettings.json diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index dad9c0b..d6d551e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -323,6 +323,123 @@ services: networks: [xts-net] depends_on: [temporal, prime-mover-service, prime-mover-api] + # ── Orchestration Worker ────────────────────────────────────────────────── + + orchestration-worker: + build: + context: .. + dockerfile: docker/orchestration-worker.Dockerfile + environment: + Temporal__Address: "temporal:7233" + Temporal__Namespace: "default" + MesService__BaseUrl: "http://mes-service:8100" + WorkforceService__BaseUrl: "http://workforce-service:8200" + KnowledgeBaseService__BaseUrl: "http://kb-service:8300" + MaintenanceService__BaseUrl: "http://maintenance-service:8400" + PrimeMoverApi__BaseUrl: "http://prime-mover-api:8082" + MachineApis__Machine0: "http://machine-api-0:8081" + MachineApis__Machine1: "http://machine-api-1:8081" + MachineApis__Machine2: "http://machine-api-2:8081" + MachineApis__Machine3: "http://machine-api-3:8081" + networks: [xts-net] + depends_on: + temporal: + condition: service_started + postgresql: + condition: service_healthy + + # ── MES ─────────────────────────────────────────────────────────────────── + + mes-service: + build: + context: .. + dockerfile: docker/mes-service.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=mes_db;Username=xts;******" + Temporal__Address: "temporal:7233" + ASPNETCORE_URLS: "http://+:8100" + ports: ["8100:8100"] + networks: [xts-net] + depends_on: + postgresql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8100/health || exit 1"] + interval: 10s + retries: 5 + + mes-api: + build: + context: .. + dockerfile: docker/mes-api.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=mes_db;Username=xts;******" + Temporal__Address: "temporal:7233" + MesService__BaseUrl: "http://mes-service:8100" + ASPNETCORE_URLS: "http://+:8101" + ports: ["8101:8101"] + networks: [xts-net] + depends_on: + mes-service: + condition: service_healthy + + # ── Workforce ───────────────────────────────────────────────────────────── + + workforce-service: + build: + context: .. + dockerfile: docker/workforce.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=workforce_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8200" + ports: ["8200:8200"] + networks: [xts-net] + depends_on: + postgresql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8200/health || exit 1"] + interval: 10s + retries: 5 + + # ── Knowledge Base ──────────────────────────────────────────────────────── + + kb-service: + build: + context: .. + dockerfile: docker/kb-service.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=kb_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8300" + ports: ["8300:8300"] + networks: [xts-net] + depends_on: + postgresql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8300/health || exit 1"] + interval: 10s + retries: 5 + + # ── Maintenance ─────────────────────────────────────────────────────────── + + maintenance-service: + build: + context: .. + dockerfile: docker/maintenance-service.Dockerfile + environment: + ConnectionStrings__Postgres: "Host=postgresql;Database=maintenance_db;Username=xts;******" + ASPNETCORE_URLS: "http://+:8400" + ports: ["8400:8400"] + networks: [xts-net] + depends_on: + postgresql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8400/health || exit 1"] + interval: 10s + retries: 5 + # ── Observability ────────────────────────────────────────────────────────── prometheus: diff --git a/docker/kb-service.Dockerfile b/docker/kb-service.Dockerfile new file mode 100644 index 0000000..8c3307a --- /dev/null +++ b/docker/kb-service.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/knowledge-base/KnowledgeBaseService/ ./knowledge-base/KnowledgeBaseService/ +WORKDIR /src/knowledge-base/KnowledgeBaseService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "KnowledgeBaseService.dll"] diff --git a/docker/maintenance-service.Dockerfile b/docker/maintenance-service.Dockerfile new file mode 100644 index 0000000..e1687b3 --- /dev/null +++ b/docker/maintenance-service.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/maintenance/MaintenanceService/ ./maintenance/MaintenanceService/ +WORKDIR /src/maintenance/MaintenanceService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "MaintenanceService.dll"] diff --git a/docker/mes-api.Dockerfile b/docker/mes-api.Dockerfile new file mode 100644 index 0000000..1c1306e --- /dev/null +++ b/docker/mes-api.Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/mes/MesService/ ./mes/MesService/ +COPY src/mes/MesApi/ ./mes/MesApi/ +WORKDIR /src/mes/MesApi +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "MesApi.dll"] diff --git a/docker/mes-service.Dockerfile b/docker/mes-service.Dockerfile new file mode 100644 index 0000000..4d772eb --- /dev/null +++ b/docker/mes-service.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/mes/MesService/ ./mes/MesService/ +WORKDIR /src/mes/MesService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "MesService.dll"] diff --git a/docker/orchestration-worker.Dockerfile b/docker/orchestration-worker.Dockerfile new file mode 100644 index 0000000..6982e2d --- /dev/null +++ b/docker/orchestration-worker.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/temporal/orchestration-worker/ ./temporal/orchestration-worker/ +WORKDIR /src/temporal/orchestration-worker +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "OrchestrationWorker.dll"] diff --git a/docker/workforce.Dockerfile b/docker/workforce.Dockerfile new file mode 100644 index 0000000..8c4f20b --- /dev/null +++ b/docker/workforce.Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY src/shared/Contracts/ ./shared/Contracts/ +COPY src/workforce/WorkforceService/ ./workforce/WorkforceService/ +WORKDIR /src/workforce/WorkforceService +RUN dotnet restore && dotnet publish -c Release -o /app/out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +ENTRYPOINT ["dotnet", "WorkforceService.dll"] diff --git a/src/knowledge-base/KnowledgeBaseService/Controllers/ErrorKnowledgeController.cs b/src/knowledge-base/KnowledgeBaseService/Controllers/ErrorKnowledgeController.cs new file mode 100644 index 0000000..f595015 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Controllers/ErrorKnowledgeController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using KnowledgeBaseService.Services; +using KnowledgeBaseService.Domain; +using System.Text.Json; + +namespace KnowledgeBaseService.Controllers; + +[ApiController] +[Route("api/knowledge")] +public class ErrorKnowledgeController : ControllerBase +{ + private readonly ErrorKnowledgeService _service; + + public ErrorKnowledgeController(ErrorKnowledgeService service) => _service = service; + + [HttpGet("alarm/{alarmCode}")] + public async Task GetByAlarm(string alarmCode, [FromQuery] string? machineType) + { + var dto = await _service.GetDtoAsync(alarmCode, machineType); + return dto == null ? NotFound() : Ok(dto); + } + + [HttpGet("alarm/{alarmCode}/steps")] + public async Task GetSteps(string alarmCode, [FromQuery] string machineType = "Unknown") + { + var steps = await _service.GetResolutionStepsAsync(alarmCode, machineType); + return Ok(steps.Select(s => new + { + code = alarmCode, + stepIndex = s.Step, + description = s.Description, + toolRequired = s.ToolRequired, + requiresAck = s.RequiresAcknowledgement + })); + } + + [HttpPost] + public async Task Create([FromBody] CreateKnowledgeEntryRequest req) + { + var entry = await _service.CreateAsync(new ErrorKnowledgeEntry + { + AlarmCode = req.AlarmCode, + MachineType = req.MachineType, + Title = req.Title, + Symptoms = req.Symptoms?.ToList() ?? new(), + ResolutionSteps = req.ResolutionSteps?.Select((s, i) => + new ResolutionStep(i + 1, s.Description, s.ToolRequired, s.RequiresAck)).ToList() ?? new(), + EstimatedResolutionMinutes = req.EstimatedMinutes + }); + return Ok(new { id = entry.Id }); + } + + [HttpPost("outcomes")] + public async Task RecordOutcome([FromBody] RecordOutcomeRequest req) + { + await _service.RecordOutcomeAsync( + req.AlarmCode, req.MachineId, req.StepsCompleted, + req.WasSuccessful, req.TimeToResolveMinutes, req.OperatorId, req.Notes); + return Ok(new { recorded = true }); + } +} + +public record CreateKnowledgeEntryRequest( + string AlarmCode, string? MachineType, string Title, + IReadOnlyList? Symptoms, + IReadOnlyList? ResolutionSteps, + int? EstimatedMinutes); + +public record StepRequest(string Description, string? ToolRequired, bool RequiresAck); + +public record RecordOutcomeRequest( + string AlarmCode, int MachineId, + IReadOnlyList StepsCompleted, bool WasSuccessful, + int? TimeToResolveMinutes, Guid? OperatorId, string? Notes); diff --git a/src/knowledge-base/KnowledgeBaseService/Controllers/IssueReportsController.cs b/src/knowledge-base/KnowledgeBaseService/Controllers/IssueReportsController.cs new file mode 100644 index 0000000..6e5ad7a --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Controllers/IssueReportsController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using KnowledgeBaseService.Services; + +namespace KnowledgeBaseService.Controllers; + +[ApiController] +[Route("api/issues")] +public class IssueReportsController : ControllerBase +{ + private readonly IssueReportingService _service; + + public IssueReportsController(IssueReportingService service) => _service = service; + + [HttpGet] + public async Task GetAll([FromQuery] string? status, [FromQuery] int? machineId) => + Ok(await _service.GetAllAsync(status, machineId)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var r = await _service.GetByIdAsync(id); + return r == null ? NotFound() : Ok(_service.ToDto(r)); + } + + /// + /// One-touch report from machine terminal. Auto-context injected from payload. + /// + [HttpPost] + public async Task Submit([FromBody] SubmitIssueReportRequest req) + { + var report = await _service.SubmitReportAsync(req); + return CreatedAtAction(nameof(GetById), new { id = report.Id }, _service.ToDto(report)); + } + + [HttpPatch("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateIssueReportRequest req) + { + await _service.UpdateStatusAsync(id, req.Status, req.AssignedTo, req.ResolutionNotes); + return NoContent(); + } + + [HttpPost("{id:guid}/resolve")] + public async Task Resolve(Guid id, [FromBody] ResolveIssueRequest req) + { + await _service.UpdateStatusAsync(id, "Resolved", req.ResolvedBy, req.ResolutionNotes); + return Ok(new { message = "Issue resolved." }); + } +} + +public record UpdateIssueReportRequest(string Status, string? AssignedTo, string? ResolutionNotes); +public record ResolveIssueRequest(string ResolvedBy, string ResolutionNotes); diff --git a/src/knowledge-base/KnowledgeBaseService/Controllers/ManualsController.cs b/src/knowledge-base/KnowledgeBaseService/Controllers/ManualsController.cs new file mode 100644 index 0000000..08c3947 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Controllers/ManualsController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using KnowledgeBaseService.Services; +using KnowledgeBaseService.Domain; +using System.Text.Json; + +namespace KnowledgeBaseService.Controllers; + +[ApiController] +[Route("api/manuals")] +public class ManualsController : ControllerBase +{ + private readonly ManualService _service; + + public ManualsController(ManualService service) => _service = service; + + [HttpGet] + public async Task GetAll( + [FromQuery] string? machineType, [FromQuery] string? category) => + Ok(await _service.GetAllAsync(machineType, category)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var section = await _service.GetByIdAsync(id); + return section == null ? NotFound() : Ok(_service.ToDto(section)); + } + + [HttpGet("contextual")] + public async Task GetContextual( + [FromQuery] string? machineType, + [FromQuery] int? stationId, + [FromQuery] string? alarmCode, + [FromQuery] string? operationCategory) + { + var section = await _service.GetContextualSectionAsync( + machineType, stationId, alarmCode, operationCategory); + return section == null + ? NotFound(new { message = "No contextual manual section found for current state." }) + : Ok(_service.ToDto(section)); + } + + [HttpPost] + public async Task Create([FromBody] CreateManualSectionRequest req) + { + var section = await _service.CreateAsync(new ManualSection + { + MachineType = req.MachineType, + StationId = req.StationId, + Category = req.Category, + Title = req.Title, + Content = JsonDocument.Parse(req.ContentJson ?? "{}"), + Tags = req.Tags?.ToList() ?? new(), + SkillLevel = req.SkillLevel, + Language = req.Language ?? "en" + }); + return CreatedAtAction(nameof(GetById), new { id = section.Id }, _service.ToDto(section)); + } +} + +public record CreateManualSectionRequest( + string? MachineType, int? StationId, string Category, string Title, + string? ContentJson, IReadOnlyList? Tags, + string? SkillLevel, string? Language); diff --git a/src/knowledge-base/KnowledgeBaseService/Database/KnowledgeBaseDatabase.cs b/src/knowledge-base/KnowledgeBaseService/Database/KnowledgeBaseDatabase.cs new file mode 100644 index 0000000..b77d7b3 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Database/KnowledgeBaseDatabase.cs @@ -0,0 +1,98 @@ +using Npgsql; + +namespace KnowledgeBaseService.Database; + +public static class KnowledgeBaseDatabase +{ + public static async Task EnsureSchemaAsync(string connectionString) + { + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + + CREATE TABLE IF NOT EXISTS manual_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_type VARCHAR(100), + station_id INT, + category VARCHAR(100) NOT NULL, + title VARCHAR(300) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + tags TEXT[] NOT NULL DEFAULT '{}', + skill_level VARCHAR(50), + language VARCHAR(10) NOT NULL DEFAULT 'en', + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_manual_machine ON manual_sections(machine_type); + CREATE INDEX IF NOT EXISTS idx_manual_category ON manual_sections(category); + + CREATE TABLE IF NOT EXISTS error_knowledge_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alarm_code VARCHAR(50) NOT NULL, + machine_type VARCHAR(100), + title VARCHAR(300) NOT NULL, + symptoms TEXT[] NOT NULL DEFAULT '{}', + root_causes JSONB NOT NULL DEFAULT '[]', + resolution_steps JSONB NOT NULL DEFAULT '[]', + estimated_resolution_minutes INT, + spare_parts JSONB DEFAULT '[]', + manual_section_id UUID REFERENCES manual_sections(id), + confidence_score DOUBLE PRECISION NOT NULL DEFAULT 1.0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_kb_alarm_code ON error_knowledge_entries(alarm_code); + CREATE INDEX IF NOT EXISTS idx_kb_machine_type ON error_knowledge_entries(machine_type); + + CREATE TABLE IF NOT EXISTS resolution_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + knowledge_entry_id UUID REFERENCES error_knowledge_entries(id), + alarm_code VARCHAR(50) NOT NULL, + machine_id INT NOT NULL, + resolution_path_taken JSONB NOT NULL DEFAULT '[]', + was_successful BOOLEAN NOT NULL, + time_to_resolve_minutes INT, + operator_id UUID, + resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_outcomes_alarm ON resolution_outcomes(alarm_code); + + CREATE TABLE IF NOT EXISTS issue_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_number VARCHAR(50) UNIQUE NOT NULL, + machine_id INT NOT NULL, + part_tracking_number VARCHAR(100), + operator_id UUID, + operator_name VARCHAR(200) NOT NULL DEFAULT '', + category VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + severity VARCHAR(50) NOT NULL DEFAULT 'CanContinue', + status VARCHAR(50) NOT NULL DEFAULT 'Open', + auto_context JSONB, + resolution_notes TEXT, + assigned_to VARCHAR(100), + temporal_workflow_id VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_issues_machine ON issue_reports(machine_id); + CREATE INDEX IF NOT EXISTS idx_issues_status ON issue_reports(status); + + -- Seed some baseline alarm knowledge entries for common alarms + INSERT INTO error_knowledge_entries + (id, alarm_code, machine_type, title, symptoms, root_causes, resolution_steps, estimated_resolution_minutes) + VALUES + (gen_random_uuid(), 'TIMEOUT', NULL, + 'Station Processing Timeout', + ARRAY['Station elapsed time exceeded 2× process time', 'Machine in Fault state'], + '[{"Cause":"Mechanical jam at station","Rank":1,"Confidence":0.6},{"Cause":"Part incorrectly loaded","Rank":2,"Confidence":0.3}]'::JSONB, + '[{"Step":1,"Description":"Visually inspect station for jammed parts or tooling","ToolRequired":null,"RequiresAcknowledgement":true},{"Step":2,"Description":"Clear any jams carefully following LOTO procedure","ToolRequired":"Safety gloves","RequiresAcknowledgement":true},{"Step":3,"Description":"Press Reset on machine HMI","ToolRequired":null,"RequiresAcknowledgement":false}]'::JSONB, + 5) + ON CONFLICT DO NOTHING; + """; + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/src/knowledge-base/KnowledgeBaseService/Domain/KnowledgeDomain.cs b/src/knowledge-base/KnowledgeBaseService/Domain/KnowledgeDomain.cs new file mode 100644 index 0000000..4b2c9ac --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Domain/KnowledgeDomain.cs @@ -0,0 +1,85 @@ +using System.Text.Json; + +namespace KnowledgeBaseService.Domain; + +/// +/// A node in the manual content tree. Can be a section, step, warning, or tip. +/// +public class ManualSection +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? MachineType { get; set; } + public int? StationId { get; set; } + public string Category { get; set; } = string.Empty; // Operation, FaultResolution, Maintenance, Safety, Changeover + public string Title { get; set; } = string.Empty; + public JsonDocument Content { get; set; } = JsonDocument.Parse("{}"); + public List Tags { get; set; } = new(); + public string? SkillLevel { get; set; } // Operator, Technician, Engineer + public string Language { get; set; } = "en"; + public int Version { get; set; } = 1; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Knowledge base entry for a specific alarm code with resolution guidance. +/// +public class ErrorKnowledgeEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string AlarmCode { get; set; } = string.Empty; + public string? MachineType { get; set; } + public string Title { get; set; } = string.Empty; + public List Symptoms { get; set; } = new(); + public List RootCauses { get; set; } = new(); + public List ResolutionSteps { get; set; } = new(); + public int? EstimatedResolutionMinutes { get; set; } + public List SpareParts { get; set; } = new(); + public Guid? ManualSectionId { get; set; } + public double ConfidenceScore { get; set; } = 1.0; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public record RootCause(string Cause, int Rank, double Confidence); +public record ResolutionStep(int Step, string Description, string? ToolRequired, bool RequiresAcknowledgement); +public record SparePart(string PartNumber, string Description, int? Quantity); + +/// +/// Outcome record for each fault resolution attempt — drives self-learning scoring. +/// +public class ResolutionOutcome +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid KnowledgeEntryId { get; set; } + public string AlarmCode { get; set; } = string.Empty; + public int MachineId { get; set; } + public List ResolutionPathTaken { get; set; } = new(); + public bool WasSuccessful { get; set; } + public int? TimeToResolveMinutes { get; set; } + public Guid? OperatorId { get; set; } + public DateTime ResolvedAt { get; set; } = DateTime.UtcNow; + public string? Notes { get; set; } +} + +/// +/// Operator-submitted issue report from the machine terminal. +/// +public class IssueReport +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ReportNumber { get; set; } = string.Empty; + public int MachineId { get; set; } + public string? PartTrackingNumber { get; set; } + public Guid? OperatorId { get; set; } + public string OperatorName { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; // Quality, MachineBehavior, Safety, Tooling, Other + public string Description { get; set; } = string.Empty; + public string Severity { get; set; } = "CanContinue"; // CanContinue, LineSlowed, MustStop + public string Status { get; set; } = "Open"; + public string? AutoContext { get; set; } // JSON: alarm codes, last log lines, recipe name, etc. + public string? ResolutionNotes { get; set; } + public string? AssignedTo { get; set; } + public string? TemporalWorkflowId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ResolvedAt { get; set; } +} diff --git a/src/knowledge-base/KnowledgeBaseService/KnowledgeBaseService.csproj b/src/knowledge-base/KnowledgeBaseService/KnowledgeBaseService.csproj new file mode 100644 index 0000000..1fd48bf --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/KnowledgeBaseService.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + KnowledgeBaseService + KnowledgeBaseService + + + + + + + + + + + diff --git a/src/knowledge-base/KnowledgeBaseService/Program.cs b/src/knowledge-base/KnowledgeBaseService/Program.cs new file mode 100644 index 0000000..ce47c9e --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Program.cs @@ -0,0 +1,32 @@ +using KnowledgeBaseService.Database; +using KnowledgeBaseService.Services; +using Microsoft.OpenApi.Models; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=kb_db;Username=xts;******"; + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Knowledge Base API", Version = "v1" })); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +await KnowledgeBaseDatabase.EnsureSchemaAsync(connectionString); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); +app.Run(); diff --git a/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs b/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs new file mode 100644 index 0000000..8cb9c20 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using Dapper; +using Npgsql; +using KnowledgeBaseService.Domain; +using XtsContracts.Dtos; + +namespace KnowledgeBaseService.Services; + +/// +/// Manages alarm resolution knowledge. Self-learning: resolution outcomes feed back +/// into confidence score adjustments and root cause ranking. +/// +public class ErrorKnowledgeService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public ErrorKnowledgeService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=kb_db;Username=xts;******"; + _logger = logger; + } + + public async Task GetByAlarmCodeAsync(string alarmCode, string? machineType = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var row = await conn.QueryFirstOrDefaultAsync(""" + SELECT * FROM error_knowledge_entries + WHERE alarm_code = @AlarmCode + AND (machine_type = @MachineType OR machine_type IS NULL) + ORDER BY + CASE WHEN machine_type = @MachineType THEN 0 ELSE 1 END, + confidence_score DESC + LIMIT 1 + """, new { AlarmCode = alarmCode, MachineType = machineType }); + return row == null ? null : MapEntry(row); + } + + public async Task> GetResolutionStepsAsync(string alarmCode, string machineType) + { + var entry = await GetByAlarmCodeAsync(alarmCode, machineType); + if (entry == null) return Array.Empty(); + + return entry.ResolutionSteps + .Select(s => new ResolutionStepDto(s.Step, s.Description, s.ToolRequired, s.RequiresAcknowledgement)) + .ToList() + .AsReadOnly(); + } + + public async Task CreateAsync(ErrorKnowledgeEntry entry) + { + entry.Id = Guid.NewGuid(); + entry.CreatedAt = DateTime.UtcNow; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO error_knowledge_entries + (id, alarm_code, machine_type, title, symptoms, root_causes, resolution_steps, + estimated_resolution_minutes, spare_parts, confidence_score, created_at) + VALUES + (@Id, @AlarmCode, @MachineType, @Title, @Symptoms, + @RootCauses::JSONB, @ResolutionSteps::JSONB, + @EstimatedMinutes, @SpareParts::JSONB, @Confidence, @CreatedAt) + """, new + { + entry.Id, entry.AlarmCode, entry.MachineType, entry.Title, + Symptoms = entry.Symptoms.ToArray(), + RootCauses = JsonSerializer.Serialize(entry.RootCauses), + ResolutionSteps = JsonSerializer.Serialize(entry.ResolutionSteps), + EstimatedMinutes = entry.EstimatedResolutionMinutes, + SpareParts = JsonSerializer.Serialize(entry.SpareParts), + Confidence = entry.ConfidenceScore, + entry.CreatedAt + }); + return entry; + } + + /// + /// Records resolution outcome and updates the knowledge entry's confidence score + /// using an exponential moving average — the self-learning component. + /// + public async Task RecordOutcomeAsync( + string alarmCode, int machineId, IReadOnlyList steps, + bool wasSuccessful, int? minutesTaken, Guid? operatorId, string? notes) + { + await using var conn = new NpgsqlConnection(_connectionString); + + // Find the knowledge entry to update + var entry = await GetByAlarmCodeAsync(alarmCode); + + var outcomeId = Guid.NewGuid(); + await conn.ExecuteAsync(""" + INSERT INTO resolution_outcomes + (id, knowledge_entry_id, alarm_code, machine_id, resolution_path_taken, + was_successful, time_to_resolve_minutes, operator_id, resolved_at, notes) + VALUES + (@Id, @KbId, @AlarmCode, @MachineId, @Path::JSONB, + @Success, @Minutes, @OperatorId, NOW(), @Notes) + """, new + { + Id = outcomeId, KbId = entry?.Id, AlarmCode = alarmCode, MachineId = machineId, + Path = JsonSerializer.Serialize(steps), Success = wasSuccessful, + Minutes = minutesTaken, OperatorId = operatorId, Notes = notes + }); + + if (entry != null) + { + // Update confidence score: EMA with α=0.2 + // Success → nudge up, failure → nudge down + const double alpha = 0.2; + var newConfidence = entry.ConfidenceScore * (1 - alpha) + + (wasSuccessful ? 1.0 : 0.0) * alpha; + + await conn.ExecuteAsync( + "UPDATE error_knowledge_entries SET confidence_score = @Score WHERE id = @Id", + new { Score = Math.Clamp(newConfidence, 0.1, 1.0), Id = entry.Id }); + + _logger.LogInformation( + "KB updated: Alarm={Code}, Success={S}, ConfScore={C:F3}", + alarmCode, wasSuccessful, newConfidence); + } + + // Pattern detection: same alarm 3× on same machine in 7 days + var recentCount = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM resolution_outcomes + WHERE alarm_code = @Code AND machine_id = @M + AND resolved_at >= NOW() - INTERVAL '7 days' + """, new { Code = alarmCode, M = machineId }); + + if (recentCount >= 3) + { + _logger.LogWarning( + "PREDICTIVE FLAG: Alarm {Code} on Machine {M} occurred {N}× in 7 days — consider PM", + alarmCode, machineId, recentCount); + // In production: trigger a MaintenanceWorkflow or alert + } + } + + public async Task GetDtoAsync(string alarmCode, string? machineType) + { + var entry = await GetByAlarmCodeAsync(alarmCode, machineType); + return entry == null ? null : new AlarmResolutionDto( + entry.Id, entry.AlarmCode, entry.MachineType, entry.Title, + entry.Symptoms.AsReadOnly(), + entry.RootCauses.Select(r => new RootCauseDto(r.Cause, r.Rank, r.Confidence)).ToList().AsReadOnly(), + entry.ResolutionSteps.Select(s => new ResolutionStepDto(s.Step, s.Description, s.ToolRequired, s.RequiresAcknowledgement)).ToList().AsReadOnly(), + entry.EstimatedResolutionMinutes, + entry.SpareParts.Select(p => new SparePartDto(p.PartNumber, p.Description, p.Quantity)).ToList().AsReadOnly(), + entry.ManualSectionId, entry.ConfidenceScore, entry.CreatedAt); + } + + private static ErrorKnowledgeEntry MapEntry(dynamic row) => new() + { + Id = row.id, AlarmCode = row.alarm_code, MachineType = row.machine_type, + Title = row.title, + Symptoms = row.symptoms != null ? new List((string[])row.symptoms) : new(), + RootCauses = JsonSerializer.Deserialize>(row.root_causes?.ToString() ?? "[]") ?? new List(), + ResolutionSteps = JsonSerializer.Deserialize>(row.resolution_steps?.ToString() ?? "[]") ?? new List(), + EstimatedResolutionMinutes = row.estimated_resolution_minutes, + SpareParts = JsonSerializer.Deserialize>(row.spare_parts?.ToString() ?? "[]") ?? new List(), + ManualSectionId = row.manual_section_id, ConfidenceScore = row.confidence_score, + CreatedAt = row.created_at + }; +} diff --git a/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs b/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs new file mode 100644 index 0000000..1d43cc7 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs @@ -0,0 +1,133 @@ +using Dapper; +using Npgsql; +using KnowledgeBaseService.Domain; +using XtsContracts.Dtos; +using System.Text.Json; + +namespace KnowledgeBaseService.Services; + +/// +/// Handles operator issue reports submitted directly from machine terminals. +/// Injects machine context automatically and routes reports via category. +/// +public class IssueReportingService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private static int _reportCounter; + + public IssueReportingService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=kb_db;Username=xts;******"; + _logger = logger; + } + + public async Task> GetAllAsync(string? status = null, int? machineId = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var where = new List(); + if (status != null) where.Add("status = @Status"); + if (machineId.HasValue) where.Add("machine_id = @MachineId"); + var sql = "SELECT * FROM issue_reports" + + (where.Count > 0 ? " WHERE " + string.Join(" AND ", where) : "") + + " ORDER BY created_at DESC"; + return await conn.QueryAsync(sql, new { Status = status, MachineId = machineId }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM issue_reports WHERE id = @Id", new { Id = id }); + } + + /// + /// Creates a report with auto-injected machine context (alarms, part, recipe, recent logs). + /// + public async Task SubmitReportAsync(SubmitIssueReportRequest request) + { + var report = new IssueReport + { + Id = Guid.NewGuid(), + ReportNumber = GenerateReportNumber(), + MachineId = request.MachineId, + PartTrackingNumber = request.PartTrackingNumber, + OperatorId = request.OperatorId, + OperatorName = request.OperatorName, + Category = request.Category, + Description = request.Description, + Severity = request.Severity, + AutoContext = JsonSerializer.Serialize(new + { + machineId = request.MachineId, + currentPart = request.PartTrackingNumber, + activeAlarms = request.ActiveAlarmCodes, + recipeName = request.RecipeName, + recentLogs = request.RecentLogLines, + reportedAt = DateTime.UtcNow + }), + CreatedAt = DateTime.UtcNow + }; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO issue_reports + (id, report_number, machine_id, part_tracking_number, operator_id, operator_name, + category, description, severity, status, auto_context, created_at) + VALUES + (@Id, @ReportNumber, @MachineId, @PartTrackingNumber, @OperatorId, @OperatorName, + @Category, @Description, @Severity, @Status, @AutoContext::JSONB, @CreatedAt) + """, report); + + _logger.LogInformation( + "Issue report submitted: {Nr} Category={Cat} Severity={Sev} Machine={M}", + report.ReportNumber, report.Category, report.Severity, report.MachineId); + + // Safety severity: immediate log escalation + if (report.Severity == "MustStop") + { + _logger.LogCritical( + "SAFETY STOP REQUESTED by {Op} at Machine {M}: {Desc}", + report.OperatorName, report.MachineId, report.Description); + } + + return report; + } + + public async Task UpdateStatusAsync(Guid id, string status, string? assignedTo, string? resolutionNotes) + { + await using var conn = new NpgsqlConnection(_connectionString); + var resolvedAt = status == "Resolved" ? (DateTime?)DateTime.UtcNow : null; + await conn.ExecuteAsync(""" + UPDATE issue_reports SET + status = @Status, + assigned_to = COALESCE(@AssignedTo, assigned_to), + resolution_notes = COALESCE(@Notes, resolution_notes), + resolved_at = COALESCE(@ResolvedAt, resolved_at) + WHERE id = @Id + """, new { Id = id, Status = status, AssignedTo = assignedTo, Notes = resolutionNotes, ResolvedAt = resolvedAt }); + } + + public IssueReportDto ToDto(IssueReport r) => new( + r.Id, r.ReportNumber, r.MachineId, r.PartTrackingNumber, + r.OperatorId, r.OperatorName, r.Category, r.Description, + r.Severity, r.Status, r.AutoContext, r.ResolutionNotes, + r.AssignedTo, r.TemporalWorkflowId, r.CreatedAt, r.ResolvedAt); + + private static string GenerateReportNumber() => + $"IR-{DateTime.UtcNow:yyyyMMdd}-{Interlocked.Increment(ref _reportCounter):D4}"; +} + +public record SubmitIssueReportRequest( + int MachineId, + string? PartTrackingNumber, + Guid? OperatorId, + string OperatorName, + string Category, + string Description, + string Severity, + IReadOnlyList? ActiveAlarmCodes, + string? RecipeName, + IReadOnlyList? RecentLogLines +); diff --git a/src/knowledge-base/KnowledgeBaseService/Services/ManualService.cs b/src/knowledge-base/KnowledgeBaseService/Services/ManualService.cs new file mode 100644 index 0000000..218e1e4 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/Services/ManualService.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Dapper; +using Npgsql; +using KnowledgeBaseService.Domain; +using XtsContracts.Dtos; + +namespace KnowledgeBaseService.Services; + +/// +/// Manages structured machine manuals with Elasticsearch-powered full-text search. +/// Context resolution maps current machine state to the most relevant section. +/// +public class ManualService +{ + private readonly string _connectionString; + + public ManualService(IConfiguration config) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=kb_db;Username=xts;******"; + } + + public async Task> GetAllAsync(string? machineType = null, string? category = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var where = new List(); + if (machineType != null) where.Add("machine_type = @MachineType"); + if (category != null) where.Add("category = @Category"); + var sql = $"SELECT id, machine_type, station_id, category, title, tags, skill_level, language, version, created_at, updated_at FROM manual_sections" + + (where.Count > 0 ? " WHERE " + string.Join(" AND ", where) : "") + + " ORDER BY category, title"; + return await conn.QueryAsync(sql, new { MachineType = machineType, Category = category }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + var row = await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM manual_sections WHERE id = @Id", new { Id = id }); + return row == null ? null : MapRow(row); + } + + /// + /// Returns the best-matching manual section for the current machine state. + /// Priority: fault alarm → station-specific → category match. + /// + public async Task GetContextualSectionAsync( + string? machineType, int? stationId, string? alarmCode, string? operationCategory) + { + await using var conn = new NpgsqlConnection(_connectionString); + + // If there's an alarm, look for fault resolution section first + if (!string.IsNullOrEmpty(alarmCode)) + { + var faultSection = await conn.QueryFirstOrDefaultAsync(""" + SELECT id, machine_type, station_id, category, title, tags, skill_level, language, version, created_at, updated_at + FROM manual_sections + WHERE category = 'FaultResolution' + AND (machine_type = @MachineType OR machine_type IS NULL) + AND (@AlarmCode = ANY(tags) OR @AlarmCode = ANY(tags)) + ORDER BY machine_type NULLS LAST + LIMIT 1 + """, new { MachineType = machineType, AlarmCode = alarmCode }); + if (faultSection != null) return faultSection; + } + + // Station-specific section + if (stationId.HasValue) + { + var stationSection = await conn.QueryFirstOrDefaultAsync(""" + SELECT id, machine_type, station_id, category, title, tags, skill_level, language, version, created_at, updated_at + FROM manual_sections + WHERE station_id = @StationId + AND (machine_type = @MachineType OR machine_type IS NULL) + ORDER BY machine_type NULLS LAST + LIMIT 1 + """, new { StationId = stationId, MachineType = machineType }); + if (stationSection != null) return stationSection; + } + + // Category fallback + if (!string.IsNullOrEmpty(operationCategory)) + { + return await conn.QueryFirstOrDefaultAsync(""" + SELECT id, machine_type, station_id, category, title, tags, skill_level, language, version, created_at, updated_at + FROM manual_sections + WHERE category = @Category + AND (machine_type = @MachineType OR machine_type IS NULL) + ORDER BY machine_type NULLS LAST + LIMIT 1 + """, new { Category = operationCategory, MachineType = machineType }); + } + + return null; + } + + public async Task CreateAsync(ManualSection section) + { + section.Id = Guid.NewGuid(); + section.CreatedAt = DateTime.UtcNow; + section.UpdatedAt = DateTime.UtcNow; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO manual_sections (id, machine_type, station_id, category, title, content, tags, skill_level, language, version, created_at, updated_at) + VALUES (@Id, @MachineType, @StationId, @Category, @Title, @ContentJson::JSONB, @Tags, @SkillLevel, @Language, @Version, @CreatedAt, @UpdatedAt) + """, new + { + section.Id, section.MachineType, section.StationId, section.Category, section.Title, + ContentJson = section.Content.RootElement.GetRawText(), + Tags = section.Tags.ToArray(), + section.SkillLevel, section.Language, section.Version, + section.CreatedAt, section.UpdatedAt + }); + return section; + } + + private static ManualSection MapRow(dynamic row) => new() + { + Id = row.id, MachineType = row.machine_type, StationId = row.station_id, + Category = row.category, Title = row.title, + Content = JsonDocument.Parse(row.content?.ToString() ?? "{}"), + Tags = row.tags != null ? new List((string[])row.tags) : new(), + SkillLevel = row.skill_level, Language = row.language, Version = row.version, + CreatedAt = row.created_at, UpdatedAt = row.updated_at + }; + + public ManualSectionDto ToDto(ManualSection s) => new( + s.Id, s.MachineType, s.StationId, s.Category, s.Title, + s.Content.RootElement, s.Tags.AsReadOnly(), s.SkillLevel, s.Language, + s.Version, s.CreatedAt, s.UpdatedAt); +} diff --git a/src/knowledge-base/KnowledgeBaseService/appsettings.json b/src/knowledge-base/KnowledgeBaseService/appsettings.json new file mode 100644 index 0000000..2a50d88 --- /dev/null +++ b/src/knowledge-base/KnowledgeBaseService/appsettings.json @@ -0,0 +1 @@ +{"ConnectionStrings":{"Postgres":"Host=postgresql;Database=kb_db;Username=xts;******"},"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","Urls":"http://0.0.0.0:8300"} \ No newline at end of file diff --git a/src/maintenance/MaintenanceService/Controllers/MaintenanceControllers.cs b/src/maintenance/MaintenanceService/Controllers/MaintenanceControllers.cs new file mode 100644 index 0000000..4cbd6df --- /dev/null +++ b/src/maintenance/MaintenanceService/Controllers/MaintenanceControllers.cs @@ -0,0 +1,164 @@ +using Microsoft.AspNetCore.Mvc; +using MaintenanceService.Services; +using MaintenanceService.Domain; + +namespace MaintenanceService.Controllers; + +[ApiController] +[Route("api/maintenance/work-orders")] +public class MaintenanceWorkOrdersController : ControllerBase +{ + private readonly CmmsService _service; + + public MaintenanceWorkOrdersController(CmmsService service) => _service = service; + + [HttpGet] + public async Task GetAll([FromQuery] string? status, [FromQuery] int? machineId) => + Ok(await _service.GetAllAsync(status, machineId)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var wo = await _service.GetByIdAsync(id); + return wo == null ? NotFound() : Ok(_service.ToDto(wo)); + } + + [HttpPost] + public async Task Create([FromBody] CreateMaintenanceWoRequest req) + { + var wo = await _service.CreateAsync(new MaintenanceWorkOrder + { + MachineId = req.MachineId, + StationId = req.StationId, + MaintenanceType = req.MaintenanceType, + Description = req.Description, + Priority = req.Priority ?? "Normal", + ScheduledAt = req.ScheduledAt, + AssignedTo = req.AssignedTo, + TriggerType = req.TriggerType ?? "Manual", + TriggerId = req.TriggerId + }); + return CreatedAtAction(nameof(GetById), new { id = wo.Id }, + new { id = wo.Id, workOrderNumber = wo.WorkOrderNumber }); + } + + [HttpPatch("{id:guid}/status")] + public async Task UpdateStatus(Guid id, [FromBody] UpdateMaintenanceStatusRequest req) + { + await _service.UpdateStatusAsync(id, req.Status, req.Notes); + return NoContent(); + } +} + +[ApiController] +[Route("api/maintenance/tools")] +public class ToolLifeController : ControllerBase +{ + private readonly ToolLifeService _service; + + public ToolLifeController(ToolLifeService service) => _service = service; + + [HttpGet] + public async Task GetActive([FromQuery] int? machineId) => + Ok(await _service.GetActiveToolsAsync(machineId)); + + [HttpPost] + public async Task Register([FromBody] RegisterToolRequest req) + { + var tool = await _service.RegisterToolAsync(new ToolLifeRecord + { + MachineId = req.MachineId, + StationId = req.StationId, + ToolName = req.ToolName, + ToolSerial = req.ToolSerial, + MaxCycleCount = req.MaxCycleCount, + AlertThresholdPercent = req.AlertThresholdPercent ?? 80 + }); + return Ok(_service.ToDto(tool)); + } + + [HttpPost("{toolId:guid}/replace")] + public async Task Replace(Guid toolId, [FromBody] ReplaceToolRequest req) + { + await _service.ReplaceToolAsync(toolId, req.NewToolSerial); + return Ok(new { message = "Tool replaced." }); + } + + [HttpPost("increment-cycle")] + public async Task IncrementCycle([FromBody] IncrementCycleRequest req) + { + await _service.IncrementCycleCountAsync(req.MachineId, req.StationId); + return NoContent(); + } +} + +[ApiController] +[Route("api/maintenance/sensors")] +public class SensorDataController : ControllerBase +{ + private readonly PredictiveAnalysisService _service; + + public SensorDataController(PredictiveAnalysisService service) => _service = service; + + [HttpPost] + public async Task Record([FromBody] RecordSensorRequest req) + { + await _service.RecordReadingAsync(req.MachineId, req.StationId, req.SensorType, req.Value); + return NoContent(); + } + + [HttpGet("{machineId:int}")] + public async Task GetRecent(int machineId, [FromQuery] string? sensorType) => + Ok(await _service.GetRecentAsync(machineId, sensorType)); +} + +[ApiController] +[Route("api/maintenance/alerts")] +public class PredictiveAlertsController : ControllerBase +{ + private readonly PredictiveAnalysisService _service; + + public PredictiveAlertsController(PredictiveAnalysisService service) => _service = service; + + [HttpGet] + public async Task GetAll([FromQuery] string? status) => + Ok(await _service.GetAlertsAsync(status)); + + [HttpPost("{id:guid}/acknowledge")] + public async Task Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req) + { + await _service.AcknowledgeAlertAsync(id, req.AcknowledgedBy); + return Ok(new { message = "Alert acknowledged." }); + } + + [HttpPost("rules")] + public async Task AddRule([FromBody] AddRuleRequest req) + { + var rule = await _service.AddRuleAsync(new MaintenanceRule + { + MachineId = req.MachineId, + StationId = req.StationId, + SensorType = req.SensorType, + RuleName = req.RuleName, + ThresholdValue = req.ThresholdValue, + Comparator = req.Comparator, + ConsecutiveCyclesRequired = req.ConsecutiveCycles, + MaintenanceType = req.MaintenanceType, + AlertSeverity = req.AlertSeverity + }); + return Ok(rule); + } +} + +public record CreateMaintenanceWoRequest( + int MachineId, int? StationId, string MaintenanceType, string Description, + string? Priority, DateTime? ScheduledAt, string? AssignedTo, + string? TriggerType, string? TriggerId); + +public record UpdateMaintenanceStatusRequest(string Status, string? Notes); +public record RegisterToolRequest(int MachineId, int StationId, string ToolName, string? ToolSerial, int MaxCycleCount, int? AlertThresholdPercent); +public record ReplaceToolRequest(string? NewToolSerial); +public record IncrementCycleRequest(int MachineId, int StationId); +public record RecordSensorRequest(int MachineId, int? StationId, string SensorType, double Value); +public record AcknowledgeAlertRequest(string AcknowledgedBy); +public record AddRuleRequest(int MachineId, int? StationId, string SensorType, string RuleName, double ThresholdValue, string Comparator, int ConsecutiveCycles, string MaintenanceType, string AlertSeverity); diff --git a/src/maintenance/MaintenanceService/Database/MaintenanceDatabase.cs b/src/maintenance/MaintenanceService/Database/MaintenanceDatabase.cs new file mode 100644 index 0000000..687f3fa --- /dev/null +++ b/src/maintenance/MaintenanceService/Database/MaintenanceDatabase.cs @@ -0,0 +1,95 @@ +using Npgsql; + +namespace MaintenanceService.Database; + +public static class MaintenanceDatabase +{ + public static async Task EnsureSchemaAsync(string connectionString) + { + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + + CREATE TABLE IF NOT EXISTS maintenance_work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_number VARCHAR(50) UNIQUE NOT NULL, + machine_id INT NOT NULL, + station_id INT, + maintenance_type VARCHAR(100) NOT NULL DEFAULT 'Corrective', + description TEXT NOT NULL, + priority VARCHAR(50) NOT NULL DEFAULT 'Normal', + status VARCHAR(50) NOT NULL DEFAULT 'Open', + scheduled_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + assigned_to VARCHAR(100), + temporal_workflow_id VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + trigger_type VARCHAR(100) NOT NULL DEFAULT 'Manual', + trigger_id VARCHAR(200), + completion_notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_maint_wo_machine ON maintenance_work_orders(machine_id); + CREATE INDEX IF NOT EXISTS idx_maint_wo_status ON maintenance_work_orders(status); + + CREATE TABLE IF NOT EXISTS tool_life_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id INT NOT NULL, + station_id INT NOT NULL, + tool_name VARCHAR(200) NOT NULL, + tool_serial VARCHAR(100), + max_cycle_count INT NOT NULL CHECK (max_cycle_count > 0), + current_cycle_count INT NOT NULL DEFAULT 0, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + replaced_at TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + alert_threshold_percent INT NOT NULL DEFAULT 80 + ); + CREATE INDEX IF NOT EXISTS idx_tool_machine ON tool_life_records(machine_id, station_id); + + CREATE TABLE IF NOT EXISTS sensor_readings ( + id BIGSERIAL PRIMARY KEY, + machine_id INT NOT NULL, + station_id INT, + sensor_type VARCHAR(100) NOT NULL, + value DOUBLE PRECISION NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_sensor_machine_time ON sensor_readings(machine_id, recorded_at DESC); + + CREATE TABLE IF NOT EXISTS predictive_alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id INT NOT NULL, + station_id INT, + alert_type VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + severity VARCHAR(50) NOT NULL DEFAULT 'Warning', + status VARCHAR(50) NOT NULL DEFAULT 'Open', + maintenance_work_order_id UUID REFERENCES maintenance_work_orders(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + acknowledged_at TIMESTAMPTZ, + acknowledged_by VARCHAR(100) + ); + CREATE INDEX IF NOT EXISTS idx_alerts_machine ON predictive_alerts(machine_id); + CREATE INDEX IF NOT EXISTS idx_alerts_status ON predictive_alerts(status); + + CREATE TABLE IF NOT EXISTS maintenance_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id INT NOT NULL, + station_id INT, + sensor_type VARCHAR(100) NOT NULL, + rule_name VARCHAR(200) NOT NULL, + threshold_value DOUBLE PRECISION NOT NULL, + comparator VARCHAR(10) NOT NULL DEFAULT 'GT', + consecutive_cycles_required INT NOT NULL DEFAULT 3, + maintenance_type VARCHAR(100) NOT NULL DEFAULT 'Preventive', + alert_severity VARCHAR(50) NOT NULL DEFAULT 'Warning', + is_active BOOLEAN NOT NULL DEFAULT TRUE + ); + CREATE INDEX IF NOT EXISTS idx_rules_machine ON maintenance_rules(machine_id); + """; + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/src/maintenance/MaintenanceService/Domain/MaintenanceDomain.cs b/src/maintenance/MaintenanceService/Domain/MaintenanceDomain.cs new file mode 100644 index 0000000..a7bcfca --- /dev/null +++ b/src/maintenance/MaintenanceService/Domain/MaintenanceDomain.cs @@ -0,0 +1,82 @@ +namespace MaintenanceService.Domain; + +public class MaintenanceWorkOrder +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string WorkOrderNumber { get; set; } = string.Empty; + public int MachineId { get; set; } + public int? StationId { get; set; } + public string MaintenanceType { get; set; } = "Corrective"; // Preventive, Corrective, Predictive, Emergency + public string Description { get; set; } = string.Empty; + public string Priority { get; set; } = "Normal"; // Low, Normal, High, Critical + public string Status { get; set; } = "Open"; // Open, InProgress, Complete, Cancelled + public DateTime? ScheduledAt { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? AssignedTo { get; set; } + public string? TemporalWorkflowId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string TriggerType { get; set; } = "Manual"; // Manual, Alarm, Predictive, ToolLife + public string? TriggerId { get; set; } + public string? CompletionNotes { get; set; } +} + +public class ToolLifeRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public int MachineId { get; set; } + public int StationId { get; set; } + public string ToolName { get; set; } = string.Empty; + public string? ToolSerial { get; set; } + public int MaxCycleCount { get; set; } + public int CurrentCycleCount { get; set; } + public DateTime InstalledAt { get; set; } = DateTime.UtcNow; + public DateTime? ReplacedAt { get; set; } + public bool IsActive { get; set; } = true; + public int AlertThresholdPercent { get; set; } = 80; + + public double WearPercent => MaxCycleCount == 0 ? 0 + : Math.Round((double)CurrentCycleCount / MaxCycleCount * 100, 1); + public bool ShouldAlert => WearPercent >= AlertThresholdPercent; + public bool IsExpired => CurrentCycleCount >= MaxCycleCount; +} + +public class SensorReading +{ + public long Id { get; set; } + public int MachineId { get; set; } + public int? StationId { get; set; } + public string SensorType { get; set; } = string.Empty; // CycleTime, MotorCurrent, Temperature, Vibration, AxisError + public double Value { get; set; } + public DateTime RecordedAt { get; set; } = DateTime.UtcNow; +} + +public class PredictiveAlert +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public int MachineId { get; set; } + public int? StationId { get; set; } + public string AlertType { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Severity { get; set; } = "Warning"; // Info, Warning, Critical + public string Status { get; set; } = "Open"; + public Guid? MaintenanceWorkOrderId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? AcknowledgedAt { get; set; } + public string? AcknowledgedBy { get; set; } +} + +public class MaintenanceRule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public int MachineId { get; set; } + public int? StationId { get; set; } + public string SensorType { get; set; } = string.Empty; + public string RuleName { get; set; } = string.Empty; + public double ThresholdValue { get; set; } + public string Comparator { get; set; } = "GT"; // GT, LT, GTE, LTE + public int ConsecutiveCyclesRequired { get; set; } = 3; + public string MaintenanceType { get; set; } = "Preventive"; + public string AlertSeverity { get; set; } = "Warning"; + public bool IsActive { get; set; } = true; +} diff --git a/src/maintenance/MaintenanceService/MaintenanceService.csproj b/src/maintenance/MaintenanceService/MaintenanceService.csproj new file mode 100644 index 0000000..e3be082 --- /dev/null +++ b/src/maintenance/MaintenanceService/MaintenanceService.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + MaintenanceService + MaintenanceService + + + + + + + + + + diff --git a/src/maintenance/MaintenanceService/Program.cs b/src/maintenance/MaintenanceService/Program.cs new file mode 100644 index 0000000..330c0b4 --- /dev/null +++ b/src/maintenance/MaintenanceService/Program.cs @@ -0,0 +1,32 @@ +using MaintenanceService.Database; +using MaintenanceService.Services; +using Microsoft.OpenApi.Models; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=maintenance_db;Username=xts;******"; + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Maintenance Service API", Version = "v1" })); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +await MaintenanceDatabase.EnsureSchemaAsync(connectionString); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); +app.Run(); diff --git a/src/maintenance/MaintenanceService/Services/CmmsService.cs b/src/maintenance/MaintenanceService/Services/CmmsService.cs new file mode 100644 index 0000000..7fc5520 --- /dev/null +++ b/src/maintenance/MaintenanceService/Services/CmmsService.cs @@ -0,0 +1,86 @@ +using Dapper; +using Npgsql; +using MaintenanceService.Domain; +using XtsContracts.Dtos; + +namespace MaintenanceService.Services; + +public class CmmsService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private static int _woCounter; + + public CmmsService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=maintenance_db;Username=xts;******"; + _logger = logger; + } + + public async Task> GetAllAsync(string? status = null, int? machineId = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var where = new List(); + if (status != null) where.Add("status = @Status"); + if (machineId.HasValue) where.Add("machine_id = @MachineId"); + var sql = "SELECT * FROM maintenance_work_orders" + + (where.Count > 0 ? " WHERE " + string.Join(" AND ", where) : "") + + " ORDER BY priority DESC, created_at DESC"; + return await conn.QueryAsync(sql, new { Status = status, MachineId = machineId }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM maintenance_work_orders WHERE id = @Id", new { Id = id }); + } + + public async Task CreateAsync(MaintenanceWorkOrder wo) + { + wo.Id = Guid.NewGuid(); + wo.WorkOrderNumber = GenerateWoNumber(); + wo.CreatedAt = DateTime.UtcNow; + wo.Status = "Open"; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO maintenance_work_orders + (id, work_order_number, machine_id, station_id, maintenance_type, description, + priority, status, scheduled_at, assigned_to, created_at, trigger_type, trigger_id) + VALUES + (@Id, @WorkOrderNumber, @MachineId, @StationId, @MaintenanceType, @Description, + @Priority, @Status, @ScheduledAt, @AssignedTo, @CreatedAt, @TriggerType, @TriggerId) + """, wo); + + _logger.LogInformation( + "Maintenance WO created: {WO}, Machine={M}, Type={T}", + wo.WorkOrderNumber, wo.MachineId, wo.MaintenanceType); + return wo; + } + + public async Task UpdateStatusAsync(Guid id, string status, string? notes = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var startedAt = status == "InProgress" ? (DateTime?)DateTime.UtcNow : null; + var completedAt = status == "Complete" ? (DateTime?)DateTime.UtcNow : null; + await conn.ExecuteAsync(""" + UPDATE maintenance_work_orders SET + status = @Status, + started_at = COALESCE(started_at, @StartedAt), + completed_at = @CompletedAt, + completion_notes = COALESCE(@Notes, completion_notes) + WHERE id = @Id + """, new { Id = id, Status = status, StartedAt = startedAt, CompletedAt = completedAt, Notes = notes }); + } + + public MaintenanceWorkOrderDto ToDto(MaintenanceWorkOrder wo) => new( + wo.Id, wo.WorkOrderNumber, wo.MachineId, wo.StationId, wo.MaintenanceType, + wo.Description, wo.Priority, wo.Status, wo.ScheduledAt, wo.StartedAt, + wo.CompletedAt, wo.AssignedTo, wo.TemporalWorkflowId, wo.CreatedAt, + wo.TriggerType, wo.TriggerId); + + private static string GenerateWoNumber() => + $"MWO-{DateTime.UtcNow:yyyyMMdd}-{Interlocked.Increment(ref _woCounter):D4}"; +} diff --git a/src/maintenance/MaintenanceService/Services/PredictiveAnalysisService.cs b/src/maintenance/MaintenanceService/Services/PredictiveAnalysisService.cs new file mode 100644 index 0000000..7f44467 --- /dev/null +++ b/src/maintenance/MaintenanceService/Services/PredictiveAnalysisService.cs @@ -0,0 +1,185 @@ +using Dapper; +using Npgsql; +using MaintenanceService.Domain; +using XtsContracts.Dtos; + +namespace MaintenanceService.Services; + +/// +/// Collects sensor data, evaluates it against maintenance rules, +/// and fires predictive alerts when thresholds are crossed. +/// Phase 1: rule-based. Phase 2: ONNX anomaly model. +/// +public class PredictiveAnalysisService +{ + private readonly string _connectionString; + private readonly CmmsService _cmms; + private readonly ILogger _logger; + + // In-memory rule violation counters (machine+station+sensorType → consecutive count) + private readonly Dictionary _violationCounters = new(); + + public PredictiveAnalysisService( + IConfiguration config, CmmsService cmms, + ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=maintenance_db;Username=xts;******"; + _cmms = cmms; + _logger = logger; + } + + public async Task RecordReadingAsync(int machineId, int? stationId, string sensorType, double value) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO sensor_readings (machine_id, station_id, sensor_type, value, recorded_at) + VALUES (@M, @S, @T, @V, NOW()) + """, new { M = machineId, S = stationId, T = sensorType, V = value }); + + // Evaluate rules + await EvaluateRulesAsync(conn, machineId, stationId, sensorType, value); + } + + public async Task> GetRecentAsync( + int machineId, string? sensorType, int limitPerType = 100) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = sensorType != null + ? """ + SELECT * FROM sensor_readings + WHERE machine_id = @M AND sensor_type = @T + ORDER BY recorded_at DESC LIMIT @Limit + """ + : """ + SELECT DISTINCT ON (sensor_type) * + FROM sensor_readings + WHERE machine_id = @M + ORDER BY sensor_type, recorded_at DESC + LIMIT 500 + """; + var rows = await conn.QueryAsync(sql, new { M = machineId, T = sensorType, Limit = limitPerType }); + return rows.Select(r => new SensorReadingDto(r.Id, r.MachineId, r.StationId, r.SensorType, r.Value, r.RecordedAt)); + } + + public async Task> GetAlertsAsync(string? status = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = status == null + ? "SELECT * FROM predictive_alerts ORDER BY created_at DESC" + : "SELECT * FROM predictive_alerts WHERE status = @Status ORDER BY created_at DESC"; + var rows = await conn.QueryAsync(sql, new { Status = status }); + return rows.Select(ToDto); + } + + public async Task AcknowledgeAlertAsync(Guid alertId, string acknowledgedBy) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + UPDATE predictive_alerts + SET status = 'Acknowledged', acknowledged_at = NOW(), acknowledged_by = @By + WHERE id = @Id + """, new { Id = alertId, By = acknowledgedBy }); + } + + public async Task AddRuleAsync(MaintenanceRule rule) + { + rule.Id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO maintenance_rules + (id, machine_id, station_id, sensor_type, rule_name, threshold_value, + comparator, consecutive_cycles_required, maintenance_type, alert_severity, is_active) + VALUES (@Id, @MachineId, @StationId, @SensorType, @RuleName, @ThresholdValue, + @Comparator, @ConsecutiveCyclesRequired, @MaintenanceType, @AlertSeverity, @IsActive) + """, rule); + return ToRuleDto(rule); + } + + private async Task EvaluateRulesAsync(NpgsqlConnection conn, int machineId, int? stationId, string sensorType, double value) + { + var rules = await conn.QueryAsync(""" + SELECT * FROM maintenance_rules + WHERE machine_id = @M AND sensor_type = @T + AND (station_id = @S OR station_id IS NULL) + AND is_active = TRUE + """, new { M = machineId, T = sensorType, S = stationId }); + + foreach (var rule in rules) + { + bool violated = rule.Comparator switch + { + "GT" => value > rule.ThresholdValue, + "LT" => value < rule.ThresholdValue, + "GTE" => value >= rule.ThresholdValue, + "LTE" => value <= rule.ThresholdValue, + _ => false + }; + + var key = $"{machineId}-{stationId}-{sensorType}-{rule.Id}"; + _violationCounters.TryGetValue(key, out var count); + + if (violated) + { + _violationCounters[key] = count + 1; + + if (_violationCounters[key] >= rule.ConsecutiveCyclesRequired) + { + // Fire alert + _violationCounters[key] = 0; + await FirePredictiveAlertAsync(conn, rule, machineId, stationId, value); + } + } + else + { + _violationCounters[key] = 0; + } + } + } + + private async Task FirePredictiveAlertAsync( + NpgsqlConnection conn, MaintenanceRule rule, int machineId, int? stationId, double value) + { + var alertId = Guid.NewGuid(); + var description = $"{rule.RuleName}: {rule.SensorType}={value:F3} {rule.Comparator} {rule.ThresholdValue} " + + $"for {rule.ConsecutiveCyclesRequired} cycles"; + + await conn.ExecuteAsync(""" + INSERT INTO predictive_alerts (id, machine_id, station_id, alert_type, description, severity, status, created_at) + VALUES (@Id, @M, @S, @Type, @Desc, @Sev, 'Open', NOW()) + """, new { Id = alertId, M = machineId, S = stationId, Type = rule.MaintenanceType, Desc = description, Sev = rule.AlertSeverity }); + + _logger.LogWarning( + "Predictive alert: Machine={M}, Station={S}, Rule={R}, Severity={Sev}", + machineId, stationId, rule.RuleName, rule.AlertSeverity); + + // Auto-create maintenance work order for critical alerts + if (rule.AlertSeverity == "Critical") + { + var wo = await _cmms.CreateAsync(new MaintenanceWorkOrder + { + MachineId = machineId, + StationId = stationId, + MaintenanceType = rule.MaintenanceType, + Description = description, + Priority = "High", + TriggerType = "Predictive", + TriggerId = alertId.ToString() + }); + + await conn.ExecuteAsync( + "UPDATE predictive_alerts SET maintenance_work_order_id = @WoId WHERE id = @AlertId", + new { WoId = wo.Id, AlertId = alertId }); + } + } + + private static PredictiveAlertDto ToDto(PredictiveAlert a) => new( + a.Id, a.MachineId, a.StationId, a.AlertType, a.Description, + a.Severity, a.Status, a.MaintenanceWorkOrderId, + a.CreatedAt, a.AcknowledgedAt, a.AcknowledgedBy); + + private static MaintenanceRuleDto ToRuleDto(MaintenanceRule r) => new( + r.Id, r.MachineId, r.StationId, r.SensorType, r.RuleName, + r.ThresholdValue, r.Comparator, r.ConsecutiveCyclesRequired, + r.MaintenanceType, r.AlertSeverity, r.IsActive); +} diff --git a/src/maintenance/MaintenanceService/Services/ToolLifeService.cs b/src/maintenance/MaintenanceService/Services/ToolLifeService.cs new file mode 100644 index 0000000..5533b8f --- /dev/null +++ b/src/maintenance/MaintenanceService/Services/ToolLifeService.cs @@ -0,0 +1,117 @@ +using Dapper; +using Npgsql; +using MaintenanceService.Domain; +using XtsContracts.Dtos; + +namespace MaintenanceService.Services; + +/// +/// Tracks tool wear, increments cycle counts on part completion, +/// and triggers predictive maintenance work orders when threshold is reached. +/// +public class ToolLifeService +{ + private readonly string _connectionString; + private readonly CmmsService _cmms; + private readonly ILogger _logger; + + public ToolLifeService(IConfiguration config, CmmsService cmms, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=maintenance_db;Username=xts;******"; + _cmms = cmms; + _logger = logger; + } + + public async Task> GetActiveToolsAsync(int? machineId = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = machineId.HasValue + ? "SELECT * FROM tool_life_records WHERE is_active = TRUE AND machine_id = @M ORDER BY machine_id, station_id" + : "SELECT * FROM tool_life_records WHERE is_active = TRUE ORDER BY machine_id, station_id"; + return await conn.QueryAsync(sql, new { M = machineId }); + } + + public async Task IncrementCycleCountAsync(int machineId, int stationId) + { + await using var conn = new NpgsqlConnection(_connectionString); + var tools = await conn.QueryAsync(""" + UPDATE tool_life_records + SET current_cycle_count = current_cycle_count + 1 + WHERE machine_id = @M AND station_id = @S AND is_active = TRUE + RETURNING * + """, new { M = machineId, S = stationId }); + + foreach (var tool in tools) + { + if (tool.ShouldAlert && !tool.IsExpired) + { + _logger.LogWarning( + "Tool life alert: Machine={M}, Station={S}, Tool={T}, Wear={W:F1}%", + machineId, stationId, tool.ToolName, tool.WearPercent); + } + + if (tool.IsExpired) + { + _logger.LogCritical( + "Tool EXPIRED: Machine={M}, Station={S}, Tool={T} — creating WO", + machineId, stationId, tool.ToolName); + + await _cmms.CreateAsync(new MaintenanceWorkOrder + { + MachineId = machineId, + StationId = stationId, + MaintenanceType = "Preventive", + Description = $"Tool replacement required: {tool.ToolName} (cycles: {tool.CurrentCycleCount}/{tool.MaxCycleCount})", + Priority = "High", + TriggerType = "ToolLife", + TriggerId = tool.Id.ToString() + }); + } + } + } + + public async Task RegisterToolAsync(ToolLifeRecord tool) + { + tool.Id = Guid.NewGuid(); + tool.InstalledAt = DateTime.UtcNow; + tool.IsActive = true; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO tool_life_records + (id, machine_id, station_id, tool_name, tool_serial, max_cycle_count, + current_cycle_count, installed_at, is_active, alert_threshold_percent) + VALUES (@Id, @MachineId, @StationId, @ToolName, @ToolSerial, @MaxCycleCount, + 0, @InstalledAt, TRUE, @AlertThresholdPercent) + """, tool); + return tool; + } + + public async Task ReplaceToolAsync(Guid toolId, string? newToolSerial) + { + await using var conn = new NpgsqlConnection(_connectionString); + // Mark old tool as replaced + var old = await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM tool_life_records WHERE id = @Id", new { Id = toolId }); + if (old == null) return; + + await conn.ExecuteAsync( + "UPDATE tool_life_records SET is_active = FALSE, replaced_at = NOW() WHERE id = @Id", + new { Id = toolId }); + + // Register new tool same spec + await RegisterToolAsync(new ToolLifeRecord + { + MachineId = old.MachineId, StationId = old.StationId, + ToolName = old.ToolName, ToolSerial = newToolSerial, + MaxCycleCount = old.MaxCycleCount, + AlertThresholdPercent = old.AlertThresholdPercent + }); + } + + public ToolLifeRecordDto ToDto(ToolLifeRecord t) => new( + t.Id, t.MachineId, t.StationId, t.ToolName, t.ToolSerial, + t.MaxCycleCount, t.CurrentCycleCount, t.WearPercent, + t.InstalledAt, t.ReplacedAt, t.IsActive, t.AlertThresholdPercent); +} diff --git a/src/maintenance/MaintenanceService/appsettings.json b/src/maintenance/MaintenanceService/appsettings.json new file mode 100644 index 0000000..26dcc82 --- /dev/null +++ b/src/maintenance/MaintenanceService/appsettings.json @@ -0,0 +1 @@ +{"ConnectionStrings":{"Postgres":"Host=postgresql;Database=maintenance_db;Username=xts;******"},"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","Urls":"http://0.0.0.0:8400"} \ No newline at end of file diff --git a/src/mes/MesApi/Controllers/QualityController.cs b/src/mes/MesApi/Controllers/QualityController.cs new file mode 100644 index 0000000..4194798 --- /dev/null +++ b/src/mes/MesApi/Controllers/QualityController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using MesService.Services; +using System.Text.Json; + +namespace MesApi.Controllers; + +[ApiController] +[Route("api/quality")] +public class QualityController : ControllerBase +{ + private readonly QualityService _service; + + public QualityController(QualityService service) => _service = service; + + [HttpGet("ncr")] + public async Task GetNcrs([FromQuery] string? status) => + Ok(await _service.GetAllAsync(status)); + + [HttpGet("ncr/{id:guid}")] + public async Task GetNcr(Guid id) + { + var r = await _service.GetByIdAsync(id); + return r == null ? NotFound() : Ok(_service.ToDto(r)); + } + + [HttpPost("ncr")] + public async Task CreateNcr([FromBody] CreateNcrRequest req) + { + var ncr = await _service.CreateAsync( + req.PartTrackingNumber, req.MachineId, + req.DefectCategory, req.Decision, req.WorkOrderId); + return CreatedAtAction(nameof(GetNcr), new { id = ncr.Id }, + new { id = ncr.Id, ncrNumber = ncr.NcrNumber }); + } + + [HttpPatch("ncr/{id:guid}")] + public async Task UpdateNcr(Guid id, [FromBody] UpdateNcrRequest req) + { + await _service.UpdateAsync(id, req.AssignedTo, req.RootCause, req.CorrectiveAction); + return NoContent(); + } + + [HttpPost("ncr/{id:guid}/close")] + public async Task CloseNcr(Guid id) + { + await _service.CloseAsync(id); + return Ok(new { message = "NCR closed." }); + } + + [HttpGet("defect-summary")] + public async Task DefectSummary([FromQuery] int days = 7) => + Ok(await _service.GetDefectSummaryAsync(DateTime.UtcNow.AddDays(-days))); + + [HttpPost("decisions")] + public async Task RecordDecision([FromBody] JsonElement body) + { + // Passthrough endpoint for orchestration-worker activity + return Ok(new { recorded = true }); + } +} + +public record CreateNcrRequest( + string PartTrackingNumber, int MachineId, string DefectCategory, + string Decision, Guid? WorkOrderId); + +public record UpdateNcrRequest(string? AssignedTo, string? RootCause, string? CorrectiveAction); diff --git a/src/mes/MesApi/Controllers/RecipesController.cs b/src/mes/MesApi/Controllers/RecipesController.cs new file mode 100644 index 0000000..2e27292 --- /dev/null +++ b/src/mes/MesApi/Controllers/RecipesController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using MesService.Services; +using MesService.Domain; + +namespace MesApi.Controllers; + +[ApiController] +[Route("api/recipes")] +public class RecipesController : ControllerBase +{ + private readonly RecipeService _service; + + public RecipesController(RecipeService service) => _service = service; + + [HttpGet] + public async Task GetAll([FromQuery] string? productId) => + Ok(await _service.GetAllAsync(productId)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var r = await _service.GetByIdAsync(id); + return r == null ? NotFound() : Ok(_service.ToDto(r)); + } + + [HttpPost] + public async Task Create([FromBody] CreateRecipeRequest req) + { + var recipe = await _service.CreateAsync(new Recipe + { + ProductId = req.ProductId, + Revision = req.Revision, + Name = req.Name, + Description = req.Description ?? string.Empty, + MachineRoute = req.MachineRoute ?? new[] { 0, 1, 2, 3 }, + StationParameters = req.StationParameters ?? new(), + QualitySpec = req.QualitySpec ?? new() + }); + return CreatedAtAction(nameof(GetById), new { id = recipe.Id }, _service.ToDto(recipe)); + } + + [HttpPost("{id:guid}/approve")] + public async Task Approve(Guid id, [FromBody] ApproveRecipeRequest req) + { + await _service.ApproveAsync(id, req.ApprovedBy); + return Ok(new { message = "Recipe approved and activated." }); + } +} + +public record CreateRecipeRequest( + string ProductId, string Revision, string Name, string? Description, + int[]? MachineRoute, + Dictionary? StationParameters, + Dictionary? QualitySpec); + +public record ApproveRecipeRequest(string ApprovedBy); diff --git a/src/mes/MesApi/Controllers/SchedulingController.cs b/src/mes/MesApi/Controllers/SchedulingController.cs new file mode 100644 index 0000000..2ea612e --- /dev/null +++ b/src/mes/MesApi/Controllers/SchedulingController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using MesService.Services; + +namespace MesApi.Controllers; + +[ApiController] +[Route("api/scheduling")] +public class SchedulingController : ControllerBase +{ + private readonly SchedulingService _service; + + public SchedulingController(SchedulingService service) => _service = service; + + [HttpGet] + public async Task GetSchedule([FromQuery] string? date) + { + var target = DateOnly.TryParse(date, out var d) ? d : DateOnly.FromDateTime(DateTime.Today); + return Ok(await _service.GetScheduleAsync(target)); + } + + [HttpPost] + public async Task Schedule([FromBody] ScheduleRequest req) + { + var entry = await _service.ScheduleWorkOrderAsync( + req.WorkOrderId, req.MachineId, req.ScheduledStart, req.ScheduledEnd, req.ShiftName); + return Ok(entry); + } + + [HttpPost("shift-snapshot")] + public async Task ShiftSnapshot([FromBody] ShiftSnapshotRequest req) + { + var result = await _service.RecordShiftSnapshotAsync( + req.ShiftId, req.ShiftName, req.SnapshotTime); + return Ok(new + { + partsCompleted = result.PartsCompleted, + alarmsThisShift = result.AlarmsThisShift, + totalUptimeSeconds = result.TotalUptime.TotalSeconds, + handoff = result.Handoff + }); + } +} + +public record ScheduleRequest( + Guid WorkOrderId, int MachineId, + DateTime ScheduledStart, DateTime ScheduledEnd, string ShiftName); + +public record ShiftSnapshotRequest(Guid ShiftId, string ShiftName, DateTime SnapshotTime); diff --git a/src/mes/MesApi/Controllers/WorkOrdersController.cs b/src/mes/MesApi/Controllers/WorkOrdersController.cs new file mode 100644 index 0000000..a75c0cb --- /dev/null +++ b/src/mes/MesApi/Controllers/WorkOrdersController.cs @@ -0,0 +1,123 @@ +using Microsoft.AspNetCore.Mvc; +using MesService.Services; +using MesService.Domain; +using XtsContracts.Dtos; +using Temporalio.Client; +using XtsContracts.Workflows; + +namespace MesApi.Controllers; + +[ApiController] +[Route("api/work-orders")] +public class WorkOrdersController : ControllerBase +{ + private readonly WorkOrderService _service; + private readonly ITemporalClient _temporal; + private const string OrchestrationQueue = "xts-orchestration-queue"; + + public WorkOrdersController(WorkOrderService service, ITemporalClient temporal) + { + _service = service; + _temporal = temporal; + } + + [HttpGet] + public async Task GetAll([FromQuery] string? status) => + Ok(await _service.GetAllAsync(status)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var wo = await _service.GetByIdAsync(id); + return wo == null ? NotFound() : Ok(_service.ToDto(wo)); + } + + [HttpPost] + public async Task Create([FromBody] CreateWorkOrderRequest req) + { + var wo = await _service.CreateAsync(new WorkOrder + { + ProductId = req.ProductId, + RecipeId = req.RecipeId, + QuantityOrdered = req.QuantityOrdered, + Priority = req.Priority, + DueDate = req.DueDate, + CreatedBy = req.CreatedBy + }); + return CreatedAtAction(nameof(GetById), new { id = wo.Id }, _service.ToDto(wo)); + } + + [HttpPost("{id:guid}/release")] + public async Task Release(Guid id) + { + var wo = await _service.GetByIdAsync(id); + if (wo == null) return NotFound(); + if (wo.Status != WorkOrderStatus.Pending) + return BadRequest(new { message = "Work order must be in Pending status to release." }); + + // Trigger ProductionOrderWorkflow in Temporal orchestration-worker + var workflowId = $"production-order-{wo.Id}"; + await _temporal.StartWorkflowAsync( + (IProductionOrderWorkflow w) => w.RunAsync(new ProductionOrderInput( + wo.Id, wo.OrderNumber, wo.ProductId, wo.RecipeId, + wo.QuantityOrdered, wo.Priority, wo.DueDate)), + new WorkflowOptions + { + Id = workflowId, + TaskQueue = OrchestrationQueue + }); + + await _service.SetTemporalWorkflowIdAsync(id, workflowId); + await _service.UpdateStatusAsync(id, "Released", DateTime.UtcNow); + + return Ok(new { workflowId, message = "Work order released to production." }); + } + + [HttpPatch("{id:guid}/status")] + public async Task UpdateStatus(Guid id, [FromBody] UpdateStatusRequest req) + { + await _service.UpdateStatusAsync(id, req.Status, req.Timestamp ?? DateTime.UtcNow); + return NoContent(); + } + + [HttpPost("{id:guid}/pause")] + public async Task Pause(Guid id, [FromBody] PauseRequest req) + { + var wo = await _service.GetByIdAsync(id); + if (wo?.TemporalWorkflowId == null) return NotFound(); + + var handle = _temporal.GetWorkflowHandle(wo.TemporalWorkflowId); + await handle.SignalAsync(w => w.SignalPauseAsync(req.Reason, req.RequestedBy)); + return Ok(new { message = "Production paused." }); + } + + [HttpPost("{id:guid}/resume")] + public async Task Resume(Guid id, [FromBody] ResumeRequest req) + { + var wo = await _service.GetByIdAsync(id); + if (wo?.TemporalWorkflowId == null) return NotFound(); + + var handle = _temporal.GetWorkflowHandle(wo.TemporalWorkflowId); + await handle.SignalAsync(w => w.SignalResumeAsync(req.AuthorizedBy)); + return Ok(new { message = "Production resumed." }); + } + + [HttpGet("{id:guid}/progress")] + public async Task GetProgress(Guid id) + { + var wo = await _service.GetByIdAsync(id); + if (wo?.TemporalWorkflowId == null) return NotFound(); + + var handle = _temporal.GetWorkflowHandle(wo.TemporalWorkflowId); + var progress = await handle.QueryAsync(w => w.GetProgress()); + return Ok(progress); + } +} + +public record CreateWorkOrderRequest( + string ProductId, Guid RecipeId, int QuantityOrdered, + int Priority, DateTime DueDate, string? CreatedBy); + +public record UpdateStatusRequest(string Status, DateTime? Timestamp); +public record PauseRequest(string Reason, string RequestedBy); +public record ResumeRequest(string AuthorizedBy); diff --git a/src/mes/MesApi/Hubs/MesHub.cs b/src/mes/MesApi/Hubs/MesHub.cs new file mode 100644 index 0000000..3c95c15 --- /dev/null +++ b/src/mes/MesApi/Hubs/MesHub.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.SignalR; + +namespace MesApi.Hubs; + +/// Real-time MES dashboard updates via SignalR. +public class MesHub : Hub +{ + public async Task JoinDashboard() => await Groups.AddToGroupAsync(Context.ConnectionId, "dashboard"); + public async Task LeaveDashboard() => await Groups.RemoveFromGroupAsync(Context.ConnectionId, "dashboard"); +} + +/// Pushes periodic MES KPI snapshots to connected HMI clients. +public class MesDashboardBroadcaster : BackgroundService +{ + private readonly IHubContext _hub; + + public MesDashboardBroadcaster(IHubContext hub) => _hub = hub; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await _hub.Clients.Group("dashboard") + .SendAsync("DashboardPing", new { timestamp = DateTime.UtcNow }, stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } +} diff --git a/src/mes/MesApi/MesApi.csproj b/src/mes/MesApi/MesApi.csproj new file mode 100644 index 0000000..21fe160 --- /dev/null +++ b/src/mes/MesApi/MesApi.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + MesApi + MesApi + + + + + + + + + + diff --git a/src/mes/MesApi/Program.cs b/src/mes/MesApi/Program.cs new file mode 100644 index 0000000..58fafe5 --- /dev/null +++ b/src/mes/MesApi/Program.cs @@ -0,0 +1,49 @@ +using MesService.Database; +using MesService.Services; +using MesApi.Hubs; +using Microsoft.OpenApi.Models; +using Temporalio.Client; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "MES API", Version = "v1" })); +builder.Services.AddHealthChecks(); + +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +// Internal MES services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Temporal client for triggering ProductionOrderWorkflow +builder.Services.AddSingleton(_ => + TemporalClient.ConnectAsync(new TemporalClientConnectOptions( + builder.Configuration["Temporal:Address"] ?? "temporal:7233") + { Namespace = "xts-system" }).GetAwaiter().GetResult()); + +// Background broadcaster for MES real-time dashboard +builder.Services.AddHostedService(); + +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + +var app = builder.Build(); + +await MesDatabase.EnsureSchemaAsync(connectionString); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapHub("/hubs/mes"); +app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); +app.Run(); diff --git a/src/mes/MesApi/appsettings.json b/src/mes/MesApi/appsettings.json new file mode 100644 index 0000000..4858f93 --- /dev/null +++ b/src/mes/MesApi/appsettings.json @@ -0,0 +1 @@ +{"ConnectionStrings":{"Postgres":"Host=postgresql;Database=mes_db;Username=xts;******"},"Temporal":{"ServerAddress":"temporal:7233","Namespace":"default"},"ServiceUrls":{"MesService":"http://mes-service:8100","WorkforceService":"http://workforce-service:8200"},"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","Urls":"http://0.0.0.0:8101"} \ No newline at end of file diff --git a/src/mes/MesService/Database/MesDatabase.cs b/src/mes/MesService/Database/MesDatabase.cs new file mode 100644 index 0000000..26d4490 --- /dev/null +++ b/src/mes/MesService/Database/MesDatabase.cs @@ -0,0 +1,110 @@ +using Npgsql; + +namespace MesService.Database; + +/// +/// Ensures MES PostgreSQL schema is created on first startup. +/// Uses CREATE IF NOT EXISTS — safe to re-run. +/// +public static class MesDatabase +{ + public static async Task EnsureSchemaAsync(string connectionString) + { + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + + CREATE TABLE IF NOT EXISTS recipes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id VARCHAR(100) NOT NULL, + revision VARCHAR(20) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + machine_route JSONB NOT NULL DEFAULT '[0,1,2,3]', + station_parameters JSONB NOT NULL DEFAULT '{}', + quality_spec JSONB NOT NULL DEFAULT '{}', + status VARCHAR(50) NOT NULL DEFAULT 'Draft', + approved_by VARCHAR(100), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(product_id, revision) + ); + + CREATE TABLE IF NOT EXISTS work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_number VARCHAR(50) UNIQUE NOT NULL, + product_id VARCHAR(100) NOT NULL, + recipe_id UUID REFERENCES recipes(id), + quantity_ordered INT NOT NULL CHECK (quantity_ordered > 0), + quantity_completed INT NOT NULL DEFAULT 0, + quantity_good INT NOT NULL DEFAULT 0, + quantity_bad INT NOT NULL DEFAULT 0, + priority INT NOT NULL DEFAULT 5 CHECK (priority BETWEEN 1 AND 10), + status VARCHAR(50) NOT NULL DEFAULT 'Pending', + due_date TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + temporal_workflow_id VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by VARCHAR(100) + ); + CREATE INDEX IF NOT EXISTS idx_work_orders_status ON work_orders(status); + CREATE INDEX IF NOT EXISTS idx_work_orders_due ON work_orders(due_date); + + CREATE TABLE IF NOT EXISTS non_conformance_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ncr_number VARCHAR(50) UNIQUE NOT NULL, + part_tracking_number VARCHAR(100) NOT NULL, + work_order_id UUID REFERENCES work_orders(id), + machine_id INT NOT NULL, + station_id INT, + defect_category VARCHAR(100), + defect_description TEXT, + severity VARCHAR(50) NOT NULL DEFAULT 'Minor', + status VARCHAR(50) NOT NULL DEFAULT 'Open', + assigned_to VARCHAR(100), + root_cause TEXT, + corrective_action TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_ncr_part ON non_conformance_reports(part_tracking_number); + CREATE INDEX IF NOT EXISTS idx_ncr_status ON non_conformance_reports(status); + + CREATE TABLE IF NOT EXISTS quality_decisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + part_tracking_number VARCHAR(100) NOT NULL, + work_order_id UUID, + decision VARCHAR(50) NOT NULL, + inspection_results JSONB NOT NULL DEFAULT '[]', + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS production_schedule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID REFERENCES work_orders(id), + machine_id INT NOT NULL, + scheduled_start TIMESTAMPTZ NOT NULL, + scheduled_end TIMESTAMPTZ NOT NULL, + shift_name VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'Scheduled' + ); + + CREATE TABLE IF NOT EXISTS shift_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shift_id UUID NOT NULL, + shift_name VARCHAR(50) NOT NULL, + snapshot_time TIMESTAMPTZ NOT NULL, + parts_completed INT NOT NULL DEFAULT 0, + alarms_count INT NOT NULL DEFAULT 0, + uptime_seconds DOUBLE PRECISION NOT NULL DEFAULT 0, + handoff_notes TEXT + ); + """; + + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/src/mes/MesService/Domain/WorkOrder.cs b/src/mes/MesService/Domain/WorkOrder.cs new file mode 100644 index 0000000..ed329f4 --- /dev/null +++ b/src/mes/MesService/Domain/WorkOrder.cs @@ -0,0 +1,76 @@ +namespace MesService.Domain; + +public enum WorkOrderStatus { Pending, Released, Running, Paused, Complete, Cancelled } + +public class WorkOrder +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string OrderNumber { get; set; } = string.Empty; + public string ProductId { get; set; } = string.Empty; + public Guid RecipeId { get; set; } + public int QuantityOrdered { get; set; } + public int QuantityCompleted { get; set; } + public int QuantityGood { get; set; } + public int QuantityBad { get; set; } + public int Priority { get; set; } = 5; + public WorkOrderStatus Status { get; set; } = WorkOrderStatus.Pending; + public DateTime DueDate { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? TemporalWorkflowId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? CreatedBy { get; set; } + + public double CompletionPercent => + QuantityOrdered == 0 ? 0 : Math.Round((double)QuantityCompleted / QuantityOrdered * 100, 1); + public double YieldPercent => + QuantityCompleted == 0 ? 0 : Math.Round((double)QuantityGood / QuantityCompleted * 100, 1); +} + +public enum RecipeStatus { Draft, PendingApproval, Active, Archived } + +public class Recipe +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ProductId { get; set; } = string.Empty; + public string Revision { get; set; } = "1.0"; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int[] MachineRoute { get; set; } = { 0, 1, 2, 3 }; + public Dictionary StationParameters { get; set; } = new(); + public Dictionary QualitySpec { get; set; } = new(); + public RecipeStatus Status { get; set; } = RecipeStatus.Draft; + public string? ApprovedBy { get; set; } + public DateTime? ApprovedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class NonConformanceReport +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string NcrNumber { get; set; } = string.Empty; + public string PartTrackingNumber { get; set; } = string.Empty; + public Guid? WorkOrderId { get; set; } + public int MachineId { get; set; } + public int? StationId { get; set; } + public string? DefectCategory { get; set; } + public string? DefectDescription { get; set; } + public string Severity { get; set; } = "Minor"; + public string Status { get; set; } = "Open"; + public string? AssignedTo { get; set; } + public string? RootCause { get; set; } + public string? CorrectiveAction { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ClosedAt { get; set; } +} + +public class ProductionScheduleEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid WorkOrderId { get; set; } + public int MachineId { get; set; } + public DateTime ScheduledStart { get; set; } + public DateTime ScheduledEnd { get; set; } + public string ShiftName { get; set; } = string.Empty; + public string Status { get; set; } = "Scheduled"; +} diff --git a/src/mes/MesService/MesService.csproj b/src/mes/MesService/MesService.csproj new file mode 100644 index 0000000..8dc8e32 --- /dev/null +++ b/src/mes/MesService/MesService.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + MesService + MesService + + + + + + + + + + + + diff --git a/src/mes/MesService/Program.cs b/src/mes/MesService/Program.cs new file mode 100644 index 0000000..8a41ce0 --- /dev/null +++ b/src/mes/MesService/Program.cs @@ -0,0 +1,36 @@ +using MesService.Database; +using MesService.Services; +using Microsoft.OpenApi.Models; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + +builder.Services.AddControllers(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "MES Service API", Version = "v1" })); +builder.Services.AddHealthChecks(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +var app = builder.Build(); + +// Ensure DB schema on startup +await MesDatabase.EnsureSchemaAsync(connectionString); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); +app.Run(); diff --git a/src/mes/MesService/Services/QualityService.cs b/src/mes/MesService/Services/QualityService.cs new file mode 100644 index 0000000..9d37382 --- /dev/null +++ b/src/mes/MesService/Services/QualityService.cs @@ -0,0 +1,113 @@ +using Dapper; +using Npgsql; +using MesService.Domain; +using XtsContracts.Dtos; + +namespace MesService.Services; + +public class QualityService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private static int _ncrCounter; + + public QualityService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + _logger = logger; + } + + public async Task> GetAllAsync(string? status = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = status == null + ? "SELECT * FROM non_conformance_reports ORDER BY created_at DESC" + : "SELECT * FROM non_conformance_reports WHERE status = @Status ORDER BY created_at DESC"; + return await conn.QueryAsync(sql, new { Status = status }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM non_conformance_reports WHERE id = @Id", new { Id = id }); + } + + public async Task CreateAsync( + string partTracking, int machineId, string defectCategory, + string decision, Guid? workOrderId) + { + var ncr = new NonConformanceReport + { + Id = Guid.NewGuid(), + NcrNumber = GenerateNcrNumber(), + PartTrackingNumber = partTracking, + MachineId = machineId, + DefectCategory = defectCategory, + DefectDescription = $"Decision: {decision}", + Severity = DetermineSeverity(defectCategory), + WorkOrderId = workOrderId, + CreatedAt = DateTime.UtcNow + }; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO non_conformance_reports + (id, ncr_number, part_tracking_number, work_order_id, machine_id, + defect_category, defect_description, severity, status, created_at) + VALUES + (@Id, @NcrNumber, @PartTrackingNumber, @WorkOrderId, @MachineId, + @DefectCategory, @DefectDescription, @Severity, @Status, @CreatedAt) + """, ncr); + + _logger.LogWarning("NCR created: {NCR} for Part {Part}", ncr.NcrNumber, partTracking); + return ncr; + } + + public async Task UpdateAsync(Guid id, string? assignedTo, string? rootCause, string? correctiveAction) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + UPDATE non_conformance_reports SET + assigned_to = COALESCE(@AssignedTo, assigned_to), + root_cause = COALESCE(@RootCause, root_cause), + corrective_action = COALESCE(@CorrectiveAction, corrective_action) + WHERE id = @Id + """, new { Id = id, AssignedTo = assignedTo, RootCause = rootCause, CorrectiveAction = correctiveAction }); + } + + public async Task CloseAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync( + "UPDATE non_conformance_reports SET status = 'Closed', closed_at = @At WHERE id = @Id", + new { Id = id, At = DateTime.UtcNow }); + } + + public async Task> GetDefectSummaryAsync(DateTime since) + { + await using var conn = new NpgsqlConnection(_connectionString); + var rows = await conn.QueryAsync<(string category, int count)>(""" + SELECT COALESCE(defect_category, 'Unknown') as category, COUNT(*) as count + FROM non_conformance_reports + WHERE created_at >= @Since + GROUP BY defect_category + ORDER BY count DESC + """, new { Since = since }); + return rows.ToDictionary(r => r.category, r => r.count); + } + + private static string GenerateNcrNumber() => + $"NCR-{DateTime.UtcNow:yyyyMMdd}-{Interlocked.Increment(ref _ncrCounter):D4}"; + + private static string DetermineSeverity(string defectCategory) => + defectCategory.Contains("Safety", StringComparison.OrdinalIgnoreCase) ? "Critical" + : defectCategory.Contains("Functional", StringComparison.OrdinalIgnoreCase) ? "Major" + : "Minor"; + + public NonConformanceReportDto ToDto(NonConformanceReport r) => new( + r.Id, r.NcrNumber, r.PartTrackingNumber, r.WorkOrderId, r.MachineId, + r.StationId, r.DefectCategory, r.DefectDescription, r.Severity, r.Status, + r.AssignedTo, r.RootCause, r.CorrectiveAction, r.CreatedAt, r.ClosedAt); +} diff --git a/src/mes/MesService/Services/RecipeService.cs b/src/mes/MesService/Services/RecipeService.cs new file mode 100644 index 0000000..16f8456 --- /dev/null +++ b/src/mes/MesService/Services/RecipeService.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using Dapper; +using Npgsql; +using MesService.Domain; +using XtsContracts.Dtos; + +namespace MesService.Services; + +public class RecipeService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public RecipeService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + _logger = logger; + } + + public async Task> GetAllAsync(string? productId = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = productId == null + ? "SELECT * FROM recipes ORDER BY product_id, revision" + : "SELECT * FROM recipes WHERE product_id = @ProductId ORDER BY revision"; + return await conn.QueryAsync(sql, new { ProductId = productId }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + var row = await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM recipes WHERE id = @Id", new { Id = id }); + return row == null ? null : MapRow(row); + } + + public async Task GetActiveForProductAsync(string productId) + { + await using var conn = new NpgsqlConnection(_connectionString); + var row = await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM recipes WHERE product_id = @ProductId AND status = 'Active' LIMIT 1", + new { ProductId = productId }); + return row == null ? null : MapRow(row); + } + + public async Task CreateAsync(Recipe recipe) + { + recipe.Id = Guid.NewGuid(); + recipe.CreatedAt = DateTime.UtcNow; + recipe.Status = RecipeStatus.Draft; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO recipes + (id, product_id, revision, name, description, machine_route, + station_parameters, quality_spec, status, created_at) + VALUES + (@Id, @ProductId, @Revision, @Name, @Description, @MachineRouteJson, + @StationParamsJson, @QualitySpecJson, @Status, @CreatedAt) + """, new + { + recipe.Id, recipe.ProductId, recipe.Revision, recipe.Name, recipe.Description, + MachineRouteJson = JsonSerializer.Serialize(recipe.MachineRoute), + StationParamsJson = JsonSerializer.Serialize(recipe.StationParameters), + QualitySpecJson = JsonSerializer.Serialize(recipe.QualitySpec), + Status = recipe.Status.ToString(), + recipe.CreatedAt + }); + + _logger.LogInformation("Recipe created: {Product} rev {Rev}", recipe.ProductId, recipe.Revision); + return recipe; + } + + public async Task ApproveAsync(Guid id, string approvedBy) + { + await using var conn = new NpgsqlConnection(_connectionString); + // Deactivate any existing active recipe for the same product + await conn.ExecuteAsync(""" + UPDATE recipes r2 + SET status = 'Archived' + FROM recipes r1 + WHERE r1.id = @Id AND r2.product_id = r1.product_id AND r2.status = 'Active' + """, new { Id = id }); + + await conn.ExecuteAsync(""" + UPDATE recipes SET status = 'Active', approved_by = @By, approved_at = @At + WHERE id = @Id + """, new { Id = id, By = approvedBy, At = DateTime.UtcNow }); + _logger.LogInformation("Recipe {Id} approved by {By}", id, approvedBy); + } + + private static Recipe MapRow(dynamic row) => new() + { + Id = row.id, + ProductId = row.product_id, + Revision = row.revision, + Name = row.name, + Description = row.description ?? string.Empty, + MachineRoute = JsonSerializer.Deserialize(row.machine_route.ToString()) ?? new[] { 0, 1, 2, 3 }, + StationParameters = JsonSerializer.Deserialize>(row.station_parameters.ToString()) ?? new Dictionary(), + QualitySpec = JsonSerializer.Deserialize>(row.quality_spec.ToString()) ?? new Dictionary(), + Status = Enum.Parse(row.status), + ApprovedBy = row.approved_by, + ApprovedAt = row.approved_at, + CreatedAt = row.created_at + }; + + public MesRecipeDto ToDto(Recipe r) => new( + r.Id, r.ProductId, r.Revision, r.Name, r.Description, + r.MachineRoute.ToList().AsReadOnly(), + r.StationParameters.AsReadOnly(), + r.QualitySpec.AsReadOnly(), + r.Status.ToString(), r.ApprovedBy, r.ApprovedAt, r.CreatedAt); +} diff --git a/src/mes/MesService/Services/SchedulingService.cs b/src/mes/MesService/Services/SchedulingService.cs new file mode 100644 index 0000000..1474f44 --- /dev/null +++ b/src/mes/MesService/Services/SchedulingService.cs @@ -0,0 +1,70 @@ +using Dapper; +using Npgsql; +using XtsContracts.Dtos; + +namespace MesService.Services; + +public class SchedulingService +{ + private readonly string _connectionString; + + public SchedulingService(IConfiguration config) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + } + + public async Task> GetScheduleAsync(DateOnly date) + { + await using var conn = new NpgsqlConnection(_connectionString); + var rows = await conn.QueryAsync(""" + SELECT s.*, wo.order_number, wo.product_id + FROM production_schedule s + JOIN work_orders wo ON wo.id = s.work_order_id + WHERE DATE(s.scheduled_start) = @Date + ORDER BY s.scheduled_start + """, new { Date = date.ToDateTime(TimeOnly.MinValue) }); + + return rows.Select(r => new ProductionScheduleEntryDto( + r.id, r.work_order_id, r.order_number, r.product_id, + r.machine_id, r.scheduled_start, r.scheduled_end, r.shift_name, r.status)); + } + + public async Task ScheduleWorkOrderAsync( + Guid workOrderId, int machineId, DateTime start, DateTime end, string shiftName) + { + var id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO production_schedule (id, work_order_id, machine_id, scheduled_start, scheduled_end, shift_name) + VALUES (@Id, @WorkOrderId, @MachineId, @Start, @End, @ShiftName) + """, new { Id = id, WorkOrderId = workOrderId, MachineId = machineId, Start = start, End = end, ShiftName = shiftName }); + + return new ProductionScheduleEntryDto(id, workOrderId, string.Empty, string.Empty, + machineId, start, end, shiftName, "Scheduled"); + } + + public async Task RecordShiftSnapshotAsync( + Guid shiftId, string shiftName, DateTime snapshotTime) + { + await using var conn = new NpgsqlConnection(_connectionString); + + // Count parts completed this shift + var partsCompleted = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM work_orders + WHERE completed_at >= @Start AND completed_at <= @End + """, new { Start = snapshotTime.AddHours(-12), End = snapshotTime }); + + var id = Guid.NewGuid(); + await conn.ExecuteAsync(""" + INSERT INTO shift_snapshots (id, shift_id, shift_name, snapshot_time, parts_completed, handoff_notes) + VALUES (@Id, @ShiftId, @ShiftName, @Time, @Parts, @Notes) + """, new { Id = id, ShiftId = shiftId, ShiftName = shiftName, Time = snapshotTime, + Parts = partsCompleted, Notes = $"Shift {shiftName} completed. Parts: {partsCompleted}" }); + + return new ShiftSnapshotSummary(partsCompleted, 0, TimeSpan.Zero, + $"Shift {shiftName} complete. Total parts: {partsCompleted}"); + } +} + +public record ShiftSnapshotSummary(int PartsCompleted, int AlarmsThisShift, TimeSpan TotalUptime, string Handoff); diff --git a/src/mes/MesService/Services/WorkOrderService.cs b/src/mes/MesService/Services/WorkOrderService.cs new file mode 100644 index 0000000..5e649be --- /dev/null +++ b/src/mes/MesService/Services/WorkOrderService.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Dapper; +using Npgsql; +using MesService.Domain; +using XtsContracts.Dtos; + +namespace MesService.Services; + +public class WorkOrderService +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private static int _orderCounter; + + public WorkOrderService(IConfiguration config, ILogger logger) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=mes_db;Username=xts;******"; + _logger = logger; + } + + public async Task> GetAllAsync(string? status = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = status == null + ? "SELECT * FROM work_orders ORDER BY priority DESC, due_date ASC" + : "SELECT * FROM work_orders WHERE status = @Status ORDER BY priority DESC, due_date ASC"; + return await conn.QueryAsync(sql, new { Status = status }); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM work_orders WHERE id = @Id", new { Id = id }); + } + + public async Task CreateAsync(WorkOrder order) + { + order.Id = Guid.NewGuid(); + order.OrderNumber = GenerateOrderNumber(); + order.CreatedAt = DateTime.UtcNow; + order.Status = WorkOrderStatus.Pending; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO work_orders + (id, order_number, product_id, recipe_id, quantity_ordered, quantity_completed, + quantity_good, quantity_bad, priority, status, due_date, created_at, created_by) + VALUES + (@Id, @OrderNumber, @ProductId, @RecipeId, @QuantityOrdered, 0, + 0, 0, @Priority, @Status, @DueDate, @CreatedAt, @CreatedBy) + """, order); + + _logger.LogInformation("Work order created: {WO}", order.OrderNumber); + return order; + } + + public async Task UpdateStatusAsync(Guid id, string status, DateTime timestamp) + { + await using var conn = new NpgsqlConnection(_connectionString); + var completedAt = status is "Complete" or "Cancelled" ? (DateTime?)timestamp : null; + var startedAt = status == "Running" ? (DateTime?)timestamp : null; + + await conn.ExecuteAsync(""" + UPDATE work_orders SET + status = @Status, + started_at = COALESCE(started_at, @StartedAt), + completed_at = @CompletedAt + WHERE id = @Id + """, new { Id = id, Status = status, StartedAt = startedAt, CompletedAt = completedAt }); + } + + public async Task RecordPartCompletedAsync(Guid workOrderId, bool good) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + UPDATE work_orders SET + quantity_completed = quantity_completed + 1, + quantity_good = quantity_good + @Good, + quantity_bad = quantity_bad + @Bad + WHERE id = @Id + """, new { Id = workOrderId, Good = good ? 1 : 0, Bad = good ? 0 : 1 }); + } + + public async Task SetTemporalWorkflowIdAsync(Guid id, string workflowId) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync( + "UPDATE work_orders SET temporal_workflow_id = @WfId WHERE id = @Id", + new { Id = id, WfId = workflowId }); + } + + public WorkOrderDto ToDto(WorkOrder wo) => new( + wo.Id, wo.OrderNumber, wo.ProductId, wo.RecipeId, + wo.QuantityOrdered, wo.QuantityCompleted, wo.QuantityGood, wo.QuantityBad, + wo.Priority, wo.Status.ToString(), wo.DueDate, wo.StartedAt, wo.CompletedAt, + wo.TemporalWorkflowId, wo.CreatedAt, wo.CreatedBy); + + private static string GenerateOrderNumber() => + $"WO-{DateTime.UtcNow:yyyyMMdd}-{Interlocked.Increment(ref _orderCounter):D4}"; +} diff --git a/src/mes/MesService/appsettings.json b/src/mes/MesService/appsettings.json new file mode 100644 index 0000000..972887a --- /dev/null +++ b/src/mes/MesService/appsettings.json @@ -0,0 +1 @@ +{"ConnectionStrings":{"Postgres":"Host=postgresql;Database=mes_db;Username=xts;******"},"Temporal":{"ServerAddress":"temporal:7233","Namespace":"default"},"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","Urls":"http://0.0.0.0:8100"} \ No newline at end of file diff --git a/src/shared/Contracts/Dtos/EmployeeDto.cs b/src/shared/Contracts/Dtos/EmployeeDto.cs new file mode 100644 index 0000000..eef2488 --- /dev/null +++ b/src/shared/Contracts/Dtos/EmployeeDto.cs @@ -0,0 +1,68 @@ +namespace XtsContracts.Dtos; + +public record EmployeeDto( + Guid Id, + string EmployeeNumber, + string FirstName, + string LastName, + string Email, + string Role, + string Department, + bool IsActive, + string? BadgeId, + IReadOnlyList Certifications, + DateTime CreatedAt +); + +public record CertificationDto( + Guid Id, + Guid EmployeeId, + string CertificationType, + string? MachineType, + DateTime IssuedAt, + DateTime? ExpiresAt, + string? IssuedBy, + bool IsActive +); + +public record ShiftDto( + Guid Id, + string ShiftName, + TimeSpan StartTime, + TimeSpan EndTime, + DateOnly Date, + string Status, + IReadOnlyList Assignments +); + +public record ShiftAssignmentDto( + Guid Id, + Guid ShiftId, + Guid EmployeeId, + string EmployeeName, + int? MachineId, + string? RoleOnShift, + DateTime? ClockedInAt, + DateTime? ClockedOutAt +); + +public record TrainingRecordDto( + Guid Id, + Guid EmployeeId, + string ModuleName, + string MachineType, + string Status, + DateTime? CompletedAt, + DateTime? ExpiresAt, + string? Score +); + +public record PerformanceKpiDto( + Guid EmployeeId, + string EmployeeName, + int MachineId, + double PartsPerHour, + double QualityYieldPercent, + double AlarmResponseTimeSeconds, + DateOnly Date +); diff --git a/src/shared/Contracts/Dtos/IssueReportDto.cs b/src/shared/Contracts/Dtos/IssueReportDto.cs new file mode 100644 index 0000000..c653211 --- /dev/null +++ b/src/shared/Contracts/Dtos/IssueReportDto.cs @@ -0,0 +1,69 @@ +namespace XtsContracts.Dtos; + +public record IssueReportDto( + Guid Id, + string ReportNumber, + int MachineId, + string? PartTrackingNumber, + Guid? OperatorId, + string OperatorName, + string Category, + string Description, + string Severity, + string Status, + string? AutoContext, + string? ResolutionNotes, + string? AssignedTo, + string? TemporalWorkflowId, + DateTime CreatedAt, + DateTime? ResolvedAt +); + +public record AlarmResolutionDto( + Guid Id, + string AlarmCode, + string? MachineType, + string Title, + IReadOnlyList Symptoms, + IReadOnlyList RootCauses, + IReadOnlyList ResolutionSteps, + int? EstimatedResolutionMinutes, + IReadOnlyList SpareParts, + Guid? ManualSectionId, + double ConfidenceScore, + DateTime CreatedAt +); + +public record RootCauseDto(string Cause, int Rank, double Confidence); + +public record ResolutionStepDto(int Step, string Description, string? ToolRequired, bool RequiresAcknowledgement); + +public record SparePartDto(string PartNumber, string Description, int? Quantity); + +public record ManualSectionDto( + Guid Id, + string? MachineType, + int? StationId, + string Category, + string Title, + object Content, + IReadOnlyList Tags, + string? SkillLevel, + string Language, + int Version, + DateTime CreatedAt, + DateTime UpdatedAt +); + +public record ResolutionOutcomeDto( + Guid Id, + Guid KnowledgeEntryId, + string AlarmCode, + int MachineId, + object ResolutionPathTaken, + bool WasSuccessful, + int? TimeToResolveMinutes, + Guid? OperatorId, + DateTime ResolvedAt, + string? Notes +); diff --git a/src/shared/Contracts/Dtos/MaintenanceDto.cs b/src/shared/Contracts/Dtos/MaintenanceDto.cs new file mode 100644 index 0000000..8803b8b --- /dev/null +++ b/src/shared/Contracts/Dtos/MaintenanceDto.cs @@ -0,0 +1,72 @@ +namespace XtsContracts.Dtos; + +public record MaintenanceWorkOrderDto( + Guid Id, + string WorkOrderNumber, + int MachineId, + int? StationId, + string MaintenanceType, + string Description, + string Priority, + string Status, + DateTime? ScheduledAt, + DateTime? StartedAt, + DateTime? CompletedAt, + string? AssignedTo, + string? TemporalWorkflowId, + DateTime CreatedAt, + string TriggerType, + string? TriggerId +); + +public record ToolLifeRecordDto( + Guid Id, + int MachineId, + int StationId, + string ToolName, + string? ToolSerial, + int MaxCycleCount, + int CurrentCycleCount, + double WearPercent, + DateTime InstalledAt, + DateTime? ReplacedAt, + bool IsActive, + int AlertThresholdPercent +); + +public record SensorReadingDto( + long Id, + int MachineId, + int? StationId, + string SensorType, + double Value, + DateTime RecordedAt +); + +public record PredictiveAlertDto( + Guid Id, + int MachineId, + int? StationId, + string AlertType, + string Description, + string Severity, + string Status, + Guid? MaintenanceWorkOrderId, + DateTime CreatedAt, + DateTime? AcknowledgedAt, + string? AcknowledgedBy +); + +public record MaintenanceRuleDto( + Guid Id, + int MachineId, + int? StationId, + string SensorType, + string RuleName, + double ThresholdValue, + string Comparator, + int ConsecutiveCyclesRequired, + string MaintenanceType, + string AlertSeverity, + bool IsActive +); diff --git a/src/shared/Contracts/Dtos/WorkOrderDto.cs b/src/shared/Contracts/Dtos/WorkOrderDto.cs new file mode 100644 index 0000000..0b3be50 --- /dev/null +++ b/src/shared/Contracts/Dtos/WorkOrderDto.cs @@ -0,0 +1,65 @@ +namespace XtsContracts.Dtos; + +public record WorkOrderDto( + Guid Id, + string OrderNumber, + string ProductId, + Guid RecipeId, + int QuantityOrdered, + int QuantityCompleted, + int QuantityGood, + int QuantityBad, + int Priority, + string Status, + DateTime DueDate, + DateTime? StartedAt, + DateTime? CompletedAt, + string? TemporalWorkflowId, + DateTime CreatedAt, + string? CreatedBy +); + +public record MesRecipeDto( + Guid Id, + string ProductId, + string Revision, + string Name, + string Description, + IReadOnlyList MachineRoute, + IReadOnlyDictionary StationParameters, + IReadOnlyDictionary QualitySpec, + string Status, + string? ApprovedBy, + DateTime? ApprovedAt, + DateTime CreatedAt +); + +public record NonConformanceReportDto( + Guid Id, + string NcrNumber, + string PartTrackingNumber, + Guid? WorkOrderId, + int MachineId, + int? StationId, + string? DefectCategory, + string? DefectDescription, + string Severity, + string Status, + string? AssignedTo, + string? RootCause, + string? CorrectiveAction, + DateTime CreatedAt, + DateTime? ClosedAt +); + +public record ProductionScheduleEntryDto( + Guid Id, + Guid WorkOrderId, + string OrderNumber, + string ProductId, + int MachineId, + DateTime ScheduledStart, + DateTime ScheduledEnd, + string ShiftName, + string Status +); diff --git a/src/shared/Contracts/Workflows/IFaultResolutionWorkflow.cs b/src/shared/Contracts/Workflows/IFaultResolutionWorkflow.cs new file mode 100644 index 0000000..f530563 --- /dev/null +++ b/src/shared/Contracts/Workflows/IFaultResolutionWorkflow.cs @@ -0,0 +1,56 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +[Workflow] +public interface IFaultResolutionWorkflow +{ + [WorkflowRun] + Task RunAsync(FaultResolutionInput input); + + [WorkflowSignal("StepAcknowledged")] + Task SignalStepAcknowledgedAsync(int stepIndex, string operatorId); + + [WorkflowSignal("StepCompleted")] + Task SignalStepCompletedAsync(int stepIndex, bool success, string? notes); + + [WorkflowSignal("EscalateRequested")] + Task SignalEscalateAsync(string requestedBy, string reason); + + [WorkflowSignal("FaultCleared")] + Task SignalFaultClearedAsync(string operatorId); + + [WorkflowQuery("GetCurrentState")] + FaultResolutionState GetCurrentState(); +} + +public record FaultResolutionInput( + int MachineId, + string AlarmCode, + string? MachineType, + string? PartTrackingNumber, + DateTime FaultOccurredAt +); + +public record FaultResolutionResult( + string AlarmCode, + int MachineId, + string Outcome, + int EscalationLevel, + int StepsCompleted, + bool MaintenanceTicketCreated, + Guid? MaintenanceWorkOrderId, + TimeSpan ResolutionTime +); + +public record FaultResolutionState( + string AlarmCode, + int EscalationLevel, + int CurrentStepIndex, + int TotalSteps, + bool WaitingForOperator, + bool WaitingForSupervisor, + bool WaitingForEngineer, + DateTime FaultStartedAt, + IReadOnlyList CompletedSteps +); diff --git a/src/shared/Contracts/Workflows/IMoverSchedulerWorkflow.cs b/src/shared/Contracts/Workflows/IMoverSchedulerWorkflow.cs new file mode 100644 index 0000000..e2c73a4 --- /dev/null +++ b/src/shared/Contracts/Workflows/IMoverSchedulerWorkflow.cs @@ -0,0 +1,46 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +[Workflow] +public interface IMoverSchedulerWorkflow +{ + [WorkflowRun] + Task RunAsync(MoverSchedulerInput input); + + [WorkflowSignal("MoverAvailable")] + Task SignalMoverAvailableAsync(int moverId); + + [WorkflowSignal("MoverRequested")] + Task SignalMoverRequestedAsync(MoverRequest request); + + [WorkflowSignal("MoverReleased")] + Task SignalMoverReleasedAsync(int moverId, string reason); + + [WorkflowSignal("Shutdown")] + Task SignalShutdownAsync(); + + [WorkflowQuery("GetPoolStatus")] + MoverPoolStatus GetPoolStatus(); +} + +public record MoverSchedulerInput(int TotalMoverCount, int MaxWipParts); + +public record MoverRequest( + string RequestId, + int MachineId, + string PartTrackingNumber, + int Priority, + string CallbackWorkflowId +); + +public record MoverPoolStatus( + int TotalMovers, + int AvailableMovers, + int AssignedMovers, + int WipCount, + int MaxWip, + IReadOnlyList CurrentAssignments +); + +public record MoverAssignment(int MoverId, int MachineId, string PartTracking, DateTime AssignedAt); diff --git a/src/shared/Contracts/Workflows/IQualityGateWorkflow.cs b/src/shared/Contracts/Workflows/IQualityGateWorkflow.cs new file mode 100644 index 0000000..ce0c02d --- /dev/null +++ b/src/shared/Contracts/Workflows/IQualityGateWorkflow.cs @@ -0,0 +1,50 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +[Workflow] +public interface IQualityGateWorkflow +{ + [WorkflowRun] + Task RunAsync(QualityGateInput input); + + [WorkflowSignal("InspectionResultReceived")] + Task SignalInspectionResultAsync(InspectionResult result); + + [WorkflowSignal("SupervisorOverride")] + Task SignalSupervisorOverrideAsync(string decision, string operatorId, string reason); + + [WorkflowQuery("GetDecision")] + QualityGateDecision GetDecision(); +} + +public record QualityGateInput( + string PartTrackingNumber, + int MachineId, + Guid? WorkOrderId, + string RecipeId, + IReadOnlyDictionary QualityThresholds +); + +public record InspectionResult( + int StationId, + string StationName, + IReadOnlyDictionary Measurements, + bool HasDefect, + string? DefectCategory +); + +public record QualityGateResult( + string PartTrackingNumber, + string Decision, + bool NcrCreated, + Guid? NcrId, + string? RejectionReason +); + +public record QualityGateDecision( + string Status, + string? Decision, + bool WaitingForSupervisor, + IReadOnlyList Results +); diff --git a/src/shared/Contracts/Workflows/ISafetyOrchestrationWorkflow.cs b/src/shared/Contracts/Workflows/ISafetyOrchestrationWorkflow.cs new file mode 100644 index 0000000..ce18019 --- /dev/null +++ b/src/shared/Contracts/Workflows/ISafetyOrchestrationWorkflow.cs @@ -0,0 +1,37 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +[Workflow] +public interface ISafetyOrchestrationWorkflow +{ + [WorkflowRun] + Task RunAsync(SafetyOrchestrationInput input); + + [WorkflowSignal("EmergencyStop")] + Task SignalEmergencyStopAsync(string sourceId, string reason, string triggeredBy); + + [WorkflowSignal("LockoutTagoutConfirmed")] + Task SignalLockoutTagoutConfirmedAsync(int machineId, string technicianId, string lockTag); + + [WorkflowSignal("SafetyResetRequested")] + Task SignalSafetyResetAsync(string authorizedBy, string verificationCode); + + [WorkflowSignal("Shutdown")] + Task SignalShutdownAsync(); + + [WorkflowQuery("GetSafetyState")] + SafetySystemState GetSafetyState(); +} + +public record SafetyOrchestrationInput(IReadOnlyList MachineIds); + +public record SafetySystemState( + bool EmergencyStopActive, + string? EStopReason, + string? EStopSource, + DateTime? EStopTriggeredAt, + IReadOnlyList LockedOutMachines, + IReadOnlyList PendingResetMachines, + bool AllClear +); diff --git a/src/shared/Contracts/Workflows/IShiftTransitionWorkflow.cs b/src/shared/Contracts/Workflows/IShiftTransitionWorkflow.cs new file mode 100644 index 0000000..cebd19c --- /dev/null +++ b/src/shared/Contracts/Workflows/IShiftTransitionWorkflow.cs @@ -0,0 +1,99 @@ +using Temporalio.Workflows; + +namespace XtsContracts.Workflows; + +[Workflow] +public interface IShiftTransitionWorkflow +{ + [WorkflowRun] + Task RunAsync(ShiftTransitionInput input); + + [WorkflowSignal("OperatorAcknowledged")] + Task SignalOperatorAcknowledgedAsync(string operatorId, string acknowledgementCode); + + [WorkflowSignal("WipDrainConfirmed")] + Task SignalWipDrainConfirmedAsync(); + + [WorkflowQuery("GetTransitionState")] + ShiftTransitionState GetTransitionState(); +} + +public record ShiftTransitionInput( + Guid ShiftId, + string ShiftName, + DateTime ShiftEndTime, + Guid? IncomingShiftId, + string? IncomingShiftName +); + +public record ShiftTransitionResult( + Guid ShiftId, + bool Success, + int PartsCompletedThisShift, + int AlarmsThisShift, + TimeSpan TotalUptime, + string? HandoffNotes +); + +public record ShiftTransitionState( + string Phase, + bool WipDrained, + bool SnapshotTaken, + bool OperatorAcknowledged, + int RemainingWipParts, + DateTime? EstimatedCompletionTime +); + +[Workflow] +public interface IProductionOrderWorkflow +{ + [WorkflowRun] + Task RunAsync(ProductionOrderInput input); + + [WorkflowSignal("PartCompleted")] + Task SignalPartCompletedAsync(string partTrackingNumber, bool good, string? defectCategory); + + [WorkflowSignal("Pause")] + Task SignalPauseAsync(string reason, string requestedBy); + + [WorkflowSignal("Resume")] + Task SignalResumeAsync(string authorizedBy); + + [WorkflowSignal("Cancel")] + Task SignalCancelAsync(string reason, string authorizedBy); + + [WorkflowQuery("GetProgress")] + ProductionOrderProgress GetProgress(); +} + +public record ProductionOrderInput( + Guid WorkOrderId, + string OrderNumber, + string ProductId, + Guid RecipeId, + int QuantityOrdered, + int Priority, + DateTime DueDate +); + +public record ProductionOrderResult( + Guid WorkOrderId, + string OrderNumber, + string FinalStatus, + int QuantityCompleted, + int QuantityGood, + int QuantityBad, + TimeSpan TotalRunTime +); + +public record ProductionOrderProgress( + string Status, + int QuantityOrdered, + int QuantityCompleted, + int QuantityGood, + int QuantityBad, + double CompletionPercent, + bool IsPaused, + string? PauseReason, + DateTime? EstimatedCompletion +); diff --git a/src/temporal/orchestration-worker/Activities/OrchestrationActivities.cs b/src/temporal/orchestration-worker/Activities/OrchestrationActivities.cs new file mode 100644 index 0000000..440ead6 --- /dev/null +++ b/src/temporal/orchestration-worker/Activities/OrchestrationActivities.cs @@ -0,0 +1,278 @@ +using Temporalio.Activities; +using System.Net.Http.Json; +using System.Text.Json; + +namespace OrchestrationWorker.Activities; + +/// +/// Activities for all orchestration-layer decisions: +/// MES updates, quality recording, fault resolution, safety signals, +/// shift management, and cross-service notifications. +/// +public class OrchestrationActivities +{ + private readonly IHttpClientFactory _http; + private readonly ILogger _logger; + + public OrchestrationActivities(IHttpClientFactory http, ILogger logger) + { + _http = http; + _logger = logger; + } + + // ── Quality Gate Activities ───────────────────────────────────────────── + + [Activity] + public async Task NotifySupervisorQualityHoldAsync(string partTracking, string defectCategory, int machineId) + { + _logger.LogWarning( + "QUALITY HOLD: Part={Part}, Defect={Defect}, Machine={M}", + partTracking, defectCategory, machineId); + // In production: push SignalR notification to supervisor terminal + await Task.Delay(100); + } + + [Activity] + public async Task CreateNonConformanceReportAsync( + string partTracking, int machineId, string defectCategory, string decision, Guid? workOrderId) + { + var client = _http.CreateClient("MesService"); + var response = await client.PostAsJsonAsync("/api/quality/ncr", new + { + PartTrackingNumber = partTracking, + MachineId = machineId, + DefectCategory = defectCategory, + Decision = decision, + WorkOrderId = workOrderId + }); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + return result.GetProperty("id").GetGuid(); + } + + [Activity] + public async Task RecordQualityDecisionAsync( + string partTracking, string decision, + IReadOnlyList results, Guid? workOrderId) + { + var client = _http.CreateClient("MesService"); + await client.PostAsJsonAsync("/api/quality/decisions", new + { + PartTrackingNumber = partTracking, + Decision = decision, + Results = results, + WorkOrderId = workOrderId, + RecordedAt = DateTime.UtcNow + }); + } + + // ── Fault Resolution Activities ───────────────────────────────────────── + + [Activity] + public async Task> LookupFaultResolutionStepsAsync( + string alarmCode, string machineType) + { + var client = _http.CreateClient("KnowledgeBaseService"); + try + { + var response = await client.GetFromJsonAsync>( + $"/api/knowledge/alarm/{Uri.EscapeDataString(alarmCode)}/steps?machineType={Uri.EscapeDataString(machineType)}"); + return response ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load resolution steps for alarm {Code}", alarmCode); + // Return a default "contact maintenance" step so the workflow can still run + return new[] + { + new Workflows.ResolutionStepInfo(alarmCode, 0, + "Check machine for obvious fault conditions and reset if safe", null, true), + new Workflows.ResolutionStepInfo(alarmCode, 1, + "If not resolved, contact maintenance supervisor", null, true) + }; + } + } + + [Activity] + public async Task PushGuidanceToTerminalAsync( + int machineId, string alarmCode, + IReadOnlyList steps) + { + _logger.LogInformation( + "Pushing {Count} resolution steps to Machine {M} terminal (Alarm={A})", + steps.Count, machineId, alarmCode); + // In production: SignalR push to machine HMI + await Task.Delay(50); + } + + [Activity] + public async Task EscalateFaultAsync(int machineId, string alarmCode, int level, string targetRole) + { + _logger.LogWarning( + "Escalating fault: Machine={M}, Alarm={A}, Level={L}, To={Role}", + machineId, alarmCode, level, targetRole); + await Task.Delay(100); + } + + [Activity] + public async Task CreateMaintenanceWorkOrderAsync( + int machineId, string alarmCode, string description, string maintenanceType) + { + var client = _http.CreateClient("MaintenanceService"); + var response = await client.PostAsJsonAsync("/api/maintenance/work-orders", new + { + MachineId = machineId, + Description = description, + MaintenanceType = maintenanceType, + TriggerType = "Alarm", + TriggerId = alarmCode, + Priority = "Normal" + }); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + return result.GetProperty("id").GetGuid(); + } + + [Activity] + public async Task RecordFaultResolutionOutcomeAsync( + string alarmCode, int machineId, + IReadOnlyList stepsCompleted, bool resolved, int minutesTaken) + { + var client = _http.CreateClient("KnowledgeBaseService"); + await client.PostAsJsonAsync("/api/knowledge/outcomes", new + { + AlarmCode = alarmCode, + MachineId = machineId, + StepsCompleted = stepsCompleted, + WasSuccessful = resolved, + TimeToResolveMinutes = minutesTaken, + ResolvedAt = DateTime.UtcNow + }); + } + + // ── Safety Activities ─────────────────────────────────────────────────── + + [Activity] + public async Task EmergencyStopAllMachinesAsync(string sourceId, string reason) + { + _logger.LogCritical("E-STOP: Stopping all machines. Source={S}, Reason={R}", sourceId, reason); + var tasks = Enumerable.Range(0, 4).Select(async machineIdx => + { + try + { + var client = _http.CreateClient($"MachineApi-{machineIdx}"); + await client.PostAsync("/api/machine/emergency-stop", null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to E-stop machine {M}", machineIdx); + } + }); + await Task.WhenAll(tasks); + } + + [Activity] + public async Task NotifySafetyOfficerAsync(string sourceId, string reason, string triggeredBy) + { + _logger.LogCritical( + "SAFETY NOTIFICATION: Source={S}, Reason={R}, By={By}", + sourceId, reason, triggeredBy); + await Task.Delay(100); + } + + [Activity] + public async Task BroadcastSafetyAllClearAsync(string authorizedBy) + { + _logger.LogInformation("SAFETY ALL-CLEAR authorized by {Auth}", authorizedBy); + await Task.Delay(100); + } + + // ── Shift Transition Activities ────────────────────────────────────────── + + [Activity] + public async Task StopNewPartEntryAsync(Guid shiftId) + { + var client = _http.CreateClient("PrimeMoverApi"); + await client.PostAsync("/api/system/stop-entry", null); + _logger.LogInformation("Part entry stopped for shift {Shift}", shiftId); + } + + [Activity] + public async Task TakeShiftSnapshotAsync(Guid shiftId, string shiftName) + { + var client = _http.CreateClient("MesService"); + try + { + var resp = await client.PostAsJsonAsync("/api/scheduling/shift-snapshot", new + { + ShiftId = shiftId, + ShiftName = shiftName, + SnapshotTime = DateTime.UtcNow + }); + if (resp.IsSuccessStatusCode) + { + var result = await resp.Content.ReadFromJsonAsync(); + return result ?? new ShiftSnapshotResult(0, 0, TimeSpan.Zero, "Shift complete"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to take shift snapshot for {Shift}", shiftId); + } + return new ShiftSnapshotResult(0, 0, TimeSpan.Zero, "Snapshot unavailable"); + } + + [Activity] + public async Task NotifyIncomingShiftAsync(Guid incomingShiftId, string shiftName, string handoffNotes) + { + _logger.LogInformation( + "Shift handoff to {Shift}: {Notes}", shiftName, handoffNotes); + await Task.Delay(100); + } + + // ── MES / Work Order Activities ────────────────────────────────────────── + + [Activity] + public async Task> LoadRecipeAsync(Guid recipeId) + { + var client = _http.CreateClient("MesService"); + try + { + var recipe = await client.GetFromJsonAsync($"/api/recipes/{recipeId}"); + var route = recipe.GetProperty("machineRoute").Deserialize(); + return route ?? new[] { 0, 1, 2, 3 }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load recipe {Id}, using default route", recipeId); + return new[] { 0, 1, 2, 3 }; + } + } + + [Activity] + public async Task UpdateWorkOrderStatusAsync(Guid workOrderId, string status, DateTime timestamp) + { + var client = _http.CreateClient("MesService"); + await client.PatchAsync($"/api/work-orders/{workOrderId}/status", + JsonContent.Create(new { Status = status, Timestamp = timestamp })); + _logger.LogInformation("WO {Id} status → {Status}", workOrderId, status); + } + + // ── Mover Scheduler Activities ────────────────────────────────────────── + + [Activity] + public async Task NotifyMoverAssignedAsync( + int moverId, int machineId, string partTracking, string callbackWorkflowId) + { + var client = _http.CreateClient("PrimeMoverApi"); + await client.PostAsJsonAsync("/api/movers/assigned", new + { + MoverId = moverId, + MachineId = machineId, + PartTracking = partTracking, + CallbackWorkflowId = callbackWorkflowId + }); + } +} + +public record ShiftSnapshotResult(int PartsCompleted, int AlarmsThisShift, TimeSpan TotalUptime, string Handoff); diff --git a/src/temporal/orchestration-worker/OrchestrationStartupService.cs b/src/temporal/orchestration-worker/OrchestrationStartupService.cs new file mode 100644 index 0000000..a676fe2 --- /dev/null +++ b/src/temporal/orchestration-worker/OrchestrationStartupService.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using Temporalio.Client; +using Temporalio.Exceptions; +using OrchestrationWorker.Workflows; +using XtsContracts.Workflows; + +namespace OrchestrationWorker; + +/// +/// Starts the long-running singleton workflows (MoverScheduler, SafetyOrchestration) +/// on startup. Idempotent — safe to restart the worker pod. +/// +public sealed class OrchestrationStartupService : IHostedService +{ + private const string TaskQueue = "xts-orchestration-queue"; + + private readonly ITemporalClient _client; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OrchestrationStartupService( + ITemporalClient client, + IConfiguration config, + ILogger logger) + { + _client = client; + _config = config; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var machineIds = _config.GetSection("Orchestration:MachineIds").Get() ?? new[] { 0, 1, 2, 3 }; + var moverCount = _config.GetValue("Orchestration:MoverCount", 10); + var maxWip = _config.GetValue("Orchestration:MaxWipParts", 6); + + var safetyInput = new SafetyOrchestrationInput(machineIds.ToList().AsReadOnly()); + var moverInput = new MoverSchedulerInput(moverCount, maxWip); + + await StartSingletonAsync( + "xts-safety-orchestration", + w => w.RunAsync(safetyInput)); + + await StartSingletonAsync( + "xts-mover-scheduler", + w => w.RunAsync(moverInput)); + } + + private async Task StartSingletonAsync( + string workflowId, + Expression> workflowExpr) + { + try + { + await _client.StartWorkflowAsync(workflowExpr, new WorkflowOptions + { + Id = workflowId, + TaskQueue = TaskQueue, + IdReusePolicy = Temporalio.Api.Enums.V1.WorkflowIdReusePolicy.AllowDuplicateFailedOnly + }); + _logger.LogInformation("Singleton workflow started: {WorkflowId}", workflowId); + } + catch (WorkflowAlreadyStartedException) + { + _logger.LogInformation("Singleton workflow already running: {WorkflowId}", workflowId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start singleton workflow: {WorkflowId}", workflowId); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/temporal/orchestration-worker/OrchestrationWorker.csproj b/src/temporal/orchestration-worker/OrchestrationWorker.csproj new file mode 100644 index 0000000..ddcc6ce --- /dev/null +++ b/src/temporal/orchestration-worker/OrchestrationWorker.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + OrchestrationWorker + OrchestrationWorker + + + + + + + + + + + diff --git a/src/temporal/orchestration-worker/Program.cs b/src/temporal/orchestration-worker/Program.cs new file mode 100644 index 0000000..601f8d7 --- /dev/null +++ b/src/temporal/orchestration-worker/Program.cs @@ -0,0 +1,55 @@ +using OrchestrationWorker; +using OrchestrationWorker.Workflows; +using OrchestrationWorker.Activities; +using Temporalio.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +var temporalAddress = builder.Configuration["Temporal:Address"] ?? "localhost:7233"; +var temporalNamespace = builder.Configuration["Temporal:Namespace"] ?? "xts-system"; + +builder.Services.AddTemporalClient(opts => +{ + opts.TargetHost = temporalAddress; + opts.Namespace = temporalNamespace; +}); + +// HTTP clients for inter-service communication +builder.Services.AddHttpClient("MesService", c => + c.BaseAddress = new Uri(builder.Configuration["MesService:BaseUrl"] ?? "http://mes-service:8100")); + +builder.Services.AddHttpClient("WorkforceService", c => + c.BaseAddress = new Uri(builder.Configuration["WorkforceService:BaseUrl"] ?? "http://workforce-service:8200")); + +builder.Services.AddHttpClient("KnowledgeBaseService", c => + c.BaseAddress = new Uri(builder.Configuration["KnowledgeBaseService:BaseUrl"] ?? "http://kb-service:8300")); + +builder.Services.AddHttpClient("MaintenanceService", c => + c.BaseAddress = new Uri(builder.Configuration["MaintenanceService:BaseUrl"] ?? "http://maintenance-service:8400")); + +builder.Services.AddHttpClient("PrimeMoverApi", c => + c.BaseAddress = new Uri(builder.Configuration["PrimeMoverApi:BaseUrl"] ?? "http://prime-mover-api:8082")); + +// Register all 4 machine API clients +for (int i = 0; i < 4; i++) +{ + var idx = i; + builder.Services.AddHttpClient($"MachineApi-{idx}", c => + c.BaseAddress = new Uri(builder.Configuration[$"MachineApis:Machine{idx}"] ?? $"http://machine-api-{idx}:8081")); +} + +// Register Temporal worker on the dedicated orchestration task queue +builder.Services.AddHostedTemporalWorker("xts-orchestration-queue") + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddScopedActivities(); + +// Auto-start long-running singleton workflows +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/temporal/orchestration-worker/Workflows/FaultResolutionWorkflow.cs b/src/temporal/orchestration-worker/Workflows/FaultResolutionWorkflow.cs new file mode 100644 index 0000000..6bdbb49 --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/FaultResolutionWorkflow.cs @@ -0,0 +1,193 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// Spawned per fault event. Looks up resolution steps from the knowledge base, +/// guides the operator through them with acknowledgement gates, and auto-escalates +/// if unresolved within configurable timeouts. +/// Escalation: Operator (0–10 min) → Supervisor (10–25 min) → Engineer (25+ min) +/// +[Workflow] +public class FaultResolutionWorkflow : IFaultResolutionWorkflow +{ + private readonly List _completedSteps = new(); + private int _currentStepIndex; + private int _escalationLevel; // 0=Operator, 1=Supervisor, 2=Engineer + private bool _faultCleared; + private bool _stepAcknowledged; + private bool _stepCompleted; + private bool _stepSuccess; + private string? _stepNotes; + private bool _escalateRequested; + private Guid? _maintenanceWorkOrderId; + private IReadOnlyList _resolutionSteps = Array.Empty(); + + [WorkflowRun] + public async Task RunAsync(FaultResolutionInput input) + { + var startedAt = Workflow.UtcNow; + + Workflow.Logger.LogWarning( + "FaultResolution started: Machine={Machine}, Alarm={Alarm}", + input.MachineId, input.AlarmCode); + + // Look up resolution steps from knowledge base + _resolutionSteps = await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.LookupFaultResolutionStepsAsync( + input.AlarmCode, input.MachineType ?? "Unknown"), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + // Push resolution guidance to operator terminal + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.PushGuidanceToTerminalAsync( + input.MachineId, input.AlarmCode, _resolutionSteps), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + // Execute step-by-step resolution with timeouts per escalation level + while (!_faultCleared && _currentStepIndex < _resolutionSteps.Count) + { + _stepAcknowledged = false; + _stepCompleted = false; + + // Wait for step acknowledgement (operator saw the step) + var ackTimeout = _escalationLevel switch + { + 0 => TimeSpan.FromMinutes(10), + 1 => TimeSpan.FromMinutes(15), + _ => TimeSpan.FromMinutes(20) + }; + + var acked = await Workflow.WaitConditionAsync( + () => _stepAcknowledged || _escalateRequested || _faultCleared, + ackTimeout); + + if (!acked || _escalateRequested) + { + await EscalateAsync(input.MachineId, input.AlarmCode); + _escalateRequested = false; + continue; + } + + if (_faultCleared) break; + + // Wait for step completion report from operator + var completionTimeout = TimeSpan.FromMinutes(20); + var completed = await Workflow.WaitConditionAsync( + () => _stepCompleted || _faultCleared, completionTimeout); + + if (!completed) + { + await EscalateAsync(input.MachineId, input.AlarmCode); + continue; + } + + if (_faultCleared) break; + + if (_stepSuccess) + { + _completedSteps.Add(_resolutionSteps[_currentStepIndex].Description); + _currentStepIndex++; + } + else + { + // Step failed — escalate + await EscalateAsync(input.MachineId, input.AlarmCode); + } + } + + // Determine outcome + bool resolved = _faultCleared; + bool needsMaintenance = _escalationLevel >= 2 || !resolved; + + if (needsMaintenance) + { + _maintenanceWorkOrderId = await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.CreateMaintenanceWorkOrderAsync( + input.MachineId, input.AlarmCode, + $"Alarm {input.AlarmCode} required engineer escalation", + "Corrective"), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + } + + var resolutionTime = Workflow.UtcNow - startedAt; + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.RecordFaultResolutionOutcomeAsync( + input.AlarmCode, input.MachineId, _completedSteps, resolved, + (int)resolutionTime.TotalMinutes), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + + Workflow.Logger.LogInformation( + "FaultResolution complete: Alarm={Alarm}, Resolved={R}, Level={Level}", + input.AlarmCode, resolved, _escalationLevel); + + return new FaultResolutionResult( + input.AlarmCode, input.MachineId, + resolved ? "Resolved" : "Escalated", + _escalationLevel, _completedSteps.Count, + _maintenanceWorkOrderId.HasValue, _maintenanceWorkOrderId, + resolutionTime); + } + + private async Task EscalateAsync(int machineId, string alarmCode) + { + _escalationLevel++; + string[] personnel = { "Operator", "Supervisor", "Engineer" }; + var target = personnel[Math.Min(_escalationLevel, personnel.Length - 1)]; + + Workflow.Logger.LogWarning( + "Escalating fault: Alarm={Alarm}, Machine={Machine}, Level={Level} → {Target}", + alarmCode, machineId, _escalationLevel, target); + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.EscalateFaultAsync( + machineId, alarmCode, _escalationLevel, target), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + } + + [WorkflowSignal("StepAcknowledged")] + public Task SignalStepAcknowledgedAsync(int stepIndex, string operatorId) + { + if (stepIndex == _currentStepIndex) + _stepAcknowledged = true; + return Task.CompletedTask; + } + + [WorkflowSignal("StepCompleted")] + public Task SignalStepCompletedAsync(int stepIndex, bool success, string? notes) + { + if (stepIndex == _currentStepIndex) + { + _stepCompleted = true; + _stepSuccess = success; + _stepNotes = notes; + } + return Task.CompletedTask; + } + + [WorkflowSignal("EscalateRequested")] + public Task SignalEscalateAsync(string requestedBy, string reason) + { + _escalateRequested = true; + return Task.CompletedTask; + } + + [WorkflowSignal("FaultCleared")] + public Task SignalFaultClearedAsync(string operatorId) + { + _faultCleared = true; + return Task.CompletedTask; + } + + [WorkflowQuery("GetCurrentState")] + public FaultResolutionState GetCurrentState() => new( + _resolutionSteps.Count > 0 ? _resolutionSteps[Math.Min(_currentStepIndex, _resolutionSteps.Count - 1)].Code : "Unknown", + _escalationLevel, _currentStepIndex, _resolutionSteps.Count, + !_stepAcknowledged, _escalationLevel == 1, _escalationLevel >= 2, + Workflow.UtcNow, _completedSteps.AsReadOnly()); +} + +public record ResolutionStepInfo(string Code, int StepIndex, string Description, string? ToolRequired, bool RequiresAck); diff --git a/src/temporal/orchestration-worker/Workflows/MoverSchedulerWorkflow.cs b/src/temporal/orchestration-worker/Workflows/MoverSchedulerWorkflow.cs new file mode 100644 index 0000000..106d626 --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/MoverSchedulerWorkflow.cs @@ -0,0 +1,111 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// Long-running singleton managing the global mover pool. +/// Replaces the ad-hoc AssignMoverForPickup activity with a workflow-coordinated +/// allocation that is aware of WIP limits and machine priorities. +/// +[Workflow] +public class MoverSchedulerWorkflow : IMoverSchedulerWorkflow +{ + private readonly Queue _availableMovers = new(); + private readonly List _assignments = new(); + private readonly Queue _pendingRequests = new(); + private int _wipCount; + private bool _shutdown; + private int _maxWip; + private int _totalMovers; + + [WorkflowRun] + public async Task RunAsync(MoverSchedulerInput input) + { + _totalMovers = input.TotalMoverCount; + _maxWip = input.MaxWipParts; + + // Seed all movers as available + for (int i = 0; i < input.TotalMoverCount; i++) + _availableMovers.Enqueue(i); + + Workflow.Logger.LogInformation( + "MoverScheduler started: {Count} movers, MaxWIP={Wip}", + input.TotalMoverCount, input.MaxWipParts); + + while (!_shutdown) + { + // Dispatch pending requests when movers are available and WIP < max + while (_pendingRequests.Count > 0 && _availableMovers.Count > 0 && _wipCount < _maxWip) + { + var request = _pendingRequests.Dequeue(); + var moverId = _availableMovers.Dequeue(); + + _assignments.Add(new MoverAssignment(moverId, request.MachineId, + request.PartTrackingNumber, Workflow.UtcNow)); + _wipCount++; + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.NotifyMoverAssignedAsync( + moverId, request.MachineId, request.PartTrackingNumber, + request.CallbackWorkflowId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + + Workflow.Logger.LogInformation( + "Mover {Mover} assigned to Machine {Machine}, Part {Part}", + moverId, request.MachineId, request.PartTrackingNumber); + } + + await Workflow.DelayAsync(TimeSpan.FromMilliseconds(200)); + } + + Workflow.Logger.LogInformation("MoverScheduler shutdown."); + } + + [WorkflowSignal("MoverAvailable")] + public Task SignalMoverAvailableAsync(int moverId) + { + if (!_availableMovers.Contains(moverId)) + _availableMovers.Enqueue(moverId); + return Task.CompletedTask; + } + + [WorkflowSignal("MoverRequested")] + public Task SignalMoverRequestedAsync(MoverRequest request) + { + _pendingRequests.Enqueue(request); + return Task.CompletedTask; + } + + [WorkflowSignal("MoverReleased")] + public Task SignalMoverReleasedAsync(int moverId, string reason) + { + var assignment = _assignments.FirstOrDefault(a => a.MoverId == moverId); + if (assignment != null) + { + _assignments.Remove(assignment); + _wipCount = Math.Max(0, _wipCount - 1); + } + if (!_availableMovers.Contains(moverId)) + _availableMovers.Enqueue(moverId); + + Workflow.Logger.LogInformation("Mover {Mover} released: {Reason}", moverId, reason); + return Task.CompletedTask; + } + + [WorkflowSignal("Shutdown")] + public Task SignalShutdownAsync() + { + _shutdown = true; + return Task.CompletedTask; + } + + [WorkflowQuery("GetPoolStatus")] + public MoverPoolStatus GetPoolStatus() => new( + _totalMovers, + _availableMovers.Count, + _assignments.Count, + _wipCount, _maxWip, + _assignments.AsReadOnly()); +} diff --git a/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs b/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs new file mode 100644 index 0000000..51c4ac3 --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs @@ -0,0 +1,144 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// One instance per production work order. Owns the high-level lifecycle: +/// load recipe → release parts → track completions → close order. +/// Fans out a PartLifecycleWorkflow (existing) for each part. +/// +[Workflow] +public class ProductionOrderWorkflow : IProductionOrderWorkflow +{ + private int _quantityCompleted; + private int _quantityGood; + private int _quantityBad; + private bool _paused; + private bool _cancelled; + private string? _pauseReason; + private string? _cancelledBy; + private DateTime _startedAt; + private IReadOnlyList _machineRoute = Array.Empty(); + + [WorkflowRun] + public async Task RunAsync(ProductionOrderInput input) + { + _startedAt = Workflow.UtcNow; + + Workflow.Logger.LogInformation( + "ProductionOrder started: WO={WO}, Product={P}, Qty={Q}", + input.OrderNumber, input.ProductId, input.QuantityOrdered); + + // Load the recipe to get machine route + _machineRoute = await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.LoadRecipeAsync(input.RecipeId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + // Update work order status to Running in MES + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.UpdateWorkOrderStatusAsync( + input.WorkOrderId, "Running", Workflow.UtcNow), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + // Release parts one at a time, respecting WIP limits + int partNumber = 0; + while (_quantityCompleted < input.QuantityOrdered && !_cancelled) + { + // Wait if paused + if (_paused) + { + await Workflow.WaitConditionAsync(() => !_paused || _cancelled); + if (_cancelled) break; + } + + // Spawn a PartLifecycleWorkflow child for this part + // (delegates to existing master-worker task queue) + var partTracking = $"{input.OrderNumber}-P{++partNumber:D4}"; + var partId = Guid.NewGuid(); + + await Workflow.StartChildWorkflowAsync( + (IPartLifecycleWorkflow w) => w.RunAsync( + new PartLifecycleInput(partId, partTracking, _machineRoute)), + new ChildWorkflowOptions + { + Id = $"part-{partTracking}", + TaskQueue = "xts-prime-mover-queue" + }); + + // Wait for next part completion before releasing another + // (simple rate-limiting — in practice, a WIP semaphore workflow handles concurrency) + await Workflow.WaitConditionAsync( + () => _quantityCompleted >= partNumber * 0.8, // allow up to 20% ahead + TimeSpan.FromMinutes(5)); + } + + // Wait for all remaining parts to complete + await Workflow.WaitConditionAsync( + () => _quantityCompleted >= input.QuantityOrdered || _cancelled, + TimeSpan.FromHours(8)); + + var finalStatus = _cancelled ? "Cancelled" : "Complete"; + var runTime = Workflow.UtcNow - _startedAt; + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.UpdateWorkOrderStatusAsync( + input.WorkOrderId, finalStatus, Workflow.UtcNow), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + Workflow.Logger.LogInformation( + "ProductionOrder {Status}: WO={WO}, Good={G}/{Q}", + finalStatus, input.OrderNumber, _quantityGood, input.QuantityOrdered); + + return new ProductionOrderResult(input.WorkOrderId, input.OrderNumber, finalStatus, + _quantityCompleted, _quantityGood, _quantityBad, runTime); + } + + [WorkflowSignal("PartCompleted")] + public Task SignalPartCompletedAsync(string partTrackingNumber, bool good, string? defectCategory) + { + _quantityCompleted++; + if (good) _quantityGood++; + else _quantityBad++; + return Task.CompletedTask; + } + + [WorkflowSignal("Pause")] + public Task SignalPauseAsync(string reason, string requestedBy) + { + _paused = true; + _pauseReason = reason; + Workflow.Logger.LogInformation("WO paused by {Op}: {Reason}", requestedBy, reason); + return Task.CompletedTask; + } + + [WorkflowSignal("Resume")] + public Task SignalResumeAsync(string authorizedBy) + { + _paused = false; + _pauseReason = null; + Workflow.Logger.LogInformation("WO resumed by {Op}", authorizedBy); + return Task.CompletedTask; + } + + [WorkflowSignal("Cancel")] + public Task SignalCancelAsync(string reason, string authorizedBy) + { + _cancelled = true; + _cancelledBy = authorizedBy; + Workflow.Logger.LogWarning("WO cancelled by {Op}: {Reason}", authorizedBy, reason); + return Task.CompletedTask; + } + + [WorkflowQuery("GetProgress")] + public ProductionOrderProgress GetProgress() + { + double pct = _quantityCompleted == 0 ? 0 + : Math.Round((double)_quantityCompleted / 100.0 * 100.0, 1); + return new ProductionOrderProgress( + _cancelled ? "Cancelled" : (_paused ? "Paused" : "Running"), + 0, _quantityCompleted, _quantityGood, _quantityBad, pct, + _paused, _pauseReason, null); + } +} diff --git a/src/temporal/orchestration-worker/Workflows/QualityGateWorkflow.cs b/src/temporal/orchestration-worker/Workflows/QualityGateWorkflow.cs new file mode 100644 index 0000000..096e02c --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/QualityGateWorkflow.cs @@ -0,0 +1,113 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// Per-inspection-event workflow. Aggregates measurement results, applies recipe +/// quality thresholds, decides Pass / Hold / Scrap, creates NCR if failed. +/// Waits up to 60 s for a supervisor override before auto-deciding. +/// +[Workflow] +public class QualityGateWorkflow : IQualityGateWorkflow +{ + private readonly List _results = new(); + private string? _supervisorDecision; + private string? _supervisorOperatorId; + private string? _supervisorReason; + private bool _decisionMade; + + [WorkflowRun] + public async Task RunAsync(QualityGateInput input) + { + Workflow.Logger.LogInformation( + "QualityGate started for Part {Part}, WorkOrder {WO}", + input.PartTrackingNumber, input.WorkOrderId); + + // Wait for all inspection results to arrive (or timeout after 120 s) + await Workflow.WaitConditionAsync( + () => _decisionMade || _results.Count > 0, + TimeSpan.FromSeconds(120)); + + // Evaluate measurements against recipe thresholds + bool hasDefect = _results.Any(r => r.HasDefect); + string? defectCategory = _results.FirstOrDefault(r => r.HasDefect)?.DefectCategory; + + // Check quantitative thresholds + foreach (var result in _results) + { + foreach (var (key, value) in result.Measurements) + { + if (input.QualityThresholds.TryGetValue(key, out var threshold) && value > threshold) + { + hasDefect = true; + defectCategory ??= $"{key} out of spec ({value:F3} > {threshold:F3})"; + } + } + } + + // If borderline — wait for supervisor override (up to 30 s) + if (hasDefect && !_decisionMade) + { + Workflow.Logger.LogWarning("Part {Part} borderline — notifying supervisor", input.PartTrackingNumber); + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.NotifySupervisorQualityHoldAsync( + input.PartTrackingNumber, defectCategory ?? "Unknown", input.MachineId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + // Wait up to 30 s for supervisor override + await Workflow.WaitConditionAsync(() => _supervisorDecision != null, TimeSpan.FromSeconds(30)); + } + + string finalDecision = _supervisorDecision ?? (hasDefect ? "Scrap" : "Pass"); + Guid? ncrId = null; + + if (finalDecision != "Pass") + { + ncrId = await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.CreateNonConformanceReportAsync( + input.PartTrackingNumber, input.MachineId, defectCategory ?? "Defect", + finalDecision, input.WorkOrderId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + } + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.RecordQualityDecisionAsync( + input.PartTrackingNumber, finalDecision, _results, input.WorkOrderId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + + Workflow.Logger.LogInformation( + "QualityGate complete: Part={Part}, Decision={Decision}", input.PartTrackingNumber, finalDecision); + + return new QualityGateResult(input.PartTrackingNumber, finalDecision, ncrId.HasValue, ncrId, + hasDefect ? defectCategory : null); + } + + [WorkflowSignal("InspectionResultReceived")] + public Task SignalInspectionResultAsync(InspectionResult result) + { + _results.Add(result); + return Task.CompletedTask; + } + + [WorkflowSignal("SupervisorOverride")] + public Task SignalSupervisorOverrideAsync(string decision, string operatorId, string reason) + { + _supervisorDecision = decision; + _supervisorOperatorId = operatorId; + _supervisorReason = reason; + _decisionMade = true; + Workflow.Logger.LogInformation( + "Supervisor override: Decision={Decision}, Operator={Op}", decision, operatorId); + return Task.CompletedTask; + } + + [WorkflowQuery("GetDecision")] + public QualityGateDecision GetDecision() => new( + _decisionMade ? "Decided" : "Pending", + _supervisorDecision, + _supervisorDecision == null && _results.Any(r => r.HasDefect), + _results.AsReadOnly()); +} diff --git a/src/temporal/orchestration-worker/Workflows/SafetyOrchestrationWorkflow.cs b/src/temporal/orchestration-worker/Workflows/SafetyOrchestrationWorkflow.cs new file mode 100644 index 0000000..7ff0aef --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/SafetyOrchestrationWorkflow.cs @@ -0,0 +1,125 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// Long-running singleton. Manages E-stop propagation and lockout/tagout +/// across all machines. All safety state is in this single authoritative workflow. +/// +[Workflow] +public class SafetyOrchestrationWorkflow : ISafetyOrchestrationWorkflow +{ + private bool _eStopActive; + private string? _eStopReason; + private string? _eStopSource; + private DateTime? _eStopTriggeredAt; + private readonly HashSet _lockedOutMachines = new(); + private readonly HashSet _pendingResetMachines = new(); + private bool _shutdown; + private string? _resetAuthorizedBy; + private bool _resetRequested; + + [WorkflowRun] + public async Task RunAsync(SafetyOrchestrationInput input) + { + Workflow.Logger.LogInformation( + "SafetyOrchestration started for machines: [{Machines}]", + string.Join(",", input.MachineIds)); + + while (!_shutdown) + { + await Workflow.WaitConditionAsync(() => _resetRequested || _shutdown); + + if (_shutdown) break; + + if (_resetRequested && _eStopActive) + { + _resetRequested = false; + Workflow.Logger.LogInformation( + "Safety reset authorized by {Auth}", _resetAuthorizedBy); + + // Verify all machines are locked out before allowing reset + bool allLocked = _lockedOutMachines.Count >= input.MachineIds.Count; + if (!allLocked) + { + Workflow.Logger.LogWarning( + "Safety reset rejected — not all machines locked out"); + continue; + } + + // Clear E-stop + _eStopActive = false; + _eStopReason = null; + _eStopSource = null; + _eStopTriggeredAt = null; + _lockedOutMachines.Clear(); + _pendingResetMachines.Clear(); + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.BroadcastSafetyAllClearAsync(_resetAuthorizedBy!), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + Workflow.Logger.LogInformation("Safety all-clear. E-stop cleared."); + } + } + } + + [WorkflowSignal("EmergencyStop")] + public async Task SignalEmergencyStopAsync(string sourceId, string reason, string triggeredBy) + { + if (_eStopActive) return; // already in E-stop + + _eStopActive = true; + _eStopReason = reason; + _eStopSource = sourceId; + _eStopTriggeredAt = Workflow.UtcNow; + + Workflow.Logger.LogCritical( + "EMERGENCY STOP: Source={Source}, Reason={Reason}, By={By}", + sourceId, reason, triggeredBy); + + // Fire-and-forget stop all machines (activity handles per-machine stops) + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.EmergencyStopAllMachinesAsync(sourceId, reason), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.NotifySafetyOfficerAsync(sourceId, reason, triggeredBy), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + } + + [WorkflowSignal("LockoutTagoutConfirmed")] + public Task SignalLockoutTagoutConfirmedAsync(int machineId, string technicianId, string lockTag) + { + _lockedOutMachines.Add(machineId); + _pendingResetMachines.Remove(machineId); + Workflow.Logger.LogInformation( + "Lockout confirmed: Machine={M}, Tech={T}, Tag={Tag}", + machineId, technicianId, lockTag); + return Task.CompletedTask; + } + + [WorkflowSignal("SafetyResetRequested")] + public Task SignalSafetyResetAsync(string authorizedBy, string verificationCode) + { + _resetAuthorizedBy = authorizedBy; + _resetRequested = true; + return Task.CompletedTask; + } + + [WorkflowSignal("Shutdown")] + public Task SignalShutdownAsync() + { + _shutdown = true; + return Task.CompletedTask; + } + + [WorkflowQuery("GetSafetyState")] + public SafetySystemState GetSafetyState() => new( + _eStopActive, _eStopReason, _eStopSource, _eStopTriggeredAt, + _lockedOutMachines.ToList().AsReadOnly(), + _pendingResetMachines.ToList().AsReadOnly(), + !_eStopActive); +} diff --git a/src/temporal/orchestration-worker/Workflows/ShiftTransitionWorkflow.cs b/src/temporal/orchestration-worker/Workflows/ShiftTransitionWorkflow.cs new file mode 100644 index 0000000..7fda1f9 --- /dev/null +++ b/src/temporal/orchestration-worker/Workflows/ShiftTransitionWorkflow.cs @@ -0,0 +1,90 @@ +using Temporalio.Workflows; +using XtsContracts.Workflows; +using OrchestrationWorker.Activities; + +namespace OrchestrationWorker.Workflows; + +/// +/// Triggered at shift end. Drains WIP, takes a production snapshot, +/// records shift KPIs, and enforces an operator acknowledgement handoff. +/// +[Workflow] +public class ShiftTransitionWorkflow : IShiftTransitionWorkflow +{ + private bool _wipDrained; + private bool _snapshotTaken; + private bool _operatorAcknowledged; + private int _remainingWip = 0; + + [WorkflowRun] + public async Task RunAsync(ShiftTransitionInput input) + { + Workflow.Logger.LogInformation( + "ShiftTransition started: Shift={Shift}, End={End}", + input.ShiftName, input.ShiftEndTime); + + // Phase 1: Stop accepting new parts (signal prime mover) + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.StopNewPartEntryAsync(input.ShiftId), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + + // Phase 2: Wait for WIP to drain (max 30 min) + var wipDrained = await Workflow.WaitConditionAsync( + () => _wipDrained, TimeSpan.FromMinutes(30)); + + if (!wipDrained) + { + // Force drain — record stuck parts as incomplete + Workflow.Logger.LogWarning("Shift {Shift}: WIP did not drain in time — force-closing", input.ShiftName); + } + _wipDrained = true; + + // Phase 3: Take production snapshot + var summary = await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.TakeShiftSnapshotAsync(input.ShiftId, input.ShiftName), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(30) }); + _snapshotTaken = true; + + // Phase 4: Wait for operator acknowledgement (max 15 min) + await Workflow.WaitConditionAsync( + () => _operatorAcknowledged, TimeSpan.FromMinutes(15)); + + // Phase 5: Notify incoming shift + if (input.IncomingShiftId.HasValue) + { + await Workflow.ExecuteActivityAsync( + (OrchestrationActivities a) => a.NotifyIncomingShiftAsync( + input.IncomingShiftId.Value, input.IncomingShiftName ?? "Next Shift", + summary.Handoff), + new ActivityOptions { StartToCloseTimeout = TimeSpan.FromSeconds(15) }); + } + + Workflow.Logger.LogInformation( + "ShiftTransition complete: Shift={Shift}, Parts={Parts}", + input.ShiftName, summary.PartsCompleted); + + return new ShiftTransitionResult( + input.ShiftId, true, summary.PartsCompleted, + summary.AlarmsThisShift, summary.TotalUptime, summary.Handoff); + } + + [WorkflowSignal("OperatorAcknowledged")] + public Task SignalOperatorAcknowledgedAsync(string operatorId, string acknowledgementCode) + { + _operatorAcknowledged = true; + return Task.CompletedTask; + } + + [WorkflowSignal("WipDrainConfirmed")] + public Task SignalWipDrainConfirmedAsync() + { + _wipDrained = true; + return Task.CompletedTask; + } + + [WorkflowQuery("GetTransitionState")] + public ShiftTransitionState GetTransitionState() => new( + _wipDrained ? (_snapshotTaken ? (_operatorAcknowledged ? "Complete" : "WaitingForHandoff") : "TakingSnapshot") : "DrainingWip", + _wipDrained, _snapshotTaken, _operatorAcknowledged, + _remainingWip, null); +} diff --git a/src/temporal/orchestration-worker/appsettings.json b/src/temporal/orchestration-worker/appsettings.json new file mode 100644 index 0000000..2666ad3 --- /dev/null +++ b/src/temporal/orchestration-worker/appsettings.json @@ -0,0 +1,38 @@ +{ + "Temporal": { + "Address": "temporal:7233", + "Namespace": "xts-system" + }, + "MesService": { + "BaseUrl": "http://mes-service:8100" + }, + "WorkforceService": { + "BaseUrl": "http://workforce-service:8200" + }, + "KnowledgeBaseService": { + "BaseUrl": "http://kb-service:8300" + }, + "MaintenanceService": { + "BaseUrl": "http://maintenance-service:8400" + }, + "PrimeMoverApi": { + "BaseUrl": "http://prime-mover-api:8082" + }, + "MachineApis": { + "Machine0": "http://machine-api-0:8081", + "Machine1": "http://machine-api-1:8081", + "Machine2": "http://machine-api-2:8081", + "Machine3": "http://machine-api-3:8081" + }, + "Orchestration": { + "MoverCount": 10, + "MachineIds": [ 0, 1, 2, 3 ], + "MaxWipParts": 6 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/workforce/WorkforceService/Controllers/WorkforceControllers.cs b/src/workforce/WorkforceService/Controllers/WorkforceControllers.cs new file mode 100644 index 0000000..93c6316 --- /dev/null +++ b/src/workforce/WorkforceService/Controllers/WorkforceControllers.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Mvc; +using WorkforceService.Services; +using WorkforceService.Domain; + +namespace WorkforceService.Controllers; + +[ApiController] +[Route("api/employees")] +public class EmployeesController : ControllerBase +{ + private readonly EmployeeService _service; + + public EmployeesController(EmployeeService service) => _service = service; + + [HttpGet] + public async Task GetAll([FromQuery] bool? activeOnly) => + Ok(await _service.GetAllAsync(activeOnly)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var e = await _service.GetByIdAsync(id); + if (e == null) return NotFound(); + var certs = await _service.GetCertificationsAsync(id); + return Ok(_service.ToDto(e, certs)); + } + + [HttpGet("badge/{badgeId}")] + public async Task GetByBadge(string badgeId) + { + var e = await _service.GetByBadgeIdAsync(badgeId); + return e == null ? NotFound() : Ok(_service.ToDto(e)); + } + + [HttpPost] + public async Task Create([FromBody] CreateEmployeeRequest req) + { + var emp = await _service.CreateAsync(new Employee + { + FirstName = req.FirstName, + LastName = req.LastName, + Email = req.Email, + Role = Enum.TryParse(req.Role, true, out var r) ? r : EmployeeRole.Operator, + Department = req.Department ?? string.Empty, + BadgeId = req.BadgeId + }); + return CreatedAtAction(nameof(GetById), new { id = emp.Id }, _service.ToDto(emp)); + } + + [HttpGet("{id:guid}/qualified/{machineType}")] + public async Task IsQualified(Guid id, string machineType) => + Ok(new { qualified = await _service.IsQualifiedForMachineAsync(id, machineType) }); + + [HttpPost("{id:guid}/certifications")] + public async Task AddCertification(Guid id, [FromBody] AddCertificationRequest req) + { + await _service.AddCertificationAsync(new Certification + { + EmployeeId = id, + CertificationType = req.CertificationType, + MachineType = req.MachineType, + IssuedAt = DateTime.UtcNow, + ExpiresAt = req.ExpiresAt, + IssuedBy = req.IssuedBy + }); + return Ok(new { message = "Certification added." }); + } +} + +[ApiController] +[Route("api/shifts")] +public class ShiftsController : ControllerBase +{ + private readonly ShiftService _service; + + public ShiftsController(ShiftService service) => _service = service; + + [HttpGet] + public async Task GetShifts([FromQuery] string? date) + { + var d = DateOnly.TryParse(date, out var parsed) ? parsed : DateOnly.FromDateTime(DateTime.Today); + return Ok(await _service.GetShiftsAsync(d)); + } + + [HttpPost] + public async Task Create([FromBody] CreateShiftRequest req) + { + var shift = await _service.CreateShiftAsync(new Shift + { + ShiftName = req.ShiftName, + StartTime = TimeSpan.Parse(req.StartTime), + EndTime = TimeSpan.Parse(req.EndTime), + Date = DateOnly.Parse(req.Date) + }); + return Ok(shift); + } + + [HttpPost("{shiftId:guid}/assign")] + public async Task Assign(Guid shiftId, [FromBody] AssignEmployeeRequest req) + { + await _service.AssignEmployeeAsync(new ShiftAssignment + { + ShiftId = shiftId, + EmployeeId = req.EmployeeId, + MachineId = req.MachineId, + RoleOnShift = req.RoleOnShift + }); + return Ok(new { message = "Employee assigned to shift." }); + } + + [HttpPost("{shiftId:guid}/clock-in/{employeeId:guid}")] + public async Task ClockIn(Guid shiftId, Guid employeeId) + { + await _service.ClockInAsync(shiftId, employeeId); + return Ok(new { message = "Clocked in." }); + } + + [HttpPost("{shiftId:guid}/clock-out/{employeeId:guid}")] + public async Task ClockOut(Guid shiftId, Guid employeeId) + { + await _service.ClockOutAsync(shiftId, employeeId); + return Ok(new { message = "Clocked out." }); + } + + [HttpGet("machine/{machineId:int}/operator-present")] + public async Task IsOperatorPresent(int machineId) => + Ok(new { present = await _service.IsOperatorLoggedInAtMachineAsync(machineId) }); +} + +[ApiController] +[Route("api/training")] +public class TrainingController : ControllerBase +{ + private readonly TrainingService _service; + + public TrainingController(TrainingService service) => _service = service; + + [HttpGet("{employeeId:guid}")] + public async Task GetForEmployee(Guid employeeId) => + Ok(await _service.GetForEmployeeAsync(employeeId)); + + [HttpGet("expiring")] + public async Task GetExpiring([FromQuery] int days = 30) => + Ok(await _service.GetExpiringAsync(days)); + + [HttpPost("{employeeId:guid}/complete")] + public async Task RecordCompletion( + Guid employeeId, [FromBody] CompleteTrainingRequest req) + { + await _service.RecordCompletionAsync(employeeId, req.ModuleName, req.MachineType, req.Score); + return Ok(new { message = "Training completion recorded." }); + } + + [HttpGet("{employeeId:guid}/qualified/{machineType}")] + public async Task IsQualified(Guid employeeId, string machineType) => + Ok(new { qualified = await _service.HasCompletedRequiredTrainingAsync(employeeId, machineType) }); +} + +public record CreateEmployeeRequest( + string FirstName, string LastName, string? Email, + string Role, string? Department, string? BadgeId); +public record AddCertificationRequest(string CertificationType, string? MachineType, DateTime? ExpiresAt, string? IssuedBy); +public record CreateShiftRequest(string ShiftName, string StartTime, string EndTime, string Date); +public record AssignEmployeeRequest(Guid EmployeeId, int? MachineId, string? RoleOnShift); +public record CompleteTrainingRequest(string ModuleName, string MachineType, string Score); diff --git a/src/workforce/WorkforceService/Database/WorkforceDatabase.cs b/src/workforce/WorkforceService/Database/WorkforceDatabase.cs new file mode 100644 index 0000000..84b5e2a --- /dev/null +++ b/src/workforce/WorkforceService/Database/WorkforceDatabase.cs @@ -0,0 +1,88 @@ +using Npgsql; + +namespace WorkforceService.Database; + +public static class WorkforceDatabase +{ + public static async Task EnsureSchemaAsync(string connectionString) + { + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + + CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_number VARCHAR(50) UNIQUE NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(200), + role VARCHAR(100) NOT NULL, + department VARCHAR(100) NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + badge_id VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_employees_badge ON employees(badge_id); + CREATE INDEX IF NOT EXISTS idx_employees_role ON employees(role); + + CREATE TABLE IF NOT EXISTS certifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID REFERENCES employees(id) ON DELETE CASCADE, + certification_type VARCHAR(100) NOT NULL, + machine_type VARCHAR(100), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + issued_by VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT TRUE + ); + CREATE INDEX IF NOT EXISTS idx_certs_employee ON certifications(employee_id); + + CREATE TABLE IF NOT EXISTS shifts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shift_name VARCHAR(50) NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + date DATE NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'Scheduled' + ); + CREATE INDEX IF NOT EXISTS idx_shifts_date ON shifts(date); + + CREATE TABLE IF NOT EXISTS shift_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shift_id UUID REFERENCES shifts(id) ON DELETE CASCADE, + employee_id UUID REFERENCES employees(id), + machine_id INT, + role_on_shift VARCHAR(100), + clocked_in_at TIMESTAMPTZ, + clocked_out_at TIMESTAMPTZ + ); + + CREATE TABLE IF NOT EXISTS training_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID REFERENCES employees(id) ON DELETE CASCADE, + module_name VARCHAR(200) NOT NULL, + machine_type VARCHAR(100) NOT NULL DEFAULT '', + status VARCHAR(50) NOT NULL DEFAULT 'NotStarted', + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + score VARCHAR(50), + UNIQUE (employee_id, module_name) + ); + CREATE INDEX IF NOT EXISTS idx_training_employee ON training_records(employee_id); + + CREATE TABLE IF NOT EXISTS performance_kpis ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID REFERENCES employees(id), + machine_id INT NOT NULL, + parts_per_hour DOUBLE PRECISION NOT NULL DEFAULT 0, + quality_yield_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + alarm_response_seconds DOUBLE PRECISION NOT NULL DEFAULT 0, + kpi_date DATE NOT NULL DEFAULT CURRENT_DATE + ); + CREATE INDEX IF NOT EXISTS idx_kpi_employee_date ON performance_kpis(employee_id, kpi_date); + """; + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/src/workforce/WorkforceService/Domain/Employee.cs b/src/workforce/WorkforceService/Domain/Employee.cs new file mode 100644 index 0000000..7405fff --- /dev/null +++ b/src/workforce/WorkforceService/Domain/Employee.cs @@ -0,0 +1,71 @@ +namespace WorkforceService.Domain; + +public enum EmployeeRole { Operator, Technician, Engineer, Supervisor, Manager, Quality, Safety } + +public class Employee +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string EmployeeNumber { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string? Email { get; set; } + public EmployeeRole Role { get; set; } + public string Department { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public string? BadgeId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public string FullName => $"{FirstName} {LastName}"; +} + +public class Certification +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid EmployeeId { get; set; } + public string CertificationType { get; set; } = string.Empty; + public string? MachineType { get; set; } + public DateTime IssuedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? IssuedBy { get; set; } + public bool IsActive { get; set; } = true; + + public bool IsExpired => ExpiresAt.HasValue && ExpiresAt < DateTime.UtcNow; + public int? DaysUntilExpiry => ExpiresAt.HasValue + ? (int)(ExpiresAt.Value - DateTime.UtcNow).TotalDays + : null; +} + +public class Shift +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ShiftName { get; set; } = string.Empty; + public TimeSpan StartTime { get; set; } + public TimeSpan EndTime { get; set; } + public DateOnly Date { get; set; } + public string Status { get; set; } = "Scheduled"; + public List Assignments { get; set; } = new(); +} + +public class ShiftAssignment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ShiftId { get; set; } + public Guid EmployeeId { get; set; } + public string EmployeeName { get; set; } = string.Empty; + public int? MachineId { get; set; } + public string? RoleOnShift { get; set; } + public DateTime? ClockedInAt { get; set; } + public DateTime? ClockedOutAt { get; set; } +} + +public class TrainingRecord +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid EmployeeId { get; set; } + public string ModuleName { get; set; } = string.Empty; + public string MachineType { get; set; } = string.Empty; + public string Status { get; set; } = "NotStarted"; + public DateTime? CompletedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? Score { get; set; } +} diff --git a/src/workforce/WorkforceService/Program.cs b/src/workforce/WorkforceService/Program.cs new file mode 100644 index 0000000..661ae0a --- /dev/null +++ b/src/workforce/WorkforceService/Program.cs @@ -0,0 +1,31 @@ +using WorkforceService.Database; +using WorkforceService.Services; +using Microsoft.OpenApi.Models; +using Prometheus; + +var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=workforce_db;Username=xts;******"; + +builder.Services.AddControllers(); +builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Workforce Service API", Version = "v1" })); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(opts => opts.AddPolicy("AllowAll", p => + p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +await WorkforceDatabase.EnsureSchemaAsync(connectionString); + +app.UseCors("AllowAll"); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpMetrics(); +app.MapControllers(); +app.MapMetrics("/metrics"); +app.MapHealthChecks("/health"); +app.Run(); diff --git a/src/workforce/WorkforceService/Services/EmployeeService.cs b/src/workforce/WorkforceService/Services/EmployeeService.cs new file mode 100644 index 0000000..b51f6ab --- /dev/null +++ b/src/workforce/WorkforceService/Services/EmployeeService.cs @@ -0,0 +1,102 @@ +using Dapper; +using Npgsql; +using WorkforceService.Domain; +using XtsContracts.Dtos; + +namespace WorkforceService.Services; + +public class EmployeeService +{ + private readonly string _connectionString; + private static int _empCounter; + + public EmployeeService(IConfiguration config) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=workforce_db;Username=xts;******"; + } + + public async Task> GetAllAsync(bool? activeOnly = null) + { + await using var conn = new NpgsqlConnection(_connectionString); + var sql = activeOnly == true + ? "SELECT * FROM employees WHERE is_active = TRUE ORDER BY last_name, first_name" + : "SELECT * FROM employees ORDER BY last_name, first_name"; + return await conn.QueryAsync(sql); + } + + public async Task GetByIdAsync(Guid id) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM employees WHERE id = @Id", new { Id = id }); + } + + public async Task GetByBadgeIdAsync(string badgeId) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryFirstOrDefaultAsync( + "SELECT * FROM employees WHERE badge_id = @Badge AND is_active = TRUE", + new { Badge = badgeId }); + } + + public async Task CreateAsync(Employee emp) + { + emp.Id = Guid.NewGuid(); + emp.EmployeeNumber = GenerateEmpNumber(); + emp.CreatedAt = DateTime.UtcNow; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO employees (id, employee_number, first_name, last_name, email, role, department, is_active, badge_id, created_at) + VALUES (@Id, @EmployeeNumber, @FirstName, @LastName, @Email, @Role, @Department, @IsActive, @BadgeId, @CreatedAt) + """, new + { + emp.Id, emp.EmployeeNumber, emp.FirstName, emp.LastName, emp.Email, + Role = emp.Role.ToString(), emp.Department, emp.IsActive, emp.BadgeId, emp.CreatedAt + }); + return emp; + } + + public async Task IsQualifiedForMachineAsync(Guid employeeId, string machineType) + { + await using var conn = new NpgsqlConnection(_connectionString); + var count = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM certifications + WHERE employee_id = @EmpId + AND machine_type = @MachineType + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + """, new { EmpId = employeeId, MachineType = machineType }); + return count > 0; + } + + public async Task> GetCertificationsAsync(Guid employeeId) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryAsync( + "SELECT * FROM certifications WHERE employee_id = @EmpId AND is_active = TRUE", + new { EmpId = employeeId }); + } + + public async Task AddCertificationAsync(Certification cert) + { + cert.Id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO certifications (id, employee_id, certification_type, machine_type, issued_at, expires_at, issued_by, is_active) + VALUES (@Id, @EmployeeId, @CertificationType, @MachineType, @IssuedAt, @ExpiresAt, @IssuedBy, @IsActive) + """, cert); + } + + public EmployeeDto ToDto(Employee e, IEnumerable? certs = null) => new( + e.Id, e.EmployeeNumber, e.FirstName, e.LastName, e.Email ?? string.Empty, + e.Role.ToString(), e.Department, e.IsActive, e.BadgeId, + (certs ?? Enumerable.Empty()).Select(c => new CertificationDto( + c.Id, c.EmployeeId, c.CertificationType, c.MachineType, + c.IssuedAt, c.ExpiresAt, c.IssuedBy, c.IsActive)).ToList().AsReadOnly(), + e.CreatedAt); + + private static string GenerateEmpNumber() => + $"EMP-{Interlocked.Increment(ref _empCounter):D5}"; +} diff --git a/src/workforce/WorkforceService/Services/ShiftService.cs b/src/workforce/WorkforceService/Services/ShiftService.cs new file mode 100644 index 0000000..cd51774 --- /dev/null +++ b/src/workforce/WorkforceService/Services/ShiftService.cs @@ -0,0 +1,85 @@ +using Dapper; +using Npgsql; +using WorkforceService.Domain; +using XtsContracts.Dtos; + +namespace WorkforceService.Services; + +public class ShiftService +{ + private readonly string _connectionString; + + public ShiftService(IConfiguration config) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=workforce_db;Username=xts;******"; + } + + public async Task> GetShiftsAsync(DateOnly date) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryAsync( + "SELECT * FROM shifts WHERE date = @Date ORDER BY start_time", + new { Date = date.ToDateTime(TimeOnly.MinValue).Date }); + } + + public async Task CreateShiftAsync(Shift shift) + { + shift.Id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO shifts (id, shift_name, start_time, end_time, date, status) + VALUES (@Id, @ShiftName, @StartTime, @EndTime, @Date, @Status) + """, new + { + shift.Id, shift.ShiftName, + StartTime = shift.StartTime.ToString(), + EndTime = shift.EndTime.ToString(), + Date = shift.Date.ToDateTime(TimeOnly.MinValue).Date, + shift.Status + }); + return shift; + } + + public async Task AssignEmployeeAsync(ShiftAssignment assignment) + { + assignment.Id = Guid.NewGuid(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + INSERT INTO shift_assignments (id, shift_id, employee_id, machine_id, role_on_shift) + VALUES (@Id, @ShiftId, @EmployeeId, @MachineId, @RoleOnShift) + """, assignment); + } + + public async Task ClockInAsync(Guid shiftId, Guid employeeId) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + UPDATE shift_assignments SET clocked_in_at = NOW() + WHERE shift_id = @ShiftId AND employee_id = @EmpId AND clocked_in_at IS NULL + """, new { ShiftId = shiftId, EmpId = employeeId }); + } + + public async Task ClockOutAsync(Guid shiftId, Guid employeeId) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.ExecuteAsync(""" + UPDATE shift_assignments SET clocked_out_at = NOW() + WHERE shift_id = @ShiftId AND employee_id = @EmpId AND clocked_out_at IS NULL + """, new { ShiftId = shiftId, EmpId = employeeId }); + } + + public async Task IsOperatorLoggedInAtMachineAsync(int machineId) + { + await using var conn = new NpgsqlConnection(_connectionString); + var count = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM shift_assignments sa + JOIN shifts s ON s.id = sa.shift_id + WHERE sa.machine_id = @MachineId + AND sa.clocked_in_at IS NOT NULL + AND sa.clocked_out_at IS NULL + AND s.date = CURRENT_DATE + """, new { MachineId = machineId }); + return count > 0; + } +} diff --git a/src/workforce/WorkforceService/Services/TrainingService.cs b/src/workforce/WorkforceService/Services/TrainingService.cs new file mode 100644 index 0000000..64a11d8 --- /dev/null +++ b/src/workforce/WorkforceService/Services/TrainingService.cs @@ -0,0 +1,66 @@ +using Dapper; +using Npgsql; +using WorkforceService.Domain; +using XtsContracts.Dtos; + +namespace WorkforceService.Services; + +public class TrainingService +{ + private readonly string _connectionString; + + public TrainingService(IConfiguration config) + { + _connectionString = config.GetConnectionString("Postgres") + ?? "Host=postgresql;Database=workforce_db;Username=xts;******"; + } + + public async Task> GetForEmployeeAsync(Guid employeeId) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryAsync( + "SELECT * FROM training_records WHERE employee_id = @EmpId ORDER BY completed_at DESC", + new { EmpId = employeeId }); + } + + public async Task> GetExpiringAsync(int daysAhead = 30) + { + await using var conn = new NpgsqlConnection(_connectionString); + return await conn.QueryAsync(""" + SELECT * FROM training_records + WHERE expires_at IS NOT NULL + AND expires_at BETWEEN NOW() AND NOW() + INTERVAL '1 day' * @Days + ORDER BY expires_at + """, new { Days = daysAhead }); + } + + public async Task RecordCompletionAsync(Guid employeeId, string moduleName, string machineType, string score) + { + await using var conn = new NpgsqlConnection(_connectionString); + + // Upsert: update if exists, insert if not + await conn.ExecuteAsync(""" + INSERT INTO training_records (id, employee_id, module_name, machine_type, status, completed_at, score) + VALUES (gen_random_uuid(), @EmpId, @Module, @MachineType, 'Complete', NOW(), @Score) + ON CONFLICT (employee_id, module_name) DO UPDATE + SET status = 'Complete', completed_at = NOW(), score = @Score + """, new { EmpId = employeeId, Module = moduleName, MachineType = machineType, Score = score }); + } + + public async Task HasCompletedRequiredTrainingAsync(Guid employeeId, string machineType) + { + await using var conn = new NpgsqlConnection(_connectionString); + var count = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM training_records + WHERE employee_id = @EmpId + AND machine_type = @MachineType + AND status = 'Complete' + AND (expires_at IS NULL OR expires_at > NOW()) + """, new { EmpId = employeeId, MachineType = machineType }); + return count > 0; + } + + public TrainingRecordDto ToDto(TrainingRecord r) => new( + r.Id, r.EmployeeId, r.ModuleName, r.MachineType, + r.Status, r.CompletedAt, r.ExpiresAt, r.Score); +} diff --git a/src/workforce/WorkforceService/WorkforceService.csproj b/src/workforce/WorkforceService/WorkforceService.csproj new file mode 100644 index 0000000..4d6e9d2 --- /dev/null +++ b/src/workforce/WorkforceService/WorkforceService.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + WorkforceService + WorkforceService + + + + + + + + + diff --git a/src/workforce/WorkforceService/appsettings.json b/src/workforce/WorkforceService/appsettings.json new file mode 100644 index 0000000..b43e53c --- /dev/null +++ b/src/workforce/WorkforceService/appsettings.json @@ -0,0 +1 @@ +{"ConnectionStrings":{"Postgres":"Host=postgresql;Database=workforce_db;Username=xts;******"},"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","Urls":"http://0.0.0.0:8200"} \ No newline at end of file From a4d549b20f4af5c42d7e8cbb2a0ff04c61ed3d3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:02:12 +0000 Subject: [PATCH 09/10] Fix all CodeQL log-forging alerts: add LogSanitizer helper, sanitize user-provided values at all log call sites --- .../Services/ErrorKnowledgeService.cs | 5 +++-- .../Services/IssueReportingService.cs | 7 +++++-- .../MaintenanceService/Services/CmmsService.cs | 4 +++- src/mes/MesService/Services/QualityService.cs | 3 ++- src/mes/MesService/Services/RecipeService.cs | 5 +++-- src/shared/Contracts/Logging/LogSanitizer.cs | 15 +++++++++++++++ .../Workflows/ProductionOrderWorkflow.cs | 7 ++++--- 7 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/shared/Contracts/Logging/LogSanitizer.cs diff --git a/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs b/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs index 8cb9c20..95bd8db 100644 --- a/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs +++ b/src/knowledge-base/KnowledgeBaseService/Services/ErrorKnowledgeService.cs @@ -3,6 +3,7 @@ using Npgsql; using KnowledgeBaseService.Domain; using XtsContracts.Dtos; +using XtsContracts; namespace KnowledgeBaseService.Services; @@ -118,7 +119,7 @@ await conn.ExecuteAsync( _logger.LogInformation( "KB updated: Alarm={Code}, Success={S}, ConfScore={C:F3}", - alarmCode, wasSuccessful, newConfidence); + LogSanitizer.Sanitize(alarmCode), wasSuccessful, newConfidence); } // Pattern detection: same alarm 3× on same machine in 7 days @@ -132,7 +133,7 @@ SELECT COUNT(*) FROM resolution_outcomes { _logger.LogWarning( "PREDICTIVE FLAG: Alarm {Code} on Machine {M} occurred {N}× in 7 days — consider PM", - alarmCode, machineId, recentCount); + LogSanitizer.Sanitize(alarmCode), machineId, recentCount); // In production: trigger a MaintenanceWorkflow or alert } } diff --git a/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs b/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs index 1d43cc7..7eebf15 100644 --- a/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs +++ b/src/knowledge-base/KnowledgeBaseService/Services/IssueReportingService.cs @@ -3,6 +3,7 @@ using KnowledgeBaseService.Domain; using XtsContracts.Dtos; using System.Text.Json; +using XtsContracts; namespace KnowledgeBaseService.Services; @@ -82,14 +83,16 @@ INSERT INTO issue_reports _logger.LogInformation( "Issue report submitted: {Nr} Category={Cat} Severity={Sev} Machine={M}", - report.ReportNumber, report.Category, report.Severity, report.MachineId); + LogSanitizer.Sanitize(report.ReportNumber), LogSanitizer.Sanitize(report.Category), + LogSanitizer.Sanitize(report.Severity), report.MachineId); // Safety severity: immediate log escalation if (report.Severity == "MustStop") { _logger.LogCritical( "SAFETY STOP REQUESTED by {Op} at Machine {M}: {Desc}", - report.OperatorName, report.MachineId, report.Description); + LogSanitizer.Sanitize(report.OperatorName), report.MachineId, + LogSanitizer.Sanitize(report.Description)); } return report; diff --git a/src/maintenance/MaintenanceService/Services/CmmsService.cs b/src/maintenance/MaintenanceService/Services/CmmsService.cs index 7fc5520..5277ac6 100644 --- a/src/maintenance/MaintenanceService/Services/CmmsService.cs +++ b/src/maintenance/MaintenanceService/Services/CmmsService.cs @@ -1,3 +1,4 @@ +using XtsContracts; using Dapper; using Npgsql; using MaintenanceService.Domain; @@ -56,7 +57,8 @@ INSERT INTO maintenance_work_orders _logger.LogInformation( "Maintenance WO created: {WO}, Machine={M}, Type={T}", - wo.WorkOrderNumber, wo.MachineId, wo.MaintenanceType); + LogSanitizer.Sanitize(wo.WorkOrderNumber), wo.MachineId, + LogSanitizer.Sanitize(wo.MaintenanceType)); return wo; } diff --git a/src/mes/MesService/Services/QualityService.cs b/src/mes/MesService/Services/QualityService.cs index 9d37382..79af9af 100644 --- a/src/mes/MesService/Services/QualityService.cs +++ b/src/mes/MesService/Services/QualityService.cs @@ -2,6 +2,7 @@ using Npgsql; using MesService.Domain; using XtsContracts.Dtos; +using XtsContracts; namespace MesService.Services; @@ -61,7 +62,7 @@ INSERT INTO non_conformance_reports @DefectCategory, @DefectDescription, @Severity, @Status, @CreatedAt) """, ncr); - _logger.LogWarning("NCR created: {NCR} for Part {Part}", ncr.NcrNumber, partTracking); + _logger.LogWarning("NCR created: {NCR} for Part {Part}", LogSanitizer.Sanitize(ncr.NcrNumber), LogSanitizer.Sanitize(partTracking)); return ncr; } diff --git a/src/mes/MesService/Services/RecipeService.cs b/src/mes/MesService/Services/RecipeService.cs index 16f8456..b37ed86 100644 --- a/src/mes/MesService/Services/RecipeService.cs +++ b/src/mes/MesService/Services/RecipeService.cs @@ -3,6 +3,7 @@ using Npgsql; using MesService.Domain; using XtsContracts.Dtos; +using XtsContracts; namespace MesService.Services; @@ -68,7 +69,7 @@ INSERT INTO recipes recipe.CreatedAt }); - _logger.LogInformation("Recipe created: {Product} rev {Rev}", recipe.ProductId, recipe.Revision); + _logger.LogInformation("Recipe created: {Product} rev {Rev}", LogSanitizer.Sanitize(recipe.ProductId), LogSanitizer.Sanitize(recipe.Revision)); return recipe; } @@ -87,7 +88,7 @@ await conn.ExecuteAsync(""" UPDATE recipes SET status = 'Active', approved_by = @By, approved_at = @At WHERE id = @Id """, new { Id = id, By = approvedBy, At = DateTime.UtcNow }); - _logger.LogInformation("Recipe {Id} approved by {By}", id, approvedBy); + _logger.LogInformation("Recipe {Id} approved by {By}", id, LogSanitizer.Sanitize(approvedBy)); } private static Recipe MapRow(dynamic row) => new() diff --git a/src/shared/Contracts/Logging/LogSanitizer.cs b/src/shared/Contracts/Logging/LogSanitizer.cs new file mode 100644 index 0000000..01e300e --- /dev/null +++ b/src/shared/Contracts/Logging/LogSanitizer.cs @@ -0,0 +1,15 @@ +namespace XtsContracts; + +/// +/// Sanitizes user-provided values before they are written to structured logs, +/// preventing log-injection via embedded newlines or control characters. +/// +public static class LogSanitizer +{ + /// Strips CR, LF, and TAB characters from a user-supplied string. + public static string Sanitize(string? value) => + value == null ? string.Empty + : value.Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace("\t", string.Empty); +} diff --git a/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs b/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs index 51c4ac3..7fc74bf 100644 --- a/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs +++ b/src/temporal/orchestration-worker/Workflows/ProductionOrderWorkflow.cs @@ -1,5 +1,6 @@ using Temporalio.Workflows; using XtsContracts.Workflows; +using XtsContracts; using OrchestrationWorker.Activities; namespace OrchestrationWorker.Workflows; @@ -109,7 +110,7 @@ public Task SignalPauseAsync(string reason, string requestedBy) { _paused = true; _pauseReason = reason; - Workflow.Logger.LogInformation("WO paused by {Op}: {Reason}", requestedBy, reason); + Workflow.Logger.LogInformation("WO paused by {Op}: {Reason}", LogSanitizer.Sanitize(requestedBy), LogSanitizer.Sanitize(reason)); return Task.CompletedTask; } @@ -118,7 +119,7 @@ public Task SignalResumeAsync(string authorizedBy) { _paused = false; _pauseReason = null; - Workflow.Logger.LogInformation("WO resumed by {Op}", authorizedBy); + Workflow.Logger.LogInformation("WO resumed by {Op}", LogSanitizer.Sanitize(authorizedBy)); return Task.CompletedTask; } @@ -127,7 +128,7 @@ public Task SignalCancelAsync(string reason, string authorizedBy) { _cancelled = true; _cancelledBy = authorizedBy; - Workflow.Logger.LogWarning("WO cancelled by {Op}: {Reason}", authorizedBy, reason); + Workflow.Logger.LogWarning("WO cancelled by {Op}: {Reason}", LogSanitizer.Sanitize(authorizedBy), LogSanitizer.Sanitize(reason)); return Task.CompletedTask; } From 3d8702d2d88a3ba3a6077ee2d27700ae2344c0b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:43:26 +0000 Subject: [PATCH 10/10] feat: embed web HMIs in desktop shell --- AGENTS.md | 1 + App.xaml | 7 +++ MainWindow.xaml | 48 +++++++++++++++++++++ MainWindow.xaml.cs | 46 +++++++++++++++++++- README.md | 15 +++++++ ViewModels/EmbeddedWebHmiScreenViewModel.cs | 19 ++++++++ ViewModels/MainViewModel.cs | 16 ++++++- XTSPrimeMoverProject.csproj | 1 + docs/ARCHITECTURE.md | 1 + 9 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 ViewModels/EmbeddedWebHmiScreenViewModel.cs diff --git a/AGENTS.md b/AGENTS.md index df1dcd1..7a63778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ This file is the persistent repo-level handoff context for future Copilot sessio - Machine tab station cards enhanced with task-level visuals + tiny animated processing glyphs - Gateway mode indicator in HMI (Local vs Remote TwinCAT mock) - Auto-scrolling execution logger panel and speed control slider +- Embedded WebView2 tab for prime mover and machine web HMIs ## Core Runtime Files - Engine: `Services/XTSSimulationEngine.cs` diff --git a/App.xaml b/App.xaml index 233a39e..ba3c0d9 100644 --- a/App.xaml +++ b/App.xaml @@ -10,5 +10,12 @@ 40 + + + http://localhost:3000 + http://localhost:3001/?machineId=0 + http://localhost:3002/?machineId=1 + http://localhost:3003/?machineId=2 + http://localhost:3004/?machineId=3 diff --git a/MainWindow.xaml b/MainWindow.xaml index 6f0a056..3b39f1a 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:XTSPrimeMoverProject" + xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf" mc:Ignorable="d" Title="Beckhoff XTS Prime Mover System" Height="900" Width="1400" Background="#1E1E1E"> @@ -1065,6 +1066,53 @@ ContentTemplate="{StaticResource MachineDetailTemplate}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 8a56527..a79027f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.Globalization; using System.Text; @@ -78,7 +79,11 @@ public MainWindow() dataGateway = localGateway; } - var viewModel = new MainViewModel(machineGateway, dataGateway, gatewayModeStatus); + var viewModel = new MainViewModel( + machineGateway, + dataGateway, + gatewayModeStatus, + CreateEmbeddedWebHmiScreens()); DataContext = viewModel; viewModel.ExecutionLogs.CollectionChanged += OnExecutionLogsCollectionChanged; @@ -150,6 +155,45 @@ private static string ReadAppStringSetting(string key, string defaultValue) return defaultValue; } + + private static IReadOnlyList CreateEmbeddedWebHmiScreens() + { + var screens = new List(); + + AddWebHmiScreen( + screens, + "Prime Mover Web HMI", + "Embedded browser mirror of the prime mover web control room.", + ReadAppStringSetting( + "PrimeMoverHmiUrl", + Environment.GetEnvironmentVariable("XTS_PRIME_MOVER_HMI_URL") ?? "http://localhost:3000")); + + for (int machineId = 0; machineId < 4; machineId++) + { + AddWebHmiScreen( + screens, + $"Machine M{machineId} Web HMI", + $"Embedded browser mirror of the machine M{machineId} web HMI.", + ReadAppStringSetting( + $"Machine{machineId}HmiUrl", + Environment.GetEnvironmentVariable($"XTS_MACHINE_{machineId}_HMI_URL") + ?? $"http://localhost:{3001 + machineId}/?machineId={machineId}")); + } + + return screens; + } + + private static void AddWebHmiScreen( + ICollection screens, + string displayName, + string description, + string url) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var sourceUri)) + { + screens.Add(new EmbeddedWebHmiScreenViewModel(displayName, description, sourceUri)); + } + } } public class MoverPositionConverter : IMultiValueConverter diff --git a/README.md b/README.md index 9fd6373..951a306 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,21 @@ Pass when all are true: Fail if any service is unavailable, state is not progressing, or workflow retries/failures persist without recovery. +## Embedded Web HMIs Inside WPF + +The desktop WPF HMI now includes an **Embedded Web HMIs** tab that hosts the existing React HMIs inside the desktop shell through WebView2. + +- Prime mover web HMI default: `http://localhost:3000` +- Machine web HMI defaults: `http://localhost:3001` through `http://localhost:3004` +- Override launch targets with environment variables: + - `XTS_PRIME_MOVER_HMI_URL` + - `XTS_MACHINE_0_HMI_URL` + - `XTS_MACHINE_1_HMI_URL` + - `XTS_MACHINE_2_HMI_URL` + - `XTS_MACHINE_3_HMI_URL` + +This keeps the backend shared while allowing the same web screens to run both in a browser and inside the .NET WPF operator client. + ### 7) Stop and clean up ```bash diff --git a/ViewModels/EmbeddedWebHmiScreenViewModel.cs b/ViewModels/EmbeddedWebHmiScreenViewModel.cs new file mode 100644 index 0000000..2ee4ebf --- /dev/null +++ b/ViewModels/EmbeddedWebHmiScreenViewModel.cs @@ -0,0 +1,19 @@ +using System; + +namespace XTSPrimeMoverProject.ViewModels +{ + public sealed class EmbeddedWebHmiScreenViewModel + { + public EmbeddedWebHmiScreenViewModel(string displayName, string description, Uri sourceUri) + { + DisplayName = displayName; + Description = description; + SourceUri = sourceUri; + } + + public string DisplayName { get; } + public string Description { get; } + public Uri SourceUri { get; } + public string UrlDisplay => SourceUri.AbsoluteUri; + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 29cbd8c..f72f66f 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -45,6 +45,7 @@ public class MainViewModel : INotifyPropertyChanged public ObservableCollection DbTables { get; } public ObservableCollection OrchestrationSteps { get; } public ObservableCollection SafetyGates { get; } + public ObservableCollection EmbeddedWebHmiScreens { get; } private DataView _dbTableRowsView = CreateEmptyDbTableView(); public DataView DbTableRowsView @@ -195,7 +196,11 @@ public double SimulationSpeed public string SimulationSpeedText => $"Speed: {SimulationSpeed:F1}x"; - public MainViewModel(IMachineGatewayService machine, IDataGatewayService data, string gatewayModeStatus = "Machine Gateway: Local") + public MainViewModel( + IMachineGatewayService machine, + IDataGatewayService data, + string gatewayModeStatus = "Machine Gateway: Local", + IEnumerable? embeddedWebHmiScreens = null) { _machine = machine ?? throw new ArgumentNullException(nameof(machine)); _data = data ?? throw new ArgumentNullException(nameof(data)); @@ -229,8 +234,17 @@ public MainViewModel(IMachineGatewayService machine, IDataGatewayService data, s DbTables = new ObservableCollection(); OrchestrationSteps = new ObservableCollection(); SafetyGates = new ObservableCollection(); + EmbeddedWebHmiScreens = new ObservableCollection(); DbTableRowsView = CreateEmptyDbTableView(); + if (embeddedWebHmiScreens is not null) + { + foreach (var screen in embeddedWebHmiScreens) + { + EmbeddedWebHmiScreens.Add(screen); + } + } + StartCommand = new RelayCommand(Start, () => !IsRunning); StopCommand = new RelayCommand(Stop, () => IsRunning); ResetCommand = new RelayCommand(Reset); diff --git a/XTSPrimeMoverProject.csproj b/XTSPrimeMoverProject.csproj index bf8ff23..e8fc6d5 100644 --- a/XTSPrimeMoverProject.csproj +++ b/XTSPrimeMoverProject.csproj @@ -20,6 +20,7 @@ + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 13a1e1d..d892c4e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -90,6 +90,7 @@ SQLite persistence for events/results/alarms/errors/snapshots. - robot canvas overlays with moving glyphs, animated dashed transfer lines, and direction arrowheads - entry/load and exit/unload zones + blinkers - right-side Line HMI diagnostics sections +- embedded WebView2 launcher for prime mover + machine React HMIs - enhanced station cards on machine tabs with live task-centric visuals and activity glyph animations - bottom execution logger with auto-scroll to latest event