diff --git a/infra/mssql/seed-config.sql b/infra/mssql/seed-config.sql new file mode 100644 index 0000000..7632c7d --- /dev/null +++ b/infra/mssql/seed-config.sql @@ -0,0 +1,195 @@ +-- ScadaLink design-data seed. +-- Auto-generated by infra/tools/dump_seed.py against ScadaLinkConfig. +-- Replays the design-time configuration (templates, scripts, +-- data connections, external systems). Idempotent: deletes +-- existing rows in the covered tables before inserting. +-- +-- Excluded: Sites (seed via docker/seed-sites.sh), Instances, +-- InstanceConnectionBindings, notifications, SMTP, API keys, +-- areas, LDAP mappings. + +SET NOCOUNT ON; +SET XACT_ABORT ON; +SET QUOTED_IDENTIFIER ON; +BEGIN TRAN; + +-- Wipe existing design + dependent rows so the seed is idempotent. +-- Order matters: dependents first. +DELETE FROM DeployedConfigSnapshots; +DELETE FROM DeploymentRecords; +DELETE FROM InstanceAlarmOverrides; +DELETE FROM InstanceAttributeOverrides; +DELETE FROM InstanceConnectionBindings; +DELETE FROM Instances; +DELETE FROM ExternalSystemMethods; +DELETE FROM ExternalSystemDefinitions; +DELETE FROM DataConnections; +DELETE FROM SharedScripts; +DELETE FROM TemplateCompositions; +UPDATE TemplateAlarms SET OnTriggerScriptId = NULL; +DELETE FROM TemplateAlarms; +DELETE FROM TemplateScripts; +DELETE FROM TemplateAttributes; +UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL; +DELETE FROM Templates; +UPDATE TemplateFolders SET ParentFolderId = NULL; +DELETE FROM TemplateFolders; + +-- TemplateFolders (1 rows) +SET IDENTITY_INSERT [TemplateFolders] ON; +INSERT INTO [TemplateFolders] ([Id], [Name], [ParentFolderId], [SortOrder]) VALUES (1002, N'Test', NULL, 0); +SET IDENTITY_INSERT [TemplateFolders] OFF; + +-- Templates (18 rows) +SET IDENTITY_INSERT [Templates] ON; +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1, N'Base Device', N'Root template for all devices', NULL, NULL, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2, N'Pump', N'Centrifugal pump template', 1, NULL, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (3, N'Sensor Module', N'Reusable sensor feature module', NULL, 1002, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (4, N'Motor Controller', N'Motor with OPC UA tags from test server', NULL, 1002, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (5, N'Variable Speed Motor', N'VFD motor extending Motor Controller with sensor composition', 4, NULL, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1002, N'Tank Monitor', N'Tank level and temperature monitoring module', NULL, NULL, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2003, N'Pump.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 1); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2004, N'Variable Speed Motor.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 2); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2005, N'Motor Controller.CoolingTank', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1002); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2006, N'Motor Controller.CoolingTank2', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1003); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2007, N'aaa', NULL, 3, NULL, 0, NULL); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2008, N'Pump.AlarmSensor', N'Reusable sensor feature module', 3, NULL, 1, 1004); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2012, N'Tank Monitor.DrivePump', N'Centrifugal pump template', 2, NULL, 1, 1008); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2013, N'Tank Monitor.DrivePump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1009); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2014, N'Tank Monitor.DrivePump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1010); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2018, N'Motor Controller.Pump', N'Centrifugal pump template', 2, NULL, 1, 1014); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2019, N'Motor Controller.Pump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1015); +INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2020, N'Motor Controller.Pump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1016); +SET IDENTITY_INSERT [Templates] OFF; + +-- TemplateAttributes (48 rows) +SET IDENTITY_INSERT [TemplateAttributes] ON; +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'Status', N'Offline', N'String', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2, 1, N'Temperature', N'0.0', N'Double', 0, NULL, N'ns=3;s=Temperature', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (9, 3, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (10, 3, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (11, 5, N'MaxRPM', N'3600', N'Double', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (12, 5, N'MinRPM', N'0', N'Double', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'Weather', N'Unknown', N'String', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'Greeting', N'', N'String', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'Goodbye', N'', N'String', 0, NULL, NULL, 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1005, 1002, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1006, 1002, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1007, 1002, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1008, 1002, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2009, 4, N'TestBool', NULL, N'Boolean', 0, NULL, N'ns=3;s=TestChildObject.TestBool', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2010, 4, N'TestInt', NULL, N'Int32', 0, NULL, N'ns=3;s=TestChildObject.TestInt', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2011, 4, N'TestFloat', NULL, N'Float', 0, NULL, N'ns=3;s=TestChildObject.TestFloat', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2012, 4, N'TestDouble', NULL, N'Double', 0, NULL, N'ns=3;s=TestChildObject.TestDouble', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2013, 4, N'TestString', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestString', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2014, 4, N'TestDateTime', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTime', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2015, 4, N'TestBoolArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestBoolArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2016, 4, N'TestDateTimeArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTimeArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2017, 4, N'TestDoubleArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDoubleArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2018, 4, N'TestFloatArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestFloatArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2019, 4, N'TestIntArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestIntArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2020, 4, N'TestStringArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestStringArray', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2021, 4, N'ScanTime', NULL, N'String', 0, NULL, N'ns=3;s=DevAppEngine.Scheduler.ScanTime', 0, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3009, 2003, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3010, 2003, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3011, 2004, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3012, 2004, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3013, 2005, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3014, 2005, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3015, 2005, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3016, 2005, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3017, 2006, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3018, 2006, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3019, 2006, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3020, 2006, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3021, 2008, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3022, 2008, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3025, 2013, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3026, 2013, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3027, 2014, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3028, 2014, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3033, 2019, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3034, 2019, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3035, 2020, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0); +INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3036, 2020, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0); +SET IDENTITY_INSERT [TemplateAttributes] OFF; + +-- TemplateScripts (12 rows) +SET IDENTITY_INSERT [TemplateScripts] ON; +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'CheckTemp', 0, N'var temp = Instance.GetAttribute("Temperature"); +if (temp.Value > 90.0) { + Instance.SetAttribute("Status", "HighTemp"); +}', N'ValueChange', NULL, NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'TestExternalSystem', 0, N'var parms = new Dictionary { ["a"] = 2, ["b"] = 3 }; var result = await ExternalSystem.Call("Test REST API", "Add", parms); Instance.SetAttribute("Status", "API result: " + result.Response.result);', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'TestDatabaseQuery', 0, N'var conn = await Database.Connection("Machine Data DB"); var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM TagHistory"; var count = await cmd.ExecuteScalarAsync(); conn.Dispose(); Instance.SetAttribute("Status", "DB: " + count + " rows");', N'Interval', N'{"intervalMs":60000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'UpdateWeather', 0, N'var weather = await Scripts.CallShared("GetWeather"); Instance.SetAttribute("Weather", weather?.ToString() ?? "Unknown");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1005, 4, N'UpdateGreeting', 0, N'var parms = new Dictionary { ["name"] = "BOB" }; var greeting = await Scripts.CallShared("Greet", parms); Instance.SetAttribute("Greeting", greeting?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1007, 4, N'SayGoodbye', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Goodbye {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1008, 4, N'UpdateGoodbye', 0, N'var parms = new Dictionary { ["Name"] = "Bob" }; var result = await Instance.CallScript("SayGoodbye", parms); Instance.SetAttribute("Goodbye", result?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1009, 4, N'Hello', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Hello {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1010, 4, N'SendEmailAlert', 0, N'await Notify.To("Engineering Alerts").Send("Motor Status Update", "Motor check-in at " + DateTimeOffset.UtcNow.ToString("HH:mm:ss") + " UTC");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1011, 1002, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 0, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1012, 2005, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0); +INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1013, 2006, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0); +SET IDENTITY_INSERT [TemplateScripts] OFF; + +-- TemplateAlarms (4 rows) +SET IDENTITY_INSERT [TemplateAlarms] ON; +INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1, 1, N'HighTemp', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Temperature","high":95.0}', NULL); +INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1002, 1002, N'HighLevel', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Level","high":80}', NULL); +INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1003, 2, N'RatePump', NULL, 750, 0, N'RateOfChange', N'{"attributeName":"AlarmSensor.SensorReading","thresholdPerSecond":25,"windowSeconds":2,"direction":"falling"}', NULL); +INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1004, 2, N'TempLevels', NULL, 500, 0, N'HiLo', N'{"attributeName":"AlarmSensor.SensorReading","loLo":-10,"lo":5,"hi":80,"hiHi":100,"loLoPriority":900,"loPriority":600,"hiPriority":600,"hiHiPriority":900,"hiDeadband":3,"hiHiDeadband":5,"hiMessage":"Temperature high — investigate","hiHiMessage":"CRITICAL: shut down immediately"}', NULL); +SET IDENTITY_INSERT [TemplateAlarms] OFF; + +-- TemplateCompositions (11 rows) +SET IDENTITY_INSERT [TemplateCompositions] ON; +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1, 2, 2003, N'TempSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (2, 5, 2004, N'TempSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1002, 4, 2005, N'CoolingTank'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1003, 4, 2006, N'CoolingTank2'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1004, 2, 2008, N'AlarmSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1008, 1002, 2012, N'DrivePump'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1009, 2012, 2013, N'TempSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1010, 2012, 2014, N'AlarmSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1014, 4, 2018, N'Pump'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1015, 2018, 2019, N'TempSensor'); +INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1016, 2018, 2020, N'AlarmSensor'); +SET IDENTITY_INSERT [TemplateCompositions] OFF; + +-- SharedScripts (2 rows) +SET IDENTITY_INSERT [SharedScripts] ON; +INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, N'GetWeather', N'var conditions = new[] +{ + "Sunny", + "Cloudy", + "Rainy", + "Stormy", + "Windy", + "Foggy", + "Snowy", + "Clear" +}; +var temps = new Random().Next(-10, 40); +var condition = conditions[new Random().Next(conditions.Length)]; +return $"{condition}, {temps}°C";', NULL, N'{"type":"string"}'); +INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (2, N'Greet', N'var name = (string)(Parameters?["name"] ?? "World"); return $"Hello, {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', N'{"type":"string"}'); +SET IDENTITY_INSERT [SharedScripts] OFF; + +-- DataConnections (3 rows) +SET IDENTITY_INSERT [DataConnections] ON; +INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (1, N'OPC PLC Simulator', N'OpcUa', N'{"endpointUrl":"opc.tcp://scadalink-opcua:50000","securityMode":"none","autoAcceptUntrustedCerts":true,"sessionTimeoutMs":60000,"operationTimeoutMs":15000,"publishingIntervalMs":1000,"samplingIntervalMs":1000,"queueSize":10,"keepAliveCount":10,"lifetimeCount":30,"maxNotificationsPerPublish":100,"discardOldest":true,"subscriptionPriority":0,"subscriptionDisplayName":"ScadaLink","timestampsToReturn":"source","deadband":null,"userIdentity":null,"heartbeat":null}', 1, NULL, 3); +INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3014, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 2, NULL, 3); +INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3015, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 3, NULL, 3); +SET IDENTITY_INSERT [DataConnections] OFF; + +-- ExternalSystemDefinitions (1 rows) +SET IDENTITY_INSERT [ExternalSystemDefinitions] ON; +INSERT INTO [ExternalSystemDefinitions] ([Id], [Name], [EndpointUrl], [AuthType], [AuthConfiguration], [MaxRetries], [RetryDelay]) VALUES (1, N'Test REST API', N'http://scadalink-restapi:5200', N'ApiKey', N'scadalink-test-key-1', 0, '00:00:00.000000'); +SET IDENTITY_INSERT [ExternalSystemDefinitions] OFF; + +-- ExternalSystemMethods (1 rows) +SET IDENTITY_INSERT [ExternalSystemMethods] ON; +INSERT INTO [ExternalSystemMethods] ([Id], [ExternalSystemDefinitionId], [Name], [HttpMethod], [Path], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, 1, N'Add', N'POST', N'/api/Add', N'{"a":"number","b":"number"}', N'{"result":"number"}'); +SET IDENTITY_INSERT [ExternalSystemMethods] OFF; + +COMMIT; diff --git a/infra/opcua/nodes.json b/infra/opcua/nodes.json index 185cdfb..e4c34d2 100644 --- a/infra/opcua/nodes.json +++ b/infra/opcua/nodes.json @@ -170,6 +170,158 @@ ] } ] + }, + { + "Folder": "DevAppEngine", + "NodeList": [], + "FolderList": [ + { + "Folder": "Scheduler", + "NodeList": [ + { + "NodeId": "DevAppEngine.Scheduler.ScanTime", + "Name": "ScanTime", + "DataType": "DateTime", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Current scan time for DevAppEngine" + } + ] + } + ] + }, + { + "Folder": "Sensor", + "NodeList": [ + { + "NodeId": "Sensor.Reading", + "Name": "Reading", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Generic sensor reading" + } + ] + }, + { + "Folder": "Misc", + "NodeList": [ + { + "NodeId": "Temperature", + "Name": "Temperature", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Standalone Temperature tag (Base Device default)" + } + ] + }, + { + "Folder": "TestChildObject", + "NodeList": [ + { + "NodeId": "TestChildObject.TestBool", + "Name": "TestBool", + "DataType": "Boolean", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar Boolean" + }, + { + "NodeId": "TestChildObject.TestBoolArray", + "Name": "TestBoolArray", + "DataType": "Boolean", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test Boolean array" + }, + { + "NodeId": "TestChildObject.TestDateTime", + "Name": "TestDateTime", + "DataType": "DateTime", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar DateTime" + }, + { + "NodeId": "TestChildObject.TestDateTimeArray", + "Name": "TestDateTimeArray", + "DataType": "DateTime", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test DateTime array" + }, + { + "NodeId": "TestChildObject.TestDouble", + "Name": "TestDouble", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar Double" + }, + { + "NodeId": "TestChildObject.TestDoubleArray", + "Name": "TestDoubleArray", + "DataType": "Double", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test Double array" + }, + { + "NodeId": "TestChildObject.TestFloat", + "Name": "TestFloat", + "DataType": "Float", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar Float" + }, + { + "NodeId": "TestChildObject.TestFloatArray", + "Name": "TestFloatArray", + "DataType": "Float", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test Float array" + }, + { + "NodeId": "TestChildObject.TestInt", + "Name": "TestInt", + "DataType": "Int32", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar Int32" + }, + { + "NodeId": "TestChildObject.TestIntArray", + "Name": "TestIntArray", + "DataType": "Int32", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test Int32 array" + }, + { + "NodeId": "TestChildObject.TestString", + "Name": "TestString", + "DataType": "String", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test scalar String" + }, + { + "NodeId": "TestChildObject.TestStringArray", + "Name": "TestStringArray", + "DataType": "String", + "ValueRank": 1, + "ArrayDimensions": [4], + "AccessLevel": "CurrentReadOrWrite", + "Description": "Test String array" + } + ] } ] } diff --git a/infra/reseed.sh b/infra/reseed.sh new file mode 100755 index 0000000..34fb0c1 --- /dev/null +++ b/infra/reseed.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Full reseed of the ScadaLink test cluster. +# +# Tears down infra + app containers, drops the MSSQL volume, brings +# everything back, lets EF Core migrations create the schema, replays +# infra/mssql/seed-config.sql for templates/scripts/data-connections, and +# re-seeds sites via docker/seed-sites.sh. +# +# Usage: +# infra/reseed.sh Full reseed (default seed file) +# infra/reseed.sh --seed PATH Replay a different seed SQL +# infra/reseed.sh --skip-teardown Replay seed against running stack +# +# Prerequisites: +# - Docker / OrbStack running +# - Python 3 with pymssql (used by infra/tools/mssql_tool.py + dump_seed.py) +# - Built scadalink:latest image (docker/build.sh — deploy.sh runs it) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SEED_FILE="$SCRIPT_DIR/mssql/seed-config.sql" +SKIP_TEARDOWN=false +MGMT_URL="http://localhost:9000" + +while [ $# -gt 0 ]; do + case "$1" in + --seed) + SEED_FILE="$2" + shift 2 + ;; + --skip-teardown) + SKIP_TEARDOWN=true + shift + ;; + -h|--help) + sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [ ! -f "$SEED_FILE" ]; then + echo "Seed file not found: $SEED_FILE" >&2 + exit 1 +fi + +echo "=== ScadaLink Reseed ===" +echo "Seed file: $SEED_FILE" +echo "" + +if ! $SKIP_TEARDOWN; then + echo "--- Stage 1/6: tear down application containers ---" + "$PROJECT_ROOT/docker/teardown.sh" + + echo "" + echo "--- Stage 2/6: wipe site SQLite state ---" + shopt -s nullglob + for d in "$PROJECT_ROOT"/docker/site-*/data; do + rm -rf "$d"/* + echo " cleared $d" + done + shopt -u nullglob + + echo "" + echo "--- Stage 3/6: tear down infra (drops MSSQL volume) ---" + (cd "$SCRIPT_DIR" && docker compose down -v) + + echo "" + echo "--- Stage 4/6: bring infra back up ---" + (cd "$SCRIPT_DIR" && docker compose up -d) + + echo " Waiting for MSSQL to accept connections..." + until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1#' -C -Q "SELECT 1" >/dev/null 2>&1; do + sleep 2 + done + echo " MSSQL ready." + + echo " Waiting for setup.sql to create ScadaLinkConfig..." + until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1#' -C \ + -Q "IF DB_ID('ScadaLinkConfig') IS NULL THROW 50000, 'not ready', 1;" \ + >/dev/null 2>&1; do + sleep 2 + done + echo " ScadaLinkConfig present." + + echo "" + echo "--- Stage 5/6: deploy central + site nodes ---" + "$PROJECT_ROOT/docker/deploy.sh" +fi + +echo "" +echo "--- Stage 6a/6: wait for central cluster /health/ready ---" +until curl -fs "$MGMT_URL/health/ready" >/dev/null 2>&1; do + sleep 2 +done +echo " Central cluster ready (EF Core migrations applied)." + +echo "" +echo "--- Stage 6b/6: seed sites (CLI) ---" +# Sites must exist before the design seed: DataConnections.SiteId FKs to Sites. +"$PROJECT_ROOT/docker/seed-sites.sh" + +echo "" +echo "--- Stage 6c/6: replay seed SQL ---" +docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1#' -C -d ScadaLinkConfig -b < "$SEED_FILE" +echo " Seed replayed." + +echo "" +echo "=== Reseed complete ===" +echo "" +echo "Verify:" +echo " $PROJECT_ROOT/src/ScadaLink.CLI/bin/Debug/net*/ScadaLink.CLI --url $MGMT_URL --username multi-role --password password template list" +echo "" +echo "To refresh the seed file from the current DB state:" +echo " python3 $SCRIPT_DIR/tools/dump_seed.py --output $SEED_FILE" diff --git a/infra/teardown.sh b/infra/teardown.sh index 7ba258e..a5d3ae4 100755 --- a/infra/teardown.sh +++ b/infra/teardown.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash # Tear down ScadaLink test infrastructure. # +# Drops the MSSQL data volume by default, so the ScadaLinkConfig DB +# (templates, scripts, data connections, etc.) is wiped. Use +# infra/reseed.sh afterwards to restore the design state from +# infra/mssql/seed-config.sql. +# # Usage: # ./teardown.sh Stop containers and delete the SQL data volume # ./teardown.sh --images Also remove downloaded Docker images @@ -44,4 +49,9 @@ fi echo "" echo "Teardown complete." -echo "To start fresh: docker compose up -d && python tools/mssql_tool.py setup --script mssql/setup.sql" +echo "" +echo "To restore the full test cluster (infra + app + design seed + sites):" +echo " infra/reseed.sh" +echo "" +echo "To start only infra (no app, no seed):" +echo " cd infra && docker compose up -d" diff --git a/infra/tools/dump_seed.py b/infra/tools/dump_seed.py new file mode 100755 index 0000000..5fd75bb --- /dev/null +++ b/infra/tools/dump_seed.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Dump design tables from ScadaLinkConfig to a replayable SQL seed file. + +Usage: + python3 infra/tools/dump_seed.py --output infra/mssql/seed-config.sql + +Tables covered (insert order; reverse for delete): + TemplateFolders, Templates, TemplateAttributes, TemplateScripts, + TemplateAlarms, TemplateCompositions, SharedScripts, DataConnections, + ExternalSystemDefinitions, ExternalSystemMethods + +Excluded by design (per-environment, not design-time): Sites (seeded via +seed-sites.sh), Instances + InstanceConnectionBindings + InstanceOverrides, +NotificationLists/Recipients, SmtpConfigurations, ApiKeys, Areas, +SiteScopeRules, LdapGroupMappings, DataProtectionKeys, audit, deployment. +""" + +import argparse +import datetime +import sys + +import pymssql + + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 1433 +DEFAULT_USER = "sa" +DEFAULT_PASSWORD = "ScadaLink_Dev1#" +DEFAULT_DATABASE = "ScadaLinkConfig" + +INSERT_ORDER = [ + "TemplateFolders", + "Templates", + "TemplateAttributes", + "TemplateScripts", + "TemplateAlarms", + "TemplateCompositions", + "SharedScripts", + "DataConnections", + "ExternalSystemDefinitions", + "ExternalSystemMethods", +] + +# Identity columns get IDENTITY_INSERT wrapped around inserts and are kept in +# the column list. All listed tables happen to use Id as their identity. +IDENTITY_TABLES = set(INSERT_ORDER) + +# Templates has self-FK Templates.ParentTemplateId; emit a single batch that +# inserts shallow rows first then deeper ones. pymssql returns rows in Id order +# from our ORDER BY, which matches insertion order for this schema (parent Id +# is always less than child Id in the live data). + + +def quote(value): + if value is None: + return "NULL" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, (bytes, bytearray)): + return "0x" + value.hex() + if isinstance(value, datetime.datetime): + return "'" + value.isoformat(sep=" ", timespec="microseconds") + "'" + if isinstance(value, datetime.date): + return "'" + value.isoformat() + "'" + if isinstance(value, datetime.time): + return "'" + value.isoformat(timespec="microseconds") + "'" + if isinstance(value, datetime.timedelta): + total = value.total_seconds() + hours, rem = divmod(int(total), 3600) + minutes, seconds = divmod(rem, 60) + micros = value.microseconds + return "'{:02d}:{:02d}:{:02d}.{:06d}'".format(hours, minutes, seconds, micros) + text = str(value).replace("'", "''") + return "N'" + text + "'" + + +def get_columns(cursor, table): + cursor.execute( + """ + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = %s + ORDER BY ORDINAL_POSITION + """, + (table,), + ) + return [row[0] for row in cursor.fetchall()] + + +def dump(args): + conn = pymssql.connect( + server=args.host, + port=args.port, + user=args.user, + password=args.password, + database=args.database, + ) + cursor = conn.cursor() + + out = [] + out.append("-- ScadaLink design-data seed.") + out.append("-- Auto-generated by infra/tools/dump_seed.py against " + args.database + ".") + out.append("-- Replays the design-time configuration (templates, scripts,") + out.append("-- data connections, external systems). Idempotent: deletes") + out.append("-- existing rows in the covered tables before inserting.") + out.append("--") + out.append("-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,") + out.append("-- InstanceConnectionBindings, notifications, SMTP, API keys,") + out.append("-- areas, LDAP mappings.") + out.append("") + out.append("SET NOCOUNT ON;") + out.append("SET XACT_ABORT ON;") + # sqlcmd defaults QUOTED_IDENTIFIER OFF; EF Core's filtered indexes + # and computed columns require ON, so force it here. + out.append("SET QUOTED_IDENTIFIER ON;") + out.append("BEGIN TRAN;") + out.append("") + + # Wipe in reverse FK order. Beyond the design tables themselves, we also + # clear instance + deployment rows because they FK to Templates and + # DataConnections; without this, an idempotent replay against a populated + # DB fails on the FK to DataConnections. On a fresh reseed (after + # teardown.sh) these tables are already empty so the DELETEs are no-ops. + out.append("-- Wipe existing design + dependent rows so the seed is idempotent.") + out.append("-- Order matters: dependents first.") + delete_order = [ + # Dependents on Instances / DataConnections / Sites. + "DeployedConfigSnapshots", + "DeploymentRecords", + "InstanceAlarmOverrides", + "InstanceAttributeOverrides", + "InstanceConnectionBindings", + "Instances", + # Design tables themselves. + "ExternalSystemMethods", + "ExternalSystemDefinitions", + "DataConnections", + "SharedScripts", + "TemplateCompositions", + # Alarms reference scripts via OnTriggerScriptId; null it first so we + # can delete scripts without FK violations. + "UPDATE TemplateAlarms SET OnTriggerScriptId = NULL", + "TemplateAlarms", + "TemplateScripts", + "TemplateAttributes", + # Templates is self-referential and references TemplateCompositions + # (OwnerCompositionId); null parent links first. + "UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL", + "Templates", + # Folders is self-referential too. + "UPDATE TemplateFolders SET ParentFolderId = NULL", + "TemplateFolders", + ] + for step in delete_order: + if step.startswith("UPDATE "): + out.append(step + ";") + else: + out.append("DELETE FROM " + step + ";") + out.append("") + + for table in INSERT_ORDER: + columns = get_columns(cursor, table) + if not columns: + print("Skipping {} (no columns found)".format(table), file=sys.stderr) + continue + + # Order by Id so self-referential rows insert in dependency order + # (in the live data, parent Id < child Id by construction). + order_clause = "ORDER BY Id" if "Id" in columns else "" + cursor.execute( + "SELECT [{}] FROM [{}] {}".format("], [".join(columns), table, order_clause) + ) + rows = cursor.fetchall() + + out.append("-- " + table + " (" + str(len(rows)) + " rows)") + if not rows: + continue + + col_list = ", ".join("[" + c + "]" for c in columns) + identity = table in IDENTITY_TABLES + if identity: + out.append("SET IDENTITY_INSERT [{}] ON;".format(table)) + for row in rows: + values = ", ".join(quote(v) for v in row) + out.append( + "INSERT INTO [{}] ({}) VALUES ({});".format(table, col_list, values) + ) + if identity: + out.append("SET IDENTITY_INSERT [{}] OFF;".format(table)) + out.append("") + + out.append("COMMIT;") + out.append("") + + sql = "\n".join(out) + with open(args.output, "w") as f: + f.write(sql) + + print("Wrote " + args.output + " (" + str(sum(1 for line in out if line.startswith('INSERT'))) + " inserts).") + + cursor.close() + conn.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--user", default=DEFAULT_USER) + parser.add_argument("--password", default=DEFAULT_PASSWORD) + parser.add_argument("--database", default=DEFAULT_DATABASE) + parser.add_argument("--output", required=True, help="Path to write seed SQL") + args = parser.parse_args() + dump(args) + + +if __name__ == "__main__": + main() diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index c94e13d..98ca90c 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -22,10 +22,10 @@ Sites @@ -41,10 +41,10 @@ Shared Scripts diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor similarity index 98% rename from src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor rename to src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor index e6dd7f4..2855a3e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor @@ -1,8 +1,8 @@ -@page "/design/smtp" +@page "/admin/smtp" @using ScadaLink.Security @using ScadaLink.Commons.Interfaces.Repositories @using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration -@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject INotificationRepository NotificationRepository @inject NavigationManager NavigationManager diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor index 414ee97..025b4f8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -3,8 +3,10 @@ @using ScadaLink.Security @using ScadaLink.Commons.Entities.InboundApi @using ScadaLink.Commons.Interfaces.Repositories +@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject IInboundApiRepository InboundApiRepository +@inject ScriptAnalysis.ScriptAnalysisService AnalysisService @inject NavigationManager NavigationManager
@@ -78,6 +80,7 @@ @@ -91,10 +94,92 @@
+
+ + @if (_showTestRun) + { +
+
+ Test Run +
+
+ Heads up: + runs the script as typed (unsaved edits included) against the supplied + Parameters. Route calls throw — cross-site + routing needs a deployed site reachable over the cluster transport. +
+
+
+ + +
+
+ + @if (_runResult != null) + { + @_runResult.DurationMs ms + } +
+ + @if (_runResult != null) + { + @if (_runResult.Success) + { +
+ +
@_runResult.ReturnValueJson
+
+ } + else + { +
+ +
@_runResult.Error
+ @if (_runResult.Markers is { Count: > 0 }) + { +
    + @foreach (var m in _runResult.Markers) + { +
  • Line @m.StartLineNumber, col @m.StartColumn: @m.Message @m.Code
  • + } +
+ } +
+ } + + @if (!string.IsNullOrEmpty(_runResult.ConsoleOutput)) + { +
+ +
@_runResult.ConsoleOutput
+
+ } + } +
+
+ } } @@ -114,6 +199,12 @@ private List _allKeys = new(); private HashSet _selectedKeyIds = new(); + private bool _showTestRun; + private bool _running; + private Dictionary _paramValues = new(); + private ScriptAnalysis.SandboxRunResult? _runResult; + private CancellationTokenSource? _runCts; + protected override async Task OnInitializedAsync() { try @@ -200,4 +291,53 @@ } private void GoBack() => NavigationManager.NavigateTo("/design/external-systems"); + + private void ToggleTestRunPanel() => _showTestRun = !_showTestRun; + + private async Task RunInSandboxAsync() + { + _runCts?.Cancel(); + _runCts = new CancellationTokenSource(); + _running = true; + _runResult = null; + StateHasChanged(); + + try + { + var jsonParams = _paramValues.ToDictionary( + kv => kv.Key, + kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value)); + var request = new ScriptAnalysis.SandboxRunRequest( + _script, jsonParams, TimeoutSeconds: _timeoutSeconds, + Kind: ScriptAnalysis.ScriptKind.InboundApi); + _runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token); + } + catch (OperationCanceledException) { /* superseded by next Run click */ } + catch (Exception ex) + { + _runResult = new ScriptAnalysis.SandboxRunResult( + Success: false, + ReturnValueJson: null, + ReturnTypeName: null, + ConsoleOutput: "", + Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}", + ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError, + DurationMs: 0, + Markers: null); + } + finally + { + _running = false; + StateHasChanged(); + } + } + + private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch + { + ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error", + ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation", + ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error", + ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout", + _ => "Error" + }; } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/DataConnectionForm.razor similarity index 96% rename from src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor rename to src/ScadaLink.CentralUI/Components/Pages/Design/DataConnectionForm.razor index 90d04fd..bd88a94 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/DataConnectionForm.razor @@ -1,7 +1,7 @@ -@page "/admin/connections/create" -@page "/admin/connections/{Id:int}/edit" -@page "/admin/data-connections/create" -@page "/admin/data-connections/{Id:int}/edit" +@page "/design/connections/create" +@page "/design/connections/{Id:int}/edit" +@page "/design/data-connections/create" +@page "/design/data-connections/{Id:int}/edit" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @@ -10,7 +10,7 @@ @using ScadaLink.Commons.Serialization @using ScadaLink.Commons.Validators @using ScadaLink.CentralUI.Components.Forms -@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject ISiteRepository SiteRepository @inject NavigationManager NavigationManager @@ -219,7 +219,7 @@ await SiteRepository.AddDataConnectionAsync(conn); } await SiteRepository.SaveChangesAsync(); - NavigationManager.NavigateTo("/admin/connections"); + NavigationManager.NavigateTo("/design/connections"); } catch (Exception ex) { @@ -237,5 +237,5 @@ _formFailoverRetryCount = 3; } - private void GoBack() => NavigationManager.NavigateTo("/admin/connections"); + private void GoBack() => NavigationManager.NavigateTo("/design/connections"); } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/DataConnections.razor similarity index 96% rename from src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor rename to src/ScadaLink.CentralUI/Components/Pages/Design/DataConnections.razor index 9a4a237..6888748 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/DataConnections.razor @@ -1,9 +1,9 @@ -@page "/admin/connections" -@page "/admin/data-connections" +@page "/design/connections" +@page "/design/data-connections" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories -@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject ISiteRepository SiteRepository @inject NavigationManager NavigationManager @inject IDialogService Dialog @@ -101,7 +101,7 @@ {
  • @@ -128,7 +128,7 @@ else { @@ -253,7 +253,7 @@ private void AddConnectionForSite(int siteId) { - NavigationManager.NavigateTo($"/admin/connections/create?siteId={siteId}"); + NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}"); } private void OnSearchChanged() diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor index cff7e4b..2079753 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor @@ -14,8 +14,6 @@

    Integration Definitions

    - Email configuration →
    @@ -67,15 +65,6 @@ Inbound API Methods @_apiMethods.Count - @if (_tab == "extsys") @@ -94,10 +83,6 @@ {
    @RenderInboundApiMethods()
    } - else if (_tab == "apikeys") - { -
    @RenderApiKeys()
    - } }
    @@ -122,14 +107,6 @@ ? _dbConnections : _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false); - // API Keys - private List _apiKeys = new(); - private string _apiKeySearch = ""; - private IEnumerable FilteredApiKeys => - string.IsNullOrWhiteSpace(_apiKeySearch) - ? _apiKeys - : _apiKeys.Where(k => k.Name?.Contains(_apiKeySearch, StringComparison.OrdinalIgnoreCase) ?? false); - // Notification Lists private List _notificationLists = new(); private Dictionary> _recipients = new(); @@ -171,7 +148,6 @@ } _apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList(); - _apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList(); } catch (Exception ex) { _errorMessage = ex.Message; } _loading = false; @@ -478,67 +454,4 @@ catch (Exception ex) { _toast.ShowError(ex.Message); } } - // ==== API Keys ==== - private RenderFragment RenderApiKeys() => __builder => - { -
    -
    API Keys
    -
    - - @if (_apiKeys.Count == 0) - { -
    -

    No API keys configured. Add your first API key from the Admin section.

    -
    - } - else - { -
    - -
    - - @if (!FilteredApiKeys.Any()) - { -

    No API keys match the filter.

    - } - -
    - @foreach (var key in FilteredApiKeys) - { -
    -
    -
    -
    -
    @key.Name
    - - @(key.IsEnabled ? "Enabled" : "Disabled") - -
    -
    - -
    -
    -
    -
    - } -
    - } - }; - - private async Task ToggleApiKeyEnabled(ApiKey key) - { - try - { - key.IsEnabled = !key.IsEnabled; - await InboundApiRepository.UpdateApiKeyAsync(key); - await InboundApiRepository.SaveChangesAsync(); - _toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}."); - } - catch (Exception ex) { _toast.ShowError(ex.Message); } - } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor index 15fc306..6b3f6e7 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor @@ -8,6 +8,7 @@ @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject ITemplateEngineRepository TemplateEngineRepository @inject SharedScriptService SharedScriptService +@inject ScriptAnalysis.ScriptAnalysisService AnalysisService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @@ -62,10 +63,92 @@
    +
    + + @if (_showTestRun) + { +
    +
    + Test Run Real I/O +
    +
    + Heads up: + External, Database, and Notify calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent. + CallShared executes the named shared script (saved version) in the same sandbox. + Attributes and CallScript still throw. +
    +
    +
    + + +
    +
    + + @if (_runResult != null) + { + @_runResult.DurationMs ms + } +
    + + @if (_runResult != null) + { + @if (_runResult.Success) + { +
    + +
    @_runResult.ReturnValueJson
    +
    + } + else + { +
    + +
    @_runResult.Error
    + @if (_runResult.Markers is { Count: > 0 }) + { +
      + @foreach (var m in _runResult.Markers) + { +
    • Line @m.StartLineNumber, col @m.StartColumn: @m.Message @m.Code
    • + } +
    + } +
    + } + + @if (!string.IsNullOrEmpty(_runResult.ConsoleOutput)) + { +
    + +
    @_runResult.ConsoleOutput
    +
    + } + } +
    +
    + } } @@ -83,6 +166,12 @@ private MonacoEditor? _editor; private IReadOnlyList _markers = Array.Empty(); + private bool _showTestRun; + private bool _running; + private Dictionary _paramValues = new(); + private ScriptAnalysis.SandboxRunResult? _runResult; + private CancellationTokenSource? _runCts; + private async Task GetCurrentUserAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); @@ -180,6 +269,56 @@ } } + private void ToggleTestRunPanel() + { + _showTestRun = !_showTestRun; + } + + private async Task RunInSandboxAsync() + { + _runCts?.Cancel(); + _runCts = new CancellationTokenSource(); + _running = true; + _runResult = null; + StateHasChanged(); + + try + { + var jsonParams = _paramValues.ToDictionary( + kv => kv.Key, + kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value)); + var request = new ScriptAnalysis.SandboxRunRequest(_formCode, jsonParams, TimeoutSeconds: null); + _runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token); + } + catch (OperationCanceledException) { /* superseded by next Run click */ } + catch (Exception ex) + { + _runResult = new ScriptAnalysis.SandboxRunResult( + Success: false, + ReturnValueJson: null, + ReturnTypeName: null, + ConsoleOutput: "", + Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}", + ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError, + DurationMs: 0, + Markers: null); + } + finally + { + _running = false; + StateHasChanged(); + } + } + + private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch + { + ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error", + ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation", + ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error", + ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout", + _ => "Error" + }; + /// /// Basic syntax check: balanced braces/brackets/parens. /// Mirrors the internal SharedScriptService.ValidateSyntax logic. diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index fa8946b..37057dc 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -1,5 +1,6 @@ @page "/design/templates/{Id:int}" @using ScadaLink.Security +@using ScadaLink.Commons.Entities.Instances @using ScadaLink.Commons.Entities.Templates @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Types.Enums @@ -8,7 +9,9 @@ @using ScadaLink.TemplateEngine.Validation @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject ITemplateEngineRepository TemplateEngineRepository +@inject ICentralUiRepository CentralUiRepository @inject TemplateService TemplateService +@inject ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @inject IDialogService Dialog @@ -106,6 +109,15 @@ private IReadOnlyList _editorChildren = Array.Empty(); + // Script modal Test Run state. + private bool _showScriptTestRun; + private bool _scriptRunning; + private Dictionary _scriptParamValues = new(); + private ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult? _scriptRunResult; + private CancellationTokenSource? _scriptRunCts; + private List _deployedInstances = new(); + private string _scriptBindInstance = string.Empty; + /// /// Editor's Parent.* context. Empty for base templates (no owner exists); /// exactly one entry for derived templates — the slot-owner resolved from @@ -185,6 +197,13 @@ _editorChildren = await BuildChildContextsAsync(_compositions); _editorParents = await BuildParentContextsAsync(Id); + // Deployed, running instances of this template — selectable as the + // bind target for a script Test Run. + _deployedInstances = (await CentralUiRepository.GetInstancesFilteredAsync(templateId: Id)) + .Where(i => i.State == InstanceState.Enabled) + .OrderBy(i => i.UniqueName) + .ToList(); + _validationResult = null; } catch (Exception ex) @@ -926,8 +945,117 @@ {
    @_scriptFormError
    } + + @if (_showScriptTestRun) + { +
    +
    + Test Run Real I/O +
    +
    + Heads up: + runs the script as typed (unsaved edits included) against the supplied + Parameters. + External, Database, and Notify calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent. + CallShared executes the named shared script (saved version) in the same sandbox. + Instance, Attributes, Children, Parent, and CallScript throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too). +
    +
    +
    + + @if (_deployedInstances.Count == 0) + { +
    + No running instances of this template. + Instance/Attributes/CallScript will throw. +
    + } + else + { + +
    + Routes Instance.GetAttribute/SetAttribute, + Attributes, Children, Parent, and + CallScript to the selected live instance. +
    + } +
    +
    + + +
    +
    + + @if (_scriptRunResult != null) + { + @_scriptRunResult.DurationMs ms + } +
    + + @if (_scriptRunResult != null) + { + @if (_scriptRunResult.Success) + { +
    + +
    @_scriptRunResult.ReturnValueJson
    +
    + } + else + { +
    + +
    @_scriptRunResult.Error
    + @if (_scriptRunResult.Markers is { Count: > 0 }) + { +
      + @foreach (var m in _scriptRunResult.Markers) + { +
    • Line @m.StartLineNumber, col @m.StartColumn: @m.Message @m.Code
    • + } +
    + } +
    + } + + @if (!string.IsNullOrEmpty(_scriptRunResult.ConsoleOutput)) + { +
    + +
    @_scriptRunResult.ConsoleOutput
    +
    + } + } +
    +
    + } @@ -1341,6 +1469,7 @@ _scriptReturn = null; _scriptIsLocked = false; _scriptModalTab = "code"; + ResetScriptTestRun(); } private void BeginEditScript(TemplateScript script) @@ -1356,6 +1485,7 @@ _scriptReturn = script.ReturnDefinition; _scriptIsLocked = script.IsLocked; _scriptModalTab = "code"; + ResetScriptTestRun(); } private void CancelScriptForm() @@ -1363,8 +1493,69 @@ _showScriptForm = false; _editScriptId = null; _scriptFormError = null; + ResetScriptTestRun(); } + private void ResetScriptTestRun() + { + _showScriptTestRun = false; + _scriptRunning = false; + _scriptParamValues = new(); + _scriptBindInstance = string.Empty; + _scriptRunResult = null; + _scriptRunCts?.Cancel(); + _scriptRunCts = null; + } + + private void ToggleScriptTestRunPanel() => _showScriptTestRun = !_showScriptTestRun; + + private async Task RunScriptInSandboxAsync() + { + _scriptRunCts?.Cancel(); + _scriptRunCts = new CancellationTokenSource(); + _scriptRunning = true; + _scriptRunResult = null; + StateHasChanged(); + + try + { + var jsonParams = _scriptParamValues.ToDictionary( + kv => kv.Key, + kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value)); + var request = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunRequest( + _scriptCode, jsonParams, TimeoutSeconds: null, + BindInstanceUniqueName: string.IsNullOrEmpty(_scriptBindInstance) ? null : _scriptBindInstance); + _scriptRunResult = await AnalysisService.RunInSandboxAsync(request, _scriptRunCts.Token); + } + catch (OperationCanceledException) { /* superseded by next Run click */ } + catch (Exception ex) + { + _scriptRunResult = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult( + Success: false, + ReturnValueJson: null, + ReturnTypeName: null, + ConsoleOutput: "", + Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}", + ErrorKind: ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError, + DurationMs: 0, + Markers: null); + } + finally + { + _scriptRunning = false; + StateHasChanged(); + } + } + + private static string ScriptErrorKindLabel(ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind kind) => kind switch + { + ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error", + ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation", + ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error", + ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout", + _ => "Error" + }; + private async Task SaveScript() { if (_selectedTemplate == null) return; diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index b655802..9cb497e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -51,10 +51,11 @@ - @* Per-site detail cards *@ - @foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key)) + @* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@ + @foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key)) { - var siteName = GetSiteName(siteId); + var isCentral = siteId == CentralHealthReportLoop.CentralSiteId; + var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId); var detailsCollapseId = $"site-details-{siteId}";
    @@ -67,10 +68,12 @@ { @OfflineGlyph Offline } - @siteName (@siteId) + @siteName@(isCentral ? "" : $" ({siteId})")
    - Last report: | Seq: @state.LastSequenceNumber + Last report: + | Last heartbeat: + | Seq: @state.LastSequenceNumber
    diff --git a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor index 55e136d..7aab91b 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor @@ -27,6 +27,13 @@ [Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ShowToolbar { get; set; } = true; + /// + /// Runtime globals surface the script is analyzed against. Defaults to + /// template/shared-script globals; set to InboundApi on the API + /// method editor so Route and Parameters type-check. + /// + [Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template; + /// /// Parameter names declared on the form (derived from the SchemaBuilder's /// JSON Schema), surfaced as completions inside Parameters["..."] literals @@ -148,7 +155,8 @@ ?? Array.Empty(), SelfAttributes?.ToArray() ?? Array.Empty(), Children?.ToArray() ?? Array.Empty(), - Parent); + Parent, + ScriptKind); private async Task FormatAsync() { @@ -189,5 +197,6 @@ ScriptAnalysis.ParameterShape[] DeclaredParameterShapes, ScriptAnalysis.AttributeShape[] SelfAttributes, ScriptAnalysis.CompositionContext[] Children, - ScriptAnalysis.CompositionContext? Parent); + ScriptAnalysis.CompositionContext? Parent, + ScriptAnalysis.ScriptKind ScriptKind); } diff --git a/src/ScadaLink.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ScadaLink.CentralUI/Components/Shared/ParameterValueForm.razor new file mode 100644 index 0000000..8e1db42 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ParameterValueForm.razor @@ -0,0 +1,180 @@ +@using ScadaLink.CentralUI.ScriptAnalysis +@using System.Text.Json + +@* + Renders an input row per declared parameter so the user can supply values + for a script test run. Primitive types get typed inputs (text / number / + checkbox); Object and List fall back to a JSON textarea with inline parse + errors. The companion SchemaBuilder edits the schema; this edits values. +*@ + +@if (Shapes.Count == 0) +{ +
    No parameters declared.
    +} +else +{ +
    + @foreach (var shape in Shapes) + { +
    +
    + +
    +
    + @RenderInput(shape) + @if (_parseErrors.TryGetValue(shape.Name, out var err)) + { +
    @err
    + } +
    +
    + } +
    +} + +@code { + [Parameter] public string? ParameterDefinitions { get; set; } + [Parameter] public Dictionary Values { get; set; } = new(); + [Parameter] public EventCallback> ValuesChanged { get; set; } + + private IReadOnlyList Shapes => + ScriptParameterNames.ParseShapes(ParameterDefinitions); + + private readonly Dictionary _rawText = new(); + private readonly Dictionary _parseErrors = new(); + + private static string FieldId(ParameterShape shape) => $"param-{shape.Name}"; + + private RenderFragment RenderInput(ParameterShape shape) => __builder => + { + switch (shape.Type) + { + case "Boolean": +
    + +
    + break; + + case "Integer": + + break; + + case "Float": + + break; + + case "String": + + break; + + default: // Object, List, List<...>, unknown + + break; + } + }; + + private string AsRaw(string name) => + _rawText.TryGetValue(name, out var raw) ? raw : ""; + + private bool AsBool(string name) => + Values.TryGetValue(name, out var v) && v is bool b && b; + + private async Task SetString(string name, string? raw) + { + _rawText[name] = raw ?? ""; + _parseErrors.Remove(name); + Values[name] = raw ?? ""; + await ValuesChanged.InvokeAsync(Values); + } + + private async Task SetBool(string name, bool value) + { + _parseErrors.Remove(name); + Values[name] = value; + await ValuesChanged.InvokeAsync(Values); + } + + private async Task SetNumeric(string name, string? raw, bool integerOnly) + { + _rawText[name] = raw ?? ""; + if (string.IsNullOrWhiteSpace(raw)) + { + _parseErrors.Remove(name); + Values.Remove(name); + await ValuesChanged.InvokeAsync(Values); + return; + } + if (integerOnly && long.TryParse(raw, out var i)) + { + _parseErrors.Remove(name); + Values[name] = i; + } + else if (!integerOnly && double.TryParse(raw, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var d)) + { + _parseErrors.Remove(name); + Values[name] = d; + } + else + { + _parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number."; + Values.Remove(name); + } + await ValuesChanged.InvokeAsync(Values); + } + + private async Task SetJson(string name, string? raw) + { + _rawText[name] = raw ?? ""; + if (string.IsNullOrWhiteSpace(raw)) + { + _parseErrors.Remove(name); + Values.Remove(name); + await ValuesChanged.InvokeAsync(Values); + return; + } + try + { + using var doc = JsonDocument.Parse(raw); + Values[name] = JsonElementToObject(doc.RootElement.Clone()); + _parseErrors.Remove(name); + } + catch (JsonException ex) + { + _parseErrors[name] = $"JSON parse error: {ex.Message}"; + Values.Remove(name); + } + await ValuesChanged.InvokeAsync(Values); + } + + private static object? JsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + _ => null + }; + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs index 0e7b84a..5e6afb8 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs @@ -9,8 +9,17 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; public interface ISharedScriptCatalog { Task> GetShapesAsync(); + + /// + /// Returns the source code and metadata for a named shared script, or + /// null if no shared script with that name exists. Used by Test Run to + /// compile and execute nested CallShared invocations. + /// + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); } +public record SharedScriptSource(string Name, string Code, string? ParameterDefinitions, string? ReturnDefinition); + public class SharedScriptCatalog : ISharedScriptCatalog { private readonly SharedScriptService _service; @@ -24,4 +33,12 @@ public class SharedScriptCatalog : ISharedScriptCatalog .Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)) .ToList(); } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(name)) return null; + var scripts = await _service.GetAllSharedScriptsAsync(cancellationToken); + var s = scripts.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.Ordinal)); + return s == null ? null : new SharedScriptSource(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition); + } } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/InboundScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/InboundScriptHost.cs new file mode 100644 index 0000000..1e1ecbd --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/InboundScriptHost.cs @@ -0,0 +1,56 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Globals type seen by inbound API method scripts during analysis. Mirrors +/// the surface the runtime exposes (see ScadaLink.InboundAPI.InboundScriptContext +/// and RouteHelper). The methods here are never invoked — Roslyn only reads +/// their signatures to type-check API method scripts and offer completions. +/// +public class InboundScriptHost +{ + public ScriptParameters Parameters { get; init; } = new(); + + public RouteHelper Route { get; } = new(); + + public System.Threading.CancellationToken CancellationToken { get; } + + /// Editor mirror of ScadaLink.InboundAPI.RouteHelper. + public class RouteHelper + { + public RouteTarget To(string instanceCode) => new(); + } + + /// Editor mirror of ScadaLink.InboundAPI.RouteTarget. + public class RouteTarget + { + public System.Threading.Tasks.Task Call( + string scriptName, + object? parameters = null, + System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.FromResult(null); + + public System.Threading.Tasks.Task GetAttribute( + string attributeName, + System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.FromResult(null); + + public System.Threading.Tasks.Task> GetAttributes( + IEnumerable attributeNames, + System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.FromResult>( + new Dictionary()); + + public System.Threading.Tasks.Task SetAttribute( + string attributeName, + string value, + System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.CompletedTask; + + public System.Threading.Tasks.Task SetAttributes( + IReadOnlyDictionary attributeValues, + System.Threading.CancellationToken cancellationToken = default) => + System.Threading.Tasks.Task.CompletedTask; + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs new file mode 100644 index 0000000..4167b30 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs @@ -0,0 +1,118 @@ +using System.Data.Common; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// User-facing surface for ExternalSystem.Call / +/// ExternalSystem.CachedCall inside a Test Run. Mirrors +/// ExternalSystemHelper in ScadaLink.SiteRuntime.Scripts.ScriptRuntimeContext +/// so the same user code compiles against both. When constructed with a null +/// client (the editor's metadata-only analysis pass) every call throws +/// ; with a real client wired in (a Test +/// Run) calls hit the live HTTP path. +/// +public class SandboxExternalHelper +{ + private readonly IExternalSystemClient? _client; + private readonly string _instanceName; + + public SandboxExternalHelper(IExternalSystemClient? client, string instanceName) + { + _client = client; + _instanceName = instanceName; + } + + public Task Call( + string systemName, + string methodName, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + { + if (_client == null) + throw new ScriptSandboxException( + $"External.Call(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run."); + return _client.CallAsync(systemName, methodName, parameters, cancellationToken); + } + + public Task CachedCall( + string systemName, + string methodName, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + { + if (_client == null) + throw new ScriptSandboxException( + $"External.CachedCall(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run."); + return _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken); + } +} + +public class SandboxDatabaseHelper +{ + private readonly IDatabaseGateway? _gateway; + private readonly string _instanceName; + + public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName) + { + _gateway = gateway; + _instanceName = instanceName; + } + + public Task Connection(string name, CancellationToken cancellationToken = default) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"Database.Connection(\"{name}\") — database gateway not configured for Test Run."); + return _gateway.GetConnectionAsync(name, cancellationToken); + } + + public Task CachedWrite( + string name, + string sql, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"Database.CachedWrite(\"{name}\") — database gateway not configured for Test Run."); + return _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken); + } +} + +public class SandboxNotifyHelper +{ + private readonly INotificationDeliveryService? _service; + private readonly string _instanceName; + + public SandboxNotifyHelper(INotificationDeliveryService? service, string instanceName) + { + _service = service; + _instanceName = instanceName; + } + + public SandboxNotifyTarget To(string listName) => + new(listName, _service, _instanceName); +} + +public class SandboxNotifyTarget +{ + private readonly string _listName; + private readonly INotificationDeliveryService? _service; + private readonly string _instanceName; + + internal SandboxNotifyTarget(string listName, INotificationDeliveryService? service, string instanceName) + { + _listName = listName; + _service = service; + _instanceName = instanceName; + } + + public Task Send(string subject, string message, CancellationToken cancellationToken = default) + { + if (_service == null) + throw new ScriptSandboxException( + $"Notify.To(\"{_listName}\").Send(...) — notification service not configured for Test Run."); + return _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken); + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs new file mode 100644 index 0000000..3c14e47 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs @@ -0,0 +1,67 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Runtime globals for an inbound API method Test Run. Mirrors +/// 's public surface so the same user code that +/// compiles for diagnostics also compiles against this type — but every +/// Route accessor throws instead of +/// reaching a deployed site. Cross-site routing needs the cluster transport and +/// a live instance, neither of which exists in a central Test Run; pure logic +/// and Parameters still work, matching how +/// throws on Attributes for shared scripts. +/// +public class SandboxInboundScriptHost +{ + public ScriptParameters Parameters { get; init; } = new(); + + public CancellationToken CancellationToken { get; init; } + + public RouteAccessor Route { get; } = new(); + + /// Mirror of ScadaLink.InboundAPI.RouteHelper. + public class RouteAccessor + { + public RouteTarget To(string instanceCode) => new(instanceCode); + } + + /// Mirror of ScadaLink.InboundAPI.RouteTarget — every call throws. + public class RouteTarget + { + private readonly string _instanceCode; + + internal RouteTarget(string instanceCode) => _instanceCode = instanceCode; + + public Task Call( + string scriptName, + object? parameters = null, + CancellationToken cancellationToken = default) => + throw Unavailable($"Call(\"{scriptName}\")"); + + public Task GetAttribute( + string attributeName, + CancellationToken cancellationToken = default) => + throw Unavailable($"GetAttribute(\"{attributeName}\")"); + + public Task> GetAttributes( + IEnumerable attributeNames, + CancellationToken cancellationToken = default) => + throw Unavailable("GetAttributes(...)"); + + public Task SetAttribute( + string attributeName, + string value, + CancellationToken cancellationToken = default) => + throw Unavailable($"SetAttribute(\"{attributeName}\")"); + + public Task SetAttributes( + IReadOnlyDictionary attributeValues, + CancellationToken cancellationToken = default) => + throw Unavailable("SetAttributes(...)"); + + private ScriptSandboxException Unavailable(string operation) => + new($"Route.To(\"{_instanceCode}\").{operation} is not available in Test Run — " + + "cross-site routing needs a deployed site reachable over the cluster transport."); + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs new file mode 100644 index 0000000..b55339f --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxInstanceGateway.cs @@ -0,0 +1,67 @@ +using ScadaLink.Commons.Messages.InboundApi; +using ScadaLink.Communication; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Backs the Test Run sandbox Instance when the run is bound to a real +/// deployed instance. Routes attribute reads/writes and sibling-script calls to +/// the instance cross-site via — the same +/// transport the inbound API's Route.To() uses. All calls run under the +/// Test Run's cancellation token, so the sandbox timeout still applies. +/// +public sealed class SandboxInstanceGateway : ISandboxInstanceGateway +{ + private readonly CommunicationService _comms; + private readonly string _siteId; + private readonly string _instanceUniqueName; + private readonly CancellationToken _runToken; + + public SandboxInstanceGateway( + CommunicationService comms, + string siteId, + string instanceUniqueName, + CancellationToken runToken) + { + _comms = comms; + _siteId = siteId; + _instanceUniqueName = instanceUniqueName; + _runToken = runToken; + } + + public async Task GetAttributeAsync(string canonicalName, CancellationToken ct) + { + var request = new RouteToGetAttributesRequest( + Guid.NewGuid().ToString(), _instanceUniqueName, + new[] { canonicalName }, DateTimeOffset.UtcNow); + var response = await _comms.RouteToGetAttributesAsync(_siteId, request, _runToken); + if (!response.Success) + throw new ScriptSandboxException( + $"GetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}"); + return response.Values.TryGetValue(canonicalName, out var value) ? value : null; + } + + public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) + { + var request = new RouteToSetAttributesRequest( + Guid.NewGuid().ToString(), _instanceUniqueName, + new Dictionary { [canonicalName] = value }, DateTimeOffset.UtcNow); + var response = await _comms.RouteToSetAttributesAsync(_siteId, request, _runToken); + if (!response.Success) + throw new ScriptSandboxException( + $"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}"); + } + + public async Task CallScriptAsync( + string canonicalScriptName, IReadOnlyDictionary? parameters, CancellationToken ct) + { + var request = new RouteToCallRequest( + Guid.NewGuid().ToString(), _instanceUniqueName, + canonicalScriptName, parameters, DateTimeOffset.UtcNow); + var response = await _comms.RouteToCallAsync(_siteId, request, _runToken); + if (!response.Success) + throw new ScriptSandboxException( + $"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}"); + return response.ReturnValue; + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxRunContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxRunContracts.cs new file mode 100644 index 0000000..40a308a --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxRunContracts.cs @@ -0,0 +1,45 @@ +using System.Text.Json; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Request from the UI to execute a script in the central sandbox. +/// Parameters arrive as JSON values and are converted to .NET primitives +/// before being placed in the Parameters dictionary supplied to the script. +/// selects which globals surface the script is compiled +/// and run against — template/shared scripts see , +/// inbound API method scripts see . +/// , when set, binds the run to a deployed +/// instance so Instance/Attributes access routes to it cross-site +/// instead of throwing. Ignored for inbound API scripts. +/// +public record SandboxRunRequest( + string Code, + Dictionary? Parameters, + int? TimeoutSeconds, + ScriptKind Kind = ScriptKind.Template, + string? BindInstanceUniqueName = null); + +public enum SandboxErrorKind +{ + None, + CompileError, + SandboxLimitation, + RuntimeError, + Timeout +} + +/// +/// Result of a Test Run. carries Roslyn diagnostics +/// when is CompileError so the UI can display them +/// the same way it does for the editor's live problems panel. +/// +public record SandboxRunResult( + bool Success, + string? ReturnValueJson, + string? ReturnTypeName, + string ConsoleOutput, + string? Error, + SandboxErrorKind ErrorKind, + long DurationMs, + IReadOnlyList? Markers); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs new file mode 100644 index 0000000..e3cfc0b --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs @@ -0,0 +1,236 @@ +using ScadaLink.Commons.Types; +using ScadaLink.Commons.Types.Scripts; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime +/// ScriptGlobals surface (ScadaLink.SiteRuntime.Scripts) member-for-member +/// so the same user code that runs at a site also compiles and runs here. +/// +/// Instance-context members — Instance.GetAttribute/SetAttribute/CallScript, +/// Attributes, Children, Parent — need a live deployed +/// instance. With no instance bound they throw ; +/// with one bound (see ) they route to it. +/// +/// ExternalSystem, Database, Notify, and +/// Scripts.CallShared run against central's real services and fire for +/// real — they do not depend on a bound instance. +/// +public class SandboxScriptHost +{ + public ScriptParameters Parameters { get; init; } = new(); + + public CancellationToken CancellationToken { get; init; } + + public AlarmContext? Alarm { get; init; } + + public ScriptScope Scope { get; init; } = ScriptScope.Root; + + public SandboxInstanceContext Instance { get; init; } = new(); + + public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem; + public SandboxDatabaseHelper Database => Instance.Database; + public SandboxNotifyHelper Notify => Instance.Notify; + public SandboxScriptCallHelper Scripts => Instance.Scripts; + + public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath); + public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath); + public SandboxCompositionAccessor? Parent => + Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath); +} + +/// +/// Backs the sandbox Instance when a Test Run is bound to a real +/// deployed instance. Null when unbound. The implementation routes to the +/// instance cross-site over the cluster transport. +/// +public interface ISandboxInstanceGateway +{ + Task GetAttributeAsync(string canonicalName, CancellationToken ct); + Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct); + Task CallScriptAsync( + string canonicalScriptName, IReadOnlyDictionary? parameters, CancellationToken ct); +} + +/// +/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.ScriptRuntimeContext — +/// the Instance global. Attribute and sibling-script access needs a real +/// deployed instance: with no gateway wired it throws; with one (a bound +/// instance) it routes cross-site. ExternalSystem/Database/ +/// Notify/Scripts run against central's real services regardless +/// of binding. +/// +public class SandboxInstanceContext +{ + private readonly ISandboxInstanceGateway? _gateway; + + public SandboxExternalHelper ExternalSystem { get; } + public SandboxDatabaseHelper Database { get; } + public SandboxNotifyHelper Notify { get; } + public SandboxScriptCallHelper Scripts { get; } + + public SandboxInstanceContext( + ISandboxInstanceGateway? gateway = null, + SandboxExternalHelper? external = null, + SandboxDatabaseHelper? database = null, + SandboxNotifyHelper? notify = null, + SandboxScriptCallHelper? scripts = null) + { + _gateway = gateway; + ExternalSystem = external ?? new SandboxExternalHelper(null, ""); + Database = database ?? new SandboxDatabaseHelper(null, ""); + Notify = notify ?? new SandboxNotifyHelper(null, ""); + Scripts = scripts ?? new SandboxScriptCallHelper(null); + } + + public Task GetAttribute(string attributeName) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"GetAttribute(\"{attributeName}\") needs a deployed instance — " + + "bind one in Test Run to read live attribute values."); + return _gateway.GetAttributeAsync(attributeName, CancellationToken.None); + } + + public void SetAttribute(string attributeName, string value) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"SetAttribute(\"{attributeName}\") needs a deployed instance — " + + "bind one in Test Run to write attribute values."); + _gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult(); + } + + public Task CallScript(string scriptName, object? parameters = null) + { + if (_gateway == null) + throw new ScriptSandboxException( + $"CallScript(\"{scriptName}\") needs a deployed instance — " + + "bind one in Test Run to call sibling scripts."); + return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None); + } +} + +/// +/// Sandbox mirror of ScriptRuntimeContext.ScriptCallHelper — +/// Scripts.CallShared(...). Compiles and runs the named shared script in +/// the same sandbox via the wired delegate. +/// +public class SandboxScriptCallHelper +{ + private readonly Func?, CancellationToken, Task>? _callShared; + + public SandboxScriptCallHelper( + Func?, CancellationToken, Task>? callShared) + { + _callShared = callShared; + } + + public Task CallShared( + string scriptName, + object? parameters = null, + CancellationToken cancellationToken = default) + { + if (_callShared == null) + throw new ScriptSandboxException( + $"Scripts.CallShared(\"{scriptName}\") — shared-script catalog not configured for Test Run."); + return _callShared(scriptName, ScriptArgs.Normalize(parameters), cancellationToken); + } +} + +/// +/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.AttributeAccessor — +/// scope-aware Attributes["X"] access anchored at a canonical-name prefix. +/// +public class SandboxAttributeAccessor +{ + private readonly SandboxInstanceContext _ctx; + + public string ScopePrefix { get; } + + public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix) + { + _ctx = ctx; + ScopePrefix = prefix; + } + + public string Resolve(string key) => + ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key; + + public object? this[string key] + { + get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult(); + set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty); + } + + public Task GetAsync(string key) => _ctx.GetAttribute(Resolve(key)); + + public Task SetAsync(string key, object? value) + { + _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty); + return Task.CompletedTask; + } +} + +/// +/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.CompositionAccessor — +/// a view of one composition: its attributes plus an invokable CallScript. +/// +public class SandboxCompositionAccessor +{ + private readonly SandboxInstanceContext _ctx; + + public string Path { get; } + + public SandboxAttributeAccessor Attributes { get; } + + public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path) + { + _ctx = ctx; + Path = path; + Attributes = new SandboxAttributeAccessor(ctx, path); + } + + public string ResolveScript(string scriptName) => + Path.Length == 0 ? scriptName : Path + "." + scriptName; + + public Task CallScript(string scriptName, object? parameters = null) + => _ctx.CallScript(ResolveScript(scriptName), parameters); +} + +/// +/// Sandbox mirror of ScadaLink.SiteRuntime.Scripts.ChildrenAccessor — +/// dictionary-style access to child compositions. +/// +public class SandboxChildrenAccessor +{ + private readonly SandboxInstanceContext _ctx; + private readonly string _selfPath; + + public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath) + { + _ctx = ctx; + _selfPath = selfPath; + } + + public SandboxCompositionAccessor this[string compositionName] + { + get + { + var path = _selfPath.Length == 0 + ? compositionName + : _selfPath + "." + compositionName; + return new SandboxCompositionAccessor(_ctx, path); + } + } +} + +/// +/// Distinct exception so the Test Run pipeline can label sandbox-only +/// limitations differently from genuine runtime errors in user code. +/// +public class ScriptSandboxException : Exception +{ + public ScriptSandboxException(string message) : base(message) { } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs index cb2b9ae..bc29c17 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -1,12 +1,25 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; +/// +/// Which runtime globals surface a script is analyzed against. Template and +/// shared scripts see (mirroring the site +/// runtime's ScriptGlobals); inbound API method scripts see +/// (with Route and Parameters). +/// +public enum ScriptKind +{ + Template, + InboundApi +} + public record DiagnoseRequest( string Code, IReadOnlyList? DeclaredParameters = null, IReadOnlyList? SiblingScripts = null, IReadOnlyList? SelfAttributes = null, IReadOnlyList? Children = null, - CompositionContext? Parent = null); + CompositionContext? Parent = null, + ScriptKind Kind = ScriptKind.Template); public record DiagnoseResponse(IReadOnlyList Markers); @@ -31,7 +44,8 @@ public record CompletionsRequest( IReadOnlyList? SiblingScripts = null, IReadOnlyList? SelfAttributes = null, IReadOnlyList? Children = null, - CompositionContext? Parent = null); + CompositionContext? Parent = null, + ScriptKind Kind = ScriptKind.Template); public record CompletionsResponse(IReadOnlyList Items); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index e41d7ba..8c538ed 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -31,6 +31,9 @@ public static class ScriptAnalysisEndpoints group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) => Results.Ok(svc.InlayHints(req))); + group.MapPost("/run", async (SandboxRunRequest req, ScriptAnalysisService svc, HttpContext http) => + Results.Ok(await svc.RunInSandboxAsync(req, http.RequestAborted))); + return endpoints; } } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index 64e2353..28bdeac 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -1,5 +1,8 @@ +using System.Diagnostics; +using System.IO; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; @@ -7,13 +10,16 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Scripting; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Interfaces.Services; namespace ScadaLink.CentralUI.ScriptAnalysis; /// /// Compiles user scripts as Roslyn C# Scripting fragments against -/// globals and surfaces diagnostics + completions -/// in the shape Monaco's provider APIs expect. +/// globals (template/shared) or +/// (inbound API) and surfaces diagnostics + +/// completions in the shape Monaco's provider APIs expect. /// /// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces /// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue @@ -23,9 +29,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; /// /// Beyond plain C# analysis, layers SCADA-specific extensions: /// - In-string completion of Parameters["..."] keys (from the request's -/// DeclaredParameters), CallShared("...") names (from -/// ), and CallScript("...") names -/// (from the request's SiblingScripts). +/// DeclaredParameters), Scripts.CallShared("...") names (from +/// ), and Instance.CallScript("...") / +/// Children["X"].CallScript("...") / Parent.CallScript("...") names +/// (from the request's SiblingScripts / Children / Parent). /// - Forbidden-API diagnostic for the documented script trust model, /// resolved against the SemanticModel so user identifiers that happen /// to share names with forbidden types (e.g. var File = ...) @@ -39,7 +46,9 @@ public class ScriptAnalysisService typeof(Enumerable).Assembly, typeof(System.Collections.Generic.Dictionary<,>).Assembly, typeof(System.ComponentModel.DescriptionAttribute).Assembly, - typeof(ScriptHost).Assembly) + typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly, + typeof(Commons.Types.ScriptParameters).Assembly, + typeof(SandboxScriptHost).Assembly) .AddImports( "System", "System.Collections.Generic", @@ -61,26 +70,46 @@ public class ScriptAnalysisService private readonly ISharedScriptCatalog _sharedScripts; private readonly IMemoryCache _cache; + private readonly IServiceProvider _services; - public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache) + public ScriptAnalysisService( + ISharedScriptCatalog sharedScripts, + IMemoryCache cache, + IServiceProvider services) { _sharedScripts = sharedScripts; _cache = cache; + _services = services; } + /// Globals type a script of the given kind is compiled against. + private static Type GlobalsTypeFor(ScriptKind kind) => + kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost); + + /// + /// Re-enables the nullable annotation context for an analysis compilation. + /// Roslyn scripting defaults to a disabled nullable context, which makes any + /// ? annotation in a user script raise CS8632. Annotations-only keeps + /// string? legal without surfacing the nullable-flow warnings. + /// + private static Compilation WithNullableAnnotations(Compilation compilation) => + compilation is CSharpCompilation cs + ? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations)) + : compilation; + public DiagnoseResponse Diagnose(DiagnoseRequest request) { if (string.IsNullOrEmpty(request.Code)) return new DiagnoseResponse(Array.Empty()); - var cacheKey = "diag:" + HashCode(request.Code); + var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code); if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null) return cached; Script script; try { - script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost)); + script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind)); } catch (Exception ex) { @@ -91,7 +120,7 @@ public class ScriptAnalysisService return Cache(cacheKey, failure); } - var compilation = script.GetCompilation(); + var compilation = WithNullableAnnotations(script.GetCompilation()); var markers = compilation .GetDiagnostics() .Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource) @@ -104,8 +133,6 @@ public class ScriptAnalysisService var model = compilation.GetSemanticModel(tree); markers.AddRange(FindForbiddenApiUsages(tree, model)); markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters)); - markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts)); - markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts)); markers.AddRange(FindUnknownAttributeKeys(tree, request)); markers.AddRange(FindUnknownChildren(tree, request.Children)); } @@ -113,6 +140,341 @@ public class ScriptAnalysisService return Cache(cacheKey, new DiagnoseResponse(markers)); } + private const int SandboxMaxTimeoutSeconds = 10; + private const int SandboxDefaultTimeoutSeconds = 5; + private const int SandboxMaxConsoleChars = 32_000; + private const int SandboxMaxReturnJsonChars = 32_000; + + private const int SandboxMaxCallSharedDepth = 16; + + /// + /// Compiles and runs a script in the central process. The globals surface + /// depends on : template and shared + /// scripts run against , inbound API method + /// scripts against . + /// Pure logic + the supplied Parameters always work. + /// For the SandboxScriptHost surface, Attributes still throws while + /// External, Database, and Notify are wired to + /// central's real , + /// , and + /// — calls fire for real and + /// have production-equivalent side effects (HTTP, SQL, SMTP). + /// CallShared compiles and executes the named shared script in the + /// same sandbox, with a recursion limit of + /// . CallScript still throws + /// because a shared script has no template siblings in this context. + /// For the SandboxInboundScriptHost surface, every Route call throws + /// because cross-site routing needs a deployed site. + /// Console.Out / Console.Error are redirected per-call so writes from + /// the script land in the result. + /// + public async Task RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Code)) + { + return new SandboxRunResult( + Success: false, + ReturnValueJson: null, + ReturnTypeName: null, + ConsoleOutput: "", + Error: "Script code is empty.", + ErrorKind: SandboxErrorKind.CompileError, + DurationMs: 0, + Markers: Array.Empty()); + } + + var timeoutSeconds = Math.Clamp( + request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds, + 1, SandboxMaxTimeoutSeconds); + + var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[] + { + Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location) + })); + + var globalsType = request.Kind == ScriptKind.InboundApi + ? typeof(SandboxInboundScriptHost) + : typeof(SandboxScriptHost); + + Script script; + try + { + script = CSharpScript.Create(request.Code, options, globalsType: globalsType); + } + catch (Exception ex) + { + return new SandboxRunResult(false, null, null, "", ex.Message, + SandboxErrorKind.CompileError, 0, + new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") }); + } + + var compileDiagnostics = script.Compile(ct); + var errorDiagnostics = compileDiagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource) + .ToList(); + if (errorDiagnostics.Count > 0) + { + var markers = errorDiagnostics.Select(ToMarker).ToList(); + return new SandboxRunResult(false, null, null, "", + string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())), + SandboxErrorKind.CompileError, 0, markers); + } + + var parameters = ConvertJsonParameters(request.Parameters); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + + // Optional instance binding: when the Test Run targets a deployed + // instance, Instance.GetAttribute/SetAttribute/CallScript and the + // Attributes/Children/Parent accessors route to it cross-site. + ISandboxInstanceGateway? instanceGateway = null; + var instanceLabel = "test-run"; + if (request.Kind != ScriptKind.InboundApi + && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName)) + { + var bindName = request.BindInstanceUniqueName.Trim(); + var locator = _services.GetService(); + var comms = _services.GetService(); + if (locator == null || comms == null) + return new SandboxRunResult(false, null, null, "", + "Instance binding is unavailable — cross-site communication is not configured on this node.", + SandboxErrorKind.SandboxLimitation, 0, null); + + var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct); + if (siteId == null) + return new SandboxRunResult(false, null, null, "", + $"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.", + SandboxErrorKind.SandboxLimitation, 0, null); + + instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token); + instanceLabel = bindName; + } + + var externalClient = _services.GetService(); + var databaseGateway = _services.GetService(); + var notifyService = _services.GetService(); + var external = new SandboxExternalHelper(externalClient, instanceLabel); + var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel); + var notify = new SandboxNotifyHelper(notifyService, instanceLabel); + + var compileCache = new Dictionary>(StringComparer.Ordinal); + var compileCacheLock = new object(); + var depth = 0; + + Func?, CancellationToken, Task>? callSharedFunc = null; + + // Scripts.CallShared and the Instance helpers share one context across + // the root script and any nested shared scripts — mirroring the site + // runtime, where a shared script runs against the caller's Instance. + var scriptsHelper = new SandboxScriptCallHelper( + (name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt)); + var instanceContext = new SandboxInstanceContext( + gateway: instanceGateway, + external: external, + database: database, + notify: notify, + scripts: scriptsHelper); + + callSharedFunc = async (name, ps, nestedCt) => + { + if (string.IsNullOrEmpty(name)) + throw new ScriptSandboxException("Scripts.CallShared called with an empty script name."); + if (depth >= SandboxMaxCallSharedDepth) + throw new ScriptSandboxException( + $"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls."); + + Script? compiled; + lock (compileCacheLock) compileCache.TryGetValue(name, out compiled); + + if (compiled == null) + { + var src = await _sharedScripts.GetByNameAsync(name, nestedCt); + if (src == null) + throw new ScriptSandboxException( + $"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central."); + + Script built; + try + { + built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost)); + } + catch (Exception ex) + { + throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}"); + } + var nestedDiag = built.Compile(nestedCt); + var nestedErrors = nestedDiag + .Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource) + .ToList(); + if (nestedErrors.Count > 0) + throw new ScriptSandboxException( + $"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}"); + + lock (compileCacheLock) + { + if (!compileCache.TryGetValue(name, out compiled)) + { + compileCache[name] = built; + compiled = built; + } + } + } + + var nestedHost = new SandboxScriptHost + { + Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary()), + CancellationToken = nestedCt, + Instance = instanceContext, + }; + + Interlocked.Increment(ref depth); + try + { + var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false); + return nestedState.ReturnValue; + } + finally + { + Interlocked.Decrement(ref depth); + } + }; + + // Inbound API scripts see a different globals surface (Parameters + + // Route); template and shared scripts see the SandboxScriptHost surface + // mirroring the site runtime's ScriptGlobals. + object host = request.Kind == ScriptKind.InboundApi + ? new SandboxInboundScriptHost + { + Parameters = new Commons.Types.ScriptParameters(parameters), + CancellationToken = linkedCts.Token, + } + : new SandboxScriptHost + { + Parameters = new Commons.Types.ScriptParameters(parameters), + CancellationToken = linkedCts.Token, + Instance = instanceContext, + }; + + var originalOut = Console.Out; + var originalError = Console.Error; + var captured = new StringWriter(); + + var stopwatch = Stopwatch.StartNew(); + try + { + Console.SetOut(captured); + Console.SetError(captured); + + // Run on a thread-pool thread with no SynchronizationContext: a + // bound script's Instance.SetAttribute / Attributes[...] block + // synchronously on cross-site I/O (the API surface is sync by + // contract), which would deadlock against the Blazor circuit's + // captured context if the script ran inline. + var state = await Task.Run( + () => script.RunAsync(host, linkedCts.Token), linkedCts.Token) + .ConfigureAwait(false); + stopwatch.Stop(); + var (returnJson, returnType) = SerializeReturn(state.ReturnValue); + return new SandboxRunResult( + Success: true, + ReturnValueJson: returnJson, + ReturnTypeName: returnType, + ConsoleOutput: TruncateConsole(captured.ToString()), + Error: null, + ErrorKind: SandboxErrorKind.None, + DurationMs: stopwatch.ElapsedMilliseconds, + Markers: null); + } + catch (ScriptSandboxException sandboxEx) + { + stopwatch.Stop(); + return new SandboxRunResult(false, null, null, + TruncateConsole(captured.ToString()), sandboxEx.Message, + SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + stopwatch.Stop(); + return new SandboxRunResult(false, null, null, + TruncateConsole(captured.ToString()), + $"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.", + SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null); + } + catch (Exception ex) + { + stopwatch.Stop(); + var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex); + if (inner is ScriptSandboxException sx) + { + return new SandboxRunResult(false, null, null, + TruncateConsole(captured.ToString()), sx.Message, + SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null); + } + return new SandboxRunResult(false, null, null, + TruncateConsole(captured.ToString()), + $"{inner.GetType().Name}: {inner.Message}", + SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + private static Dictionary ConvertJsonParameters( + Dictionary? parameters) + { + var result = new Dictionary(StringComparer.Ordinal); + if (parameters == null) return result; + foreach (var (key, value) in parameters) + { + result[key] = JsonElementToObject(value); + } + return result; + } + + private static object? JsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + _ => null + }; + } + + private static (string Json, string TypeName) SerializeReturn(object? value) + { + if (value == null) return ("null", "null"); + var typeName = value.GetType().Name; + try + { + var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }); + if (json.Length > SandboxMaxReturnJsonChars) + json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)"; + return (json, typeName); + } + catch (Exception ex) + { + return ($"\"\"", typeName); + } + } + + private static string TruncateConsole(string text) + { + if (text.Length <= SandboxMaxConsoleChars) return text; + return text[..SandboxMaxConsoleChars] + "\n… (truncated)"; + } + private DiagnoseResponse Cache(string key, DiagnoseResponse value) { _cache.Set(key, value, new MemoryCacheEntryOptions @@ -137,7 +499,7 @@ public class ScriptAnalysisService Script script; try { - script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost)); + script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind)); } catch { @@ -242,54 +604,32 @@ public class ScriptAnalysisService } } - // CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...") + // Scripts.CallShared("...") / Instance.CallScript("...") / + // Children["X"].CallScript("...") / Parent.CallScript("...") if (owner is InvocationExpressionSyntax inv) { - var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; - var calleeMa = inv.Expression as MemberAccessExpressionSyntax; - var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText; - - if (calleeName == "CallShared") + var call = ClassifyScriptCall(inv); + switch (call.Kind) { - var shapes = await _sharedScripts.GetShapesAsync(); - return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList(); - } - - if (calleeName == "CallScript") - { - // Children["X"].CallScript("..." or Parent.CallScript("... - if (calleeMa != null) + case ScriptCallKind.Shared: { - // Children["X"].CallScript - if (calleeMa.Expression is ElementAccessExpressionSyntax childElem - && childElem.Expression is IdentifierNameSyntax cid - && cid.Identifier.ValueText == "Children" - && childElem.ArgumentList.Arguments.Count == 1 - && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit - && cLit.IsKind(SyntaxKind.StringLiteralExpression)) - { - var compName = cLit.Token.ValueText; - var comp = (request.Children ?? Array.Empty()) - .FirstOrDefault(c => c.Name == compName); - if (comp != null) - return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList(); - return new List(); - } - // Parent.CallScript - if (calleeMa.Expression is IdentifierNameSyntax pid - && pid.Identifier.ValueText == "Parent" - && request.Parent != null) - { - return request.Parent.Scripts - .Select(s => MakeCallCompletion(s, "parent script")) - .ToList(); - } + var shapes = await _sharedScripts.GetShapesAsync(); + return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList(); + } + case ScriptCallKind.Sibling: + return (request.SiblingScripts ?? Array.Empty()) + .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList(); + case ScriptCallKind.Parent: + return (request.Parent?.Scripts ?? Array.Empty()) + .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList(); + case ScriptCallKind.Child: + { + var comp = (request.Children ?? Array.Empty()) + .FirstOrDefault(c => c.Name == call.CompositionName); + return comp != null + ? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList() + : new List(); } - - // Plain CallScript("...") — siblings - return (request.SiblingScripts ?? Array.Empty()) - .Select(s => MakeCallCompletion(s, "sibling script")) - .ToList(); } } @@ -298,24 +638,25 @@ public class ScriptAnalysisService /// /// Builds a Monaco snippet that fills the call after the name, e.g. - /// Greet", ${1:name}, ${2:count}). The JS provider extends the - /// completion range over the auto-closed ") if Monaco inserted - /// one, so the snippet replaces the rest of the call cleanly. + /// Greet", new { name = ${1:name}, count = ${2:count} }). The JS + /// provider extends the completion range over the auto-closed ") if + /// Monaco inserted one, so the snippet replaces the rest of the call cleanly. /// private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail) { + // The runtime call API takes the arguments as an anonymous object; the + // snippet emits one member per declared parameter. string insertText; - int insertRules; + const int insertAsSnippet = 4; if (shape.Parameters.Count == 0) { insertText = shape.Name + "\")"; - insertRules = 4; } else { - var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}")); - insertText = $"{shape.Name}\", {args})"; - insertRules = 4; + var entries = string.Join(", ", shape.Parameters.Select((p, i) => + $"{p.Name} = ${{{i + 1}:{p.Name}}}")); + insertText = $"{shape.Name}\", new {{ {entries} }})"; } var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}")); var returnType = shape.ReturnType ?? "void"; @@ -324,7 +665,7 @@ public class ScriptAnalysisService InsertText: insertText, Detail: $"{detail} ({paramList}) -> {returnType}", Kind: "Method", - InsertTextRules: insertRules); + InsertTextRules: insertAsSnippet); } public FormatResponse Format(FormatRequest request) @@ -348,51 +689,14 @@ public class ScriptAnalysisService } } - public InlayHintsResponse InlayHints(InlayHintsRequest request) - { - if (string.IsNullOrEmpty(request.Code)) - return new InlayHintsResponse(Array.Empty()); - - var script = TryParse(request.Code); - if (script == null) return new InlayHintsResponse(Array.Empty()); - var (tree, _) = script.Value; - - IReadOnlyList? sharedShapes = null; - IReadOnlyList SharedShapes() => - sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); - - var hints = new List(); - foreach (var inv in tree.GetRoot().DescendantNodes().OfType()) - { - var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; - if (callee is not ("CallShared" or "CallScript")) continue; - if (inv.ArgumentList.Arguments.Count < 1) continue; - - var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; - if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; - var scriptName = nameArg.Token.ValueText; - if (string.IsNullOrEmpty(scriptName)) continue; - - ScriptShape? shape = callee == "CallShared" - ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) - : request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName); - if (shape == null) continue; - - for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) - { - var arg = inv.ArgumentList.Arguments[i]; - var p = shape.Parameters[i - 1]; - var pos = arg.Span.Start; - var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span; - hints.Add(new InlayHint( - Line: lineSpan.Start.Line + 1, - Column: lineSpan.Start.Character + 1, - Label: $"{p.Name}:")); - } - } - - return new InlayHintsResponse(hints); - } + /// + /// Parameter-name inlay hints are obsolete under the runtime call API: + /// Scripts.CallShared / Instance.CallScript pass arguments as an explicit + /// IReadOnlyDictionary literal ({ ["p"] = … }), which is + /// already self-labelling — there are no positional arguments to annotate. + /// + public InlayHintsResponse InlayHints(InlayHintsRequest request) => + new(Array.Empty()); public HoverResponse Hover(HoverRequest request) { @@ -429,19 +733,15 @@ public class ScriptAnalysisService if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null); - var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; + var call = ClassifyScriptCall(inv); + if (call.Kind == ScriptCallKind.None) return new HoverResponse(null); var rawName = token.ValueText; if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null); - ScriptShape? shape = null; - if (calleeName == "CallShared") - shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult() - .FirstOrDefault(s => s.Name == rawName); - else if (calleeName == "CallScript" && request.SiblingScripts != null) - shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName); - + var shape = ResolveCalledShape( + call, rawName, request.SiblingScripts, request.Children, request.Parent); if (shape == null) return new HoverResponse(null); - return new HoverResponse(FormatHover(shape, calleeName!)); + return new HoverResponse(FormatHover(shape, call)); } public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request) @@ -471,24 +771,20 @@ public class ScriptAnalysisService } if (inv == null) return empty; - var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; - if (calleeName is not ("CallShared" or "CallScript")) return empty; + var call = ClassifyScriptCall(inv); + if (call.Kind == ScriptCallKind.None) return empty; // First argument is the name literal; pull it out. if (inv.ArgumentList.Arguments.Count < 1) return empty; var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; var scriptName = nameArg?.Token.ValueText ?? ""; - ScriptShape? shape = null; - if (calleeName == "CallShared") - shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult() - .FirstOrDefault(s => s.Name == scriptName); - else if (request.SiblingScripts != null) - shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName); + var shape = ResolveCalledShape( + call, scriptName, request.SiblingScripts, request.Children, request.Parent); if (shape == null) return empty; var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList(); - var label = $"{calleeName}(\"{shape.Name}\"" + + var label = $"{CallLabel(call)}(\"{shape.Name}\"" + (paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")"; // ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because @@ -514,7 +810,7 @@ public class ScriptAnalysisService if (string.IsNullOrEmpty(code)) return null; try { - var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost)); + var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost)); var compilation = s.GetCompilation(); var tree = compilation.SyntaxTrees.FirstOrDefault(); return tree == null ? null : (tree, compilation); @@ -525,14 +821,13 @@ public class ScriptAnalysisService } } - private static string FormatHover(ScriptShape shape, string callee) + private static string FormatHover(ScriptShape shape, ScriptCallInfo call) { var ps = shape.Parameters.Count == 0 ? "(no parameters)" : string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}")); var rt = shape.ReturnType ?? "void"; - var kind = callee == "CallShared" ? "shared script" : "sibling script"; - return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```"; + return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```"; } private static List? TryGetDotMembers(SyntaxToken token, SemanticModel model) @@ -583,52 +878,85 @@ public class ScriptAnalysisService } } - private IEnumerable FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList? siblings) + private enum ScriptCallKind { None, Shared, Sibling, Child, Parent } + + /// A classified script-call invocation: which kind, and (for a child) the composition name. + private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName); + + /// + /// Classifies an invocation against the runtime call surface: + /// Scripts.CallShared(...), Instance.CallScript(...), + /// Children["X"].CallScript(...), and Parent.CallScript(...). + /// The first argument of each is the called script's name literal. + /// + private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv) { - var root = tree.GetRoot(); + if (inv.Expression is not MemberAccessExpressionSyntax ma) + return new ScriptCallInfo(ScriptCallKind.None, null); - IReadOnlyList? sharedShapes = null; - IReadOnlyList SharedShapes() => - sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); + var method = ma.Name.Identifier.ValueText; - foreach (var inv in root.DescendantNodes().OfType()) + if (method == "CallShared" + && ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts") + return new ScriptCallInfo(ScriptCallKind.Shared, null); + + if (method == "CallScript") { - var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; - if (callee is not ("CallShared" or "CallScript")) continue; - if (inv.ArgumentList.Arguments.Count < 1) continue; - - var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; - if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; - var scriptName = nameArg.Token.ValueText; - if (string.IsNullOrEmpty(scriptName)) continue; - - ScriptShape? shape = callee == "CallShared" - ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) - : siblings?.FirstOrDefault(s => s.Name == scriptName); - if (shape == null) continue; - - var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name - var expectedRequired = shape.Parameters.Count(p => p.Required); - var expectedTotal = shape.Parameters.Count; - - if (passedCount < expectedRequired || passedCount > expectedTotal) + if (ma.Expression is IdentifierNameSyntax iid) { - var span = inv.GetLocation().GetLineSpan().Span; - var expected = expectedRequired == expectedTotal - ? expectedTotal.ToString() - : $"{expectedRequired}–{expectedTotal}"; - yield return new DiagnosticMarker( - Severity: 8, - StartLineNumber: span.Start.Line + 1, - StartColumn: span.Start.Character + 1, - EndLineNumber: span.End.Line + 1, - EndColumn: span.End.Character + 1, - Message: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.", - Code: "SCADA004"); + if (iid.Identifier.ValueText == "Instance") + return new ScriptCallInfo(ScriptCallKind.Sibling, null); + if (iid.Identifier.ValueText == "Parent") + return new ScriptCallInfo(ScriptCallKind.Parent, null); } + if (ma.Expression is ElementAccessExpressionSyntax childElem + && childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children" + && childElem.ArgumentList.Arguments.Count == 1 + && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit + && cLit.IsKind(SyntaxKind.StringLiteralExpression)) + return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText); } + + return new ScriptCallInfo(ScriptCallKind.None, null); } + /// Human-readable call expression, e.g. Scripts.CallShared. + private static string CallLabel(ScriptCallInfo call) => call.Kind switch + { + ScriptCallKind.Shared => "Scripts.CallShared", + ScriptCallKind.Sibling => "Instance.CallScript", + ScriptCallKind.Parent => "Parent.CallScript", + ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript", + _ => "call" + }; + + /// Short description of what the call targets, for completions/hover. + private static string CallDetail(ScriptCallInfo call) => call.Kind switch + { + ScriptCallKind.Shared => "shared script", + ScriptCallKind.Sibling => "sibling script", + ScriptCallKind.Parent => "parent script", + ScriptCallKind.Child => $"script on {call.CompositionName}", + _ => "script" + }; + + /// Resolves the called script's shape from the metadata in scope for its kind. + private ScriptShape? ResolveCalledShape( + ScriptCallInfo call, + string scriptName, + IReadOnlyList? siblings, + IReadOnlyList? children, + CompositionContext? parent) => call.Kind switch + { + ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult() + .FirstOrDefault(s => s.Name == scriptName), + ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName), + ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName), + ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName) + ?.Scripts.FirstOrDefault(s => s.Name == scriptName), + _ => null + }; + /// /// SCADA006 — flag Attributes["typo"], /// Children["X"].Attributes["typo"], and @@ -758,112 +1086,6 @@ public class ScriptAnalysisService return new(AttributeContextKind.None, null); } - private IEnumerable FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList? siblings) - { - var root = tree.GetRoot(); - - IReadOnlyList? sharedShapes = null; - IReadOnlyList SharedShapes() => - sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); - - foreach (var inv in root.DescendantNodes().OfType()) - { - var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; - if (callee is not ("CallShared" or "CallScript")) continue; - if (inv.ArgumentList.Arguments.Count < 1) continue; - var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; - if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; - var scriptName = nameArg.Token.ValueText; - if (string.IsNullOrEmpty(scriptName)) continue; - - ScriptShape? shape = callee == "CallShared" - ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) - : siblings?.FirstOrDefault(s => s.Name == scriptName); - if (shape == null) continue; - - for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) - { - var arg = inv.ArgumentList.Arguments[i].Expression; - var p = shape.Parameters[i - 1]; - var literalType = LiteralTypeOf(arg); - if (literalType == null) continue; // Not a literal we can check. - if (TypeAccepts(p.Type, literalType.Value)) continue; - var span = arg.GetLocation().GetLineSpan().Span; - yield return new DiagnosticMarker( - Severity: 8, - StartLineNumber: span.Start.Line + 1, - StartColumn: span.Start.Character + 1, - EndLineNumber: span.End.Line + 1, - EndColumn: span.End.Character + 1, - Message: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.", - Code: "SCADA005"); - } - } - } - - private enum LiteralKind { String, Integer, Float, Boolean, Null } - - private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr) - { - if (expr is LiteralExpressionSyntax lit) - { - if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String; - if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression)) - return LiteralKind.Boolean; - if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null; - if (lit.IsKind(SyntaxKind.NumericLiteralExpression)) - { - var text = lit.Token.Text; - return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase) - || text.EndsWith("d", StringComparison.OrdinalIgnoreCase) - ? LiteralKind.Float - : LiteralKind.Integer; - } - } - if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String; - return null; - } - - /// - /// True when a literal of is acceptable for a - /// parameter declared as . Object/List always - /// accept (we don't introspect collection literals); Null is acceptable - /// for any non-value type. - /// - private static bool TypeAccepts(string declared, LiteralKind literal) - { - var d = NormalizeDeclaredType(declared); - if (literal == LiteralKind.Null) return d is "Object" or "List" or "String"; - return d switch - { - "Boolean" => literal == LiteralKind.Boolean, - "Integer" => literal == LiteralKind.Integer, - "Float" => literal is LiteralKind.Float or LiteralKind.Integer, - "String" => literal == LiteralKind.String, - "Object" or "List" => true, - _ => true // unknown SCADA type — assume compatible - }; - } - - /// - /// Normalizes legacy / .NET type names from stored ParameterDefinitions - /// JSON to the canonical Inbound API set. Mirrors the frontend - /// ParameterListEditor's normalization so SCADA005 doesn't false-negative - /// on data still in the legacy shape. - /// - private static string NormalizeDeclaredType(string declared) => - declared.ToLowerInvariant() switch - { - "boolean" or "bool" => "Boolean", - "integer" or "int" or "int32" or "int64" or "int16" or "byte" - or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer", - "float" or "double" or "single" or "decimal" => "Float", - "string" or "datetime" => "String", - "object" => "Object", - "list" => "List", - _ => declared - }; - private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { var root = tree.GetRoot(); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs deleted file mode 100644 index 9a98951..0000000 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptHost.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace ScadaLink.CentralUI.ScriptAnalysis; - -/// -/// Globals type seen by user scripts during analysis. Mirrors the surface -/// the runtime exposes (see ScadaLink.SiteRuntime.Scripts.ScriptGlobals). -/// The methods and indexers here are never invoked — Roslyn only reads -/// their signatures to know what's in scope while compiling for diagnostics -/// and completions. -/// -public class ScriptHost -{ - public IReadOnlyDictionary Parameters { get; init; } = - new Dictionary(); - - /// Invokes another shared script by name and returns its result. - public object? CallShared(string name, params object?[] args) => null; - - /// Invokes another script on the same template and returns its result. - public object? CallScript(string name, params object?[] args) => null; - - // Scope-aware accessors. SCADA-specific completion + diagnostics live in - // ScriptAnalysisService; these stubs exist so the bare Roslyn pass doesn't - // produce CS0103 errors on Attributes / Children / Parent. - - public AttributeBag Attributes { get; } = new(); - public ChildrenBag Children { get; } = new(); - public CompositionBag? Parent { get; } = new(); - - public class AttributeBag - { - public object? this[string name] - { - get => null; - set { /* no-op for analyzer */ } - } - public System.Threading.Tasks.Task GetAsync(string name) => - System.Threading.Tasks.Task.FromResult(null); - public System.Threading.Tasks.Task SetAsync(string name, object? value) => - System.Threading.Tasks.Task.CompletedTask; - } - - public class CompositionBag - { - public AttributeBag Attributes { get; } = new(); - public System.Threading.Tasks.Task CallScript(string name, params object?[] args) => - System.Threading.Tasks.Task.FromResult(null); - } - - public class ChildrenBag - { - public CompositionBag this[string compositionName] => new(); - } -} diff --git a/src/ScadaLink.CentralUI/wwwroot/css/site.css b/src/ScadaLink.CentralUI/wwwroot/css/site.css index a61a98e..d8621ff 100644 --- a/src/ScadaLink.CentralUI/wwwroot/css/site.css +++ b/src/ScadaLink.CentralUI/wwwroot/css/site.css @@ -4,10 +4,23 @@ .sidebar { min-width: 220px; max-width: 220px; - min-height: 100vh; + height: 100vh; background-color: var(--bs-dark); } +/* Keep the sidebar pinned to the viewport on lg+ so it stays visible even + when the main content scrolls past 100vh. The wrapper is the flex child + of MainLayout; align-self prevents the flex row from stretching it. */ +@media (min-width: 992px) { + #sidebar-collapse { + position: sticky; + top: 0; + height: 100vh; + align-self: flex-start; + z-index: 1020; + } +} + .sidebar .nav-link { color: var(--bs-gray-500); padding: 0.4rem 1rem; @@ -51,7 +64,7 @@ .sidebar { min-width: 100%; max-width: 100%; - min-height: auto; + height: auto; } } diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index a3230a4..501d94b 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -40,20 +40,23 @@ async function lookupContext(model) { const empty = { declaredParameters: [], siblingScripts: [], declaredParameterShapes: [], - selfAttributes: [], children: [], parent: null + selfAttributes: [], children: [], parent: null, scriptKind: 0 }; for (const key in editors) { if (editors[key].editor.getModel() === model) { try { const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext"); if (got) { + const kind = got.ScriptKind != null ? got.ScriptKind + : (got.scriptKind != null ? got.scriptKind : 0); return { declaredParameters: got.DeclaredParameters || got.declaredParameters || [], siblingScripts: got.SiblingScripts || got.siblingScripts || [], declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [], selfAttributes: got.SelfAttributes || got.selfAttributes || [], children: got.Children || got.children || [], - parent: got.Parent || got.parent || null + parent: got.Parent || got.parent || null, + scriptKind: kind }; } } catch (e) { /* fall through */ } @@ -82,7 +85,8 @@ siblingScripts: ctx.siblingScripts, selfAttributes: ctx.selfAttributes, children: ctx.children, - parent: ctx.parent + parent: ctx.parent, + kind: ctx.scriptKind }) }); if (!resp.ok) return { suggestions: [] }; @@ -269,7 +273,8 @@ body: JSON.stringify({ code: model.getValue(), declaredParameters: ctx.declaredParameters, - siblingScripts: ctx.siblingScripts + siblingScripts: ctx.siblingScripts, + kind: ctx.scriptKind }) }); if (!resp.ok) return []; diff --git a/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs b/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs index 8264358..9ea30df 100644 --- a/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs +++ b/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs @@ -7,7 +7,7 @@ public record AlarmStateChanged( string AlarmName, AlarmState State, int Priority, - DateTimeOffset Timestamp) + DateTimeOffset Timestamp) : ISiteStreamEvent { /// /// Severity level when is . diff --git a/src/ScadaLink.Commons/Messages/Streaming/AttributeValueChanged.cs b/src/ScadaLink.Commons/Messages/Streaming/AttributeValueChanged.cs index 64680c9..cbf0838 100644 --- a/src/ScadaLink.Commons/Messages/Streaming/AttributeValueChanged.cs +++ b/src/ScadaLink.Commons/Messages/Streaming/AttributeValueChanged.cs @@ -6,4 +6,4 @@ public record AttributeValueChanged( string AttributeName, object? Value, string Quality, - DateTimeOffset Timestamp); + DateTimeOffset Timestamp) : ISiteStreamEvent; diff --git a/src/ScadaLink.Commons/Messages/Streaming/ISiteStreamEvent.cs b/src/ScadaLink.Commons/Messages/Streaming/ISiteStreamEvent.cs new file mode 100644 index 0000000..a83599c --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Streaming/ISiteStreamEvent.cs @@ -0,0 +1,10 @@ +namespace ScadaLink.Commons.Messages.Streaming; + +/// +/// Marker interface for events published to the site-wide stream +/// (attribute value changes and alarm state changes). +/// +public interface ISiteStreamEvent +{ + string InstanceUniqueName { get; } +} diff --git a/src/ScadaLink.Commons/Types/ScriptArgs.cs b/src/ScadaLink.Commons/Types/ScriptArgs.cs new file mode 100644 index 0000000..3c095a1 --- /dev/null +++ b/src/ScadaLink.Commons/Types/ScriptArgs.cs @@ -0,0 +1,52 @@ +using System.Collections; +using System.Reflection; + +namespace ScadaLink.Commons.Types; + +/// +/// Normalizes the loosely-typed parameters argument of a script call +/// (Scripts.CallShared, Instance.CallScript, +/// Children["X"].CallScript, Parent.CallScript, +/// Route.To().Call) into the dictionary the runtime carries. +/// +/// Accepts: null; an existing dictionary; or any object whose public +/// properties become the parameter entries — so callers can pass an anonymous +/// object, new { name = "Bob", count = 3 }, instead of building a +/// Dictionary<string, object?> by hand. +/// +public static class ScriptArgs +{ + public static IReadOnlyDictionary? Normalize(object? parameters) + { + switch (parameters) + { + case null: + return null; + case IReadOnlyDictionary roDict: + return roDict; + case IDictionary dict: + return new Dictionary(dict); + case IDictionary raw: + { + var result = new Dictionary(); + foreach (DictionaryEntry entry in raw) + result[entry.Key?.ToString() ?? string.Empty] = entry.Value; + return result; + } + } + + var type = parameters.GetType(); + if (type.IsPrimitive || parameters is string or decimal) + throw new ArgumentException( + $"Script call parameters must be an object or dictionary, not {type.Name}.", + nameof(parameters)); + + var bag = new Dictionary(); + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length > 0) continue; + bag[prop.Name] = prop.GetValue(parameters); + } + return bag; + } +} diff --git a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs index 12f13f1..1e8f0c3 100644 --- a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs @@ -108,8 +108,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers // Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager Receive(msg => _deploymentManagerProxy.Forward(msg)); - // Inbound API Route.To().Call() — forward to Deployment Manager for instance routing + // Inbound API Route.To() — forward to Deployment Manager for instance routing Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); // Pattern 7: Remote Queries Receive(msg => diff --git a/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs b/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs index 25601cb..bff2c75 100644 --- a/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs +++ b/src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs @@ -47,6 +47,7 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat SiteId = report.SiteId, LatestReport = report, LastReportReceivedAt = now, + LastHeartbeatAt = now, LastSequenceNumber = report.SequenceNumber, IsOnline = true }; @@ -64,6 +65,7 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat var wasOffline = !existing.IsOnline; existing.LatestReport = report; existing.LastReportReceivedAt = now; + existing.LastHeartbeatAt = now; existing.LastSequenceNumber = report.SequenceNumber; existing.IsOnline = true; @@ -86,8 +88,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat if (!_siteStates.TryGetValue(siteId, out var state)) return; - if (receivedAt > state.LastReportReceivedAt) - state.LastReportReceivedAt = receivedAt; + if (receivedAt > state.LastHeartbeatAt) + state.LastHeartbeatAt = receivedAt; if (!state.IsOnline) { @@ -141,12 +143,15 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat var state = kvp.Value; if (!state.IsOnline) continue; - var elapsed = now - state.LastReportReceivedAt; + // Use LastHeartbeatAt — heartbeats arrive every ~5s from any + // healthy site node, so OfflineTimeout only fires when no node + // can reach central, not during single-node failovers. + var elapsed = now - state.LastHeartbeatAt; if (elapsed > _options.OfflineTimeout) { state.IsOnline = false; _logger.LogWarning( - "Site {SiteId} marked offline — no report for {Elapsed}s (timeout: {Timeout}s)", + "Site {SiteId} marked offline — no signal for {Elapsed}s (timeout: {Timeout}s)", state.SiteId, elapsed.TotalSeconds, _options.OfflineTimeout.TotalSeconds); } } diff --git a/src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs b/src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs new file mode 100644 index 0000000..b0cc9cd --- /dev/null +++ b/src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ScadaLink.HealthMonitoring; + +/// +/// Central-side counterpart to . +/// Periodically builds a SiteHealthReport for the central cluster itself +/// (siteId = ) and feeds it into the local +/// CentralHealthAggregator so the UI can render central as another card +/// on /monitoring/health. Only the cluster leader (Primary) generates +/// reports — the standby's aggregator catches up on failover when it +/// becomes Primary and starts its own loop. +/// +public class CentralHealthReportLoop : BackgroundService +{ + /// + /// Reserved siteId used to represent the central cluster in the + /// shared CentralHealthAggregator keyspace. + /// + public const string CentralSiteId = "central"; + + private readonly ISiteHealthCollector _collector; + private readonly ICentralHealthAggregator _aggregator; + private readonly IClusterNodeProvider _clusterNodeProvider; + private readonly HealthMonitoringOptions _options; + private readonly ILogger _logger; + + // Seeded with Unix-ms so reports from a newly-elected central leader + // always sort after reports from any prior leader for siteId="central". + private long _sequenceNumber = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + public CentralHealthReportLoop( + ISiteHealthCollector collector, + ICentralHealthAggregator aggregator, + IClusterNodeProvider clusterNodeProvider, + IOptions options, + ILogger logger) + { + _collector = collector; + _aggregator = aggregator; + _clusterNodeProvider = clusterNodeProvider; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Central health report loop starting, interval {Interval}s", + _options.ReportInterval.TotalSeconds); + + using var timer = new PeriodicTimer(_options.ReportInterval); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + var isPrimary = _clusterNodeProvider.SelfIsPrimary; + _collector.SetActiveNode(isPrimary); + + if (!isPrimary) + continue; + + _collector.SetClusterNodes(_clusterNodeProvider.GetClusterNodes()); + + var seq = Interlocked.Increment(ref _sequenceNumber); + var report = _collector.CollectReport(CentralSiteId); + var reportWithSeq = report with { SequenceNumber = seq }; + + _aggregator.ProcessReport(reportWithSeq); + + _logger.LogDebug("Generated central health report #{Seq}", seq); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate central health report"); + } + } + } +} diff --git a/src/ScadaLink.HealthMonitoring/HealthReportSender.cs b/src/ScadaLink.HealthMonitoring/HealthReportSender.cs index 6806571..7366e48 100644 --- a/src/ScadaLink.HealthMonitoring/HealthReportSender.cs +++ b/src/ScadaLink.HealthMonitoring/HealthReportSender.cs @@ -19,7 +19,13 @@ public class HealthReportSender : BackgroundService private readonly string _siteId; private readonly StoreAndForwardStorage? _sfStorage; private readonly IClusterNodeProvider? _clusterNodeProvider; - private long _sequenceNumber; + + // Seeded with Unix-ms at construction so reports from a freshly-active + // node always sort after reports from any prior active node for the same + // site. Without this seeding, failover would silently drop the new + // active's first reports because their per-process counter starts below + // the prior active's last sequence number. + private long _sequenceNumber = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); public HealthReportSender( ISiteHealthCollector collector, diff --git a/src/ScadaLink.HealthMonitoring/IClusterNodeProvider.cs b/src/ScadaLink.HealthMonitoring/IClusterNodeProvider.cs index c5de246..f71eb45 100644 --- a/src/ScadaLink.HealthMonitoring/IClusterNodeProvider.cs +++ b/src/ScadaLink.HealthMonitoring/IClusterNodeProvider.cs @@ -9,4 +9,11 @@ namespace ScadaLink.HealthMonitoring; public interface IClusterNodeProvider { IReadOnlyList GetClusterNodes(); + + /// + /// True when this node is currently the cluster leader (Primary) for the + /// provider's role scope. Used by the central report loop to decide which + /// node should generate the "central" health report. + /// + bool SelfIsPrimary { get; } } diff --git a/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs b/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs index d18774a..58c232f 100644 --- a/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs @@ -26,13 +26,16 @@ public static class ServiceCollectionExtensions } /// - /// Register central-side health aggregation services. + /// Register central-side health aggregation services. Includes the + /// that generates a self-report + /// for the central cluster so it appears on /monitoring/health. /// public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(); return services; } diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthState.cs b/src/ScadaLink.HealthMonitoring/SiteHealthState.cs index bd23cea..2b77ef0 100644 --- a/src/ScadaLink.HealthMonitoring/SiteHealthState.cs +++ b/src/ScadaLink.HealthMonitoring/SiteHealthState.cs @@ -9,7 +9,21 @@ public class SiteHealthState { public required string SiteId { get; init; } public SiteHealthReport LatestReport { get; set; } = null!; + + /// + /// Time the latest full was processed. + /// Used by the UI to surface report staleness during failover. + /// public DateTimeOffset LastReportReceivedAt { get; set; } + + /// + /// Time the most recent signal of any kind (full report OR ~5s heartbeat) + /// was received. Drives offline detection — heartbeats from the standby + /// keep the site marked online even when the active node is unable to + /// produce a report (mid-failover, brief stalls). + /// + public DateTimeOffset LastHeartbeatAt { get; set; } + public long LastSequenceNumber { get; set; } public bool IsOnline { get; set; } } diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index c8aac60..d1bd176 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -175,6 +175,11 @@ akka {{ /// private void RegisterCentralActors() { + // Feed this central node's hostname into the local health collector so + // the CentralHealthReportLoop's report identifies the active node. + var centralHealthCollector = _serviceProvider.GetService(); + centralHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname); + var siteClientFactory = new DefaultSiteClientFactory(); var centralCommActor = _actorSystem!.ActorOf( Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteClientFactory)), diff --git a/src/ScadaLink.Host/Health/AkkaClusterNodeProvider.cs b/src/ScadaLink.Host/Health/AkkaClusterNodeProvider.cs index 6caa325..828f78b 100644 --- a/src/ScadaLink.Host/Health/AkkaClusterNodeProvider.cs +++ b/src/ScadaLink.Host/Health/AkkaClusterNodeProvider.cs @@ -20,6 +20,19 @@ public class AkkaClusterNodeProvider : IClusterNodeProvider _siteRole = siteRole; } + public bool SelfIsPrimary + { + get + { + var system = _akkaService.ActorSystem; + if (system == null) return false; + var cluster = Cluster.Get(system); + if (cluster.SelfMember.Status != MemberStatus.Up) return false; + var leader = cluster.State.Leader; + return leader != null && leader.Equals(cluster.SelfAddress); + } + } + public IReadOnlyList GetClusterNodes() { var system = _akkaService.ActorSystem; diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index e1595c9..0ec5930 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -94,6 +94,14 @@ try builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // Cluster node status provider scoped to the Central role — feeds the + // CentralHealthReportLoop so the central cluster appears on /monitoring/health. + builder.Services.AddSingleton(sp => + { + var akkaService = sp.GetRequiredService(); + return new AkkaClusterNodeProvider(akkaService, "Central"); + }); + // Options binding SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:Security")); diff --git a/src/ScadaLink.InboundAPI/RouteHelper.cs b/src/ScadaLink.InboundAPI/RouteHelper.cs index aa05328..207b83a 100644 --- a/src/ScadaLink.InboundAPI/RouteHelper.cs +++ b/src/ScadaLink.InboundAPI/RouteHelper.cs @@ -1,5 +1,6 @@ using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.InboundApi; +using ScadaLink.Commons.Types; using ScadaLink.Communication; namespace ScadaLink.InboundAPI; @@ -51,18 +52,20 @@ public class RouteTarget } /// - /// Calls a script on the remote instance. Synchronous from API caller's perspective. + /// Calls a script on the remote instance. Synchronous from API caller's + /// perspective. may be a dictionary or an + /// anonymous object (new { name = "Bob" }) — see . /// public async Task Call( string scriptName, - IReadOnlyDictionary? parameters = null, + object? parameters = null, CancellationToken cancellationToken = default) { var siteId = await ResolveSiteAsync(cancellationToken); var correlationId = Guid.NewGuid().ToString(); var request = new RouteToCallRequest( - correlationId, _instanceCode, scriptName, parameters, DateTimeOffset.UtcNow); + correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow); var response = await _communicationService.RouteToCallAsync( siteId, request, cancellationToken); diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index b320a19..6c398c6 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -4,6 +4,7 @@ using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.InboundApi; +using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types.Enums; @@ -81,6 +82,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers // Inbound API Route.To().Call() — route to Instance Actors Receive(RouteInboundApiCall); + Receive(RouteInboundApiGetAttributes); + Receive(RouteInboundApiSetAttributes); // Internal startup messages Receive(HandleStartupConfigsLoaded); @@ -567,6 +570,75 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers } } + /// + /// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s) + /// call (or a central Test Run bound to the instance). Asks the Instance Actor + /// per attribute and combines the results. + /// + private void RouteInboundApiGetAttributes(RouteToGetAttributesRequest request) + { + if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) + { + Sender.Tell(new RouteToGetAttributesResponse( + request.CorrelationId, new Dictionary(), false, + $"Instance '{request.InstanceUniqueName}' not found on this site.", + DateTimeOffset.UtcNow)); + return; + } + + var sender = Sender; + var names = request.AttributeNames; + var asks = names + .Select(name => instanceActor.Ask( + new GetAttributeRequest( + request.CorrelationId, request.InstanceUniqueName, name, DateTimeOffset.UtcNow), + TimeSpan.FromSeconds(30))) + .ToArray(); + + Task.WhenAll(asks).ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var values = new Dictionary(); + for (var i = 0; i < names.Count; i++) + values[names[i]] = t.Result[i].Found ? t.Result[i].Value : null; + return new RouteToGetAttributesResponse( + request.CorrelationId, values, true, null, DateTimeOffset.UtcNow); + } + return new RouteToGetAttributesResponse( + request.CorrelationId, new Dictionary(), false, + t.Exception?.GetBaseException().Message ?? "Attribute read timed out", + DateTimeOffset.UtcNow); + }).PipeTo(sender); + } + + /// + /// Writes attribute values on a deployed instance for a Route.To().SetAttribute(s) + /// call (or a central Test Run bound to the instance). Writes are Tell'd to the + /// Instance Actor — serialized through its mailbox — and acknowledged optimistically, + /// matching the fire-and-forget semantics of Instance.SetAttribute. + /// + private void RouteInboundApiSetAttributes(RouteToSetAttributesRequest request) + { + if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) + { + Sender.Tell(new RouteToSetAttributesResponse( + request.CorrelationId, false, + $"Instance '{request.InstanceUniqueName}' not found on this site.", + DateTimeOffset.UtcNow)); + return; + } + + foreach (var (name, value) in request.AttributeValues) + { + instanceActor.Tell(new SetStaticAttributeCommand( + request.CorrelationId, request.InstanceUniqueName, name, value, DateTimeOffset.UtcNow)); + } + + Sender.Tell(new RouteToSetAttributesResponse( + request.CorrelationId, true, null, DateTimeOffset.UtcNow)); + } + /// /// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.). /// Persists artifacts to SiteStorageService and recompiles shared scripts. diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 85aa776..68a230b 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -216,26 +216,19 @@ public class InstanceActor : ReceiveActor PublishAndNotifyChildren(changed); // Persist asynchronously -- fire and forget since the actor is the source of truth - var self = Self; - var sender = Sender; + // and SetAttribute is called from scripts via Tell (no response consumer). + var instanceName = _instanceUniqueName; + var attributeName = command.AttributeName; + var logger = _logger; _storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value) .ContinueWith(t => { - var success = t.IsCompletedSuccessfully; - var error = t.Exception?.GetBaseException().Message; - if (!success) - { - // Value is already in memory; log the persistence failure - // In-memory state is authoritative - } - return new SetStaticAttributeResponse( - command.CorrelationId, - _instanceUniqueName, - command.AttributeName, - success, - error, - DateTimeOffset.UtcNow); - }).PipeTo(sender); + logger.LogWarning( + t.Exception?.GetBaseException(), + "Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative", + instanceName, + attributeName); + }, TaskContinuationOptions.OnlyOnFaulted); } /// diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs index 8b60f4b..d517102 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs @@ -61,7 +61,7 @@ public class CompositionAccessor public string ResolveScript(string scriptName) => Path.Length == 0 ? scriptName : Path + "." + scriptName; - public Task CallScript(string scriptName, IReadOnlyDictionary? parameters = null) + public Task CallScript(string scriptName, object? parameters = null) => _ctx.CallScript(ResolveScript(scriptName), parameters); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index c06f061..dc5db9b 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.ScriptExecution; +using ScadaLink.Commons.Types; namespace ScadaLink.SiteRuntime.Scripts; @@ -116,8 +117,10 @@ public class ScriptRuntimeContext /// Calls a sibling script on the same instance by name (Ask pattern). /// WP-20: Enforces recursion limit. /// WP-22: Uses Ask pattern for CallScript. + /// may be a dictionary or an anonymous object + /// (new { name = "Bob" }) — see . /// - public async Task CallScript(string scriptName, IReadOnlyDictionary? parameters = null) + public async Task CallScript(string scriptName, object? parameters = null) { var nextDepth = _currentCallDepth + 1; if (nextDepth > _maxCallDepth) @@ -131,7 +134,7 @@ public class ScriptRuntimeContext var correlationId = Guid.NewGuid().ToString(); var request = new ScriptCallRequest( scriptName, - parameters, + ScriptArgs.Normalize(parameters), nextDepth, correlationId); @@ -200,10 +203,12 @@ public class ScriptRuntimeContext /// /// WP-17: Executes a shared script inline (direct method call, not actor message). /// WP-20: Enforces recursion limit. + /// may be a dictionary or an anonymous + /// object (new { name = "Bob" }) — see . /// public async Task CallShared( string scriptName, - IReadOnlyDictionary? parameters = null, + object? parameters = null, CancellationToken cancellationToken = default) { var nextDepth = _currentCallDepth + 1; @@ -215,7 +220,8 @@ public class ScriptRuntimeContext throw new InvalidOperationException(msg); } - return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken); + return await _library.ExecuteAsync( + scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken); } } diff --git a/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs b/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs index 161e42a..3188cbb 100644 --- a/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs +++ b/src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs @@ -11,7 +11,8 @@ namespace ScadaLink.SiteRuntime.Streaming; /// /// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value /// and alarm state changes. Instance Actors publish events via fire-and-forget Tell. -/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow. +/// A BroadcastHub fans events out to per-subscriber graphs, each filtered by +/// instance name and bounded by a drop-oldest buffer. /// /// Filterable by instance name for debug view (WP-25). /// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors @@ -20,11 +21,13 @@ namespace ScadaLink.SiteRuntime.Streaming; public class SiteStreamManager : ISiteStreamSubscriber { private ActorSystem? _system; + private IMaterializer? _materializer; private readonly int _bufferSize; private readonly ILogger _logger; private readonly object _lock = new(); private IActorRef? _sourceActor; + private Source? _hubSource; private readonly Dictionary _subscriptions = new(); public SiteStreamManager( @@ -36,64 +39,73 @@ public class SiteStreamManager : ISiteStreamSubscriber } /// - /// Initializes the stream source. Must be called after ActorSystem is ready. + /// Initializes the broadcast stream. Must be called after ActorSystem is ready. /// The ActorSystem is passed here rather than via the constructor so that /// SiteStreamManager can be created by DI before the actor system exists. /// public void Initialize(ActorSystem system) { _system = system; - var materializer = _system.Materializer(); + _materializer = _system.Materializer(); - var source = Source.ActorRef( - _bufferSize, - OverflowStrategy.DropHead); + var (sourceActor, hubSource) = Source.ActorRef( + _bufferSize, + OverflowStrategy.DropHead) + .ToMaterialized( + BroadcastHub.Sink(bufferSize: 256), + Keep.Both) + .Run(_materializer); - var (actorRef, _) = source - .PreMaterialize(materializer); - - _sourceActor = actorRef; + _sourceActor = sourceActor; + _hubSource = hubSource; _logger.LogInformation( - "SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize); + "SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize); } /// - /// Publishes an attribute value change to the stream. + /// Publishes an attribute value change to the broadcast hub. /// Fire-and-forget — never blocks the calling actor. /// public void PublishAttributeValueChanged(AttributeValueChanged changed) { _sourceActor?.Tell(changed); - - // Also forward to filtered subscribers - ForwardToSubscribers(changed.InstanceUniqueName, changed); } /// - /// Publishes an alarm state change to the stream. + /// Publishes an alarm state change to the broadcast hub. /// Fire-and-forget — never blocks the calling actor. /// public void PublishAlarmStateChanged(AlarmStateChanged changed) { _sourceActor?.Tell(changed); - - // Also forward to filtered subscribers - ForwardToSubscribers(changed.InstanceUniqueName, changed); } /// /// WP-25: Subscribe to events for a specific instance (debug view). - /// Returns a subscription ID for unsubscribing. + /// Materializes a per-subscriber filtered stream off the BroadcastHub + /// with a drop-oldest buffer; returns a subscription ID for unsubscribing. /// public string Subscribe(string instanceName, IActorRef subscriber) { + if (_hubSource is null || _materializer is null) + throw new InvalidOperationException("SiteStreamManager.Initialize must be called before Subscribe"); + var subscriptionId = Guid.NewGuid().ToString(); + var capturedInstance = instanceName; + var capturedSubscriber = subscriber; + + var killSwitch = _hubSource + .Where(ev => ev.InstanceUniqueName == capturedInstance) + .Buffer(_bufferSize, OverflowStrategy.DropHead) + .ViaMaterialized(KillSwitches.Single(), Keep.Right) + .To(Sink.ForEach(ev => capturedSubscriber.Tell(ev))) + .Run(_materializer); lock (_lock) { _subscriptions[subscriptionId] = new SubscriptionInfo( - instanceName, subscriber, DateTimeOffset.UtcNow); + instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow); } _logger.LogDebug( @@ -104,44 +116,47 @@ public class SiteStreamManager : ISiteStreamSubscriber } /// - /// WP-25: Unsubscribe from instance events. + /// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber + /// stream graph via its KillSwitch. /// public bool Unsubscribe(string subscriptionId) { + SubscriptionInfo? info; lock (_lock) { - var removed = _subscriptions.Remove(subscriptionId); - if (removed) - { - _logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId); - } - return removed; + if (!_subscriptions.Remove(subscriptionId, out info)) + return false; } + + info.KillSwitch.Shutdown(); + _logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId); + return true; } /// /// WP-25: Remove all subscriptions for a specific subscriber actor. - /// Called when connection is interrupted. + /// Called when a connection is interrupted. /// public void RemoveSubscriber(IActorRef subscriber) { + List toShutdown; lock (_lock) { - var toRemove = _subscriptions + var matched = _subscriptions .Where(kvp => kvp.Value.Subscriber.Equals(subscriber)) - .Select(kvp => kvp.Key) .ToList(); + foreach (var kvp in matched) + _subscriptions.Remove(kvp.Key); + toShutdown = matched.Select(kvp => kvp.Value).ToList(); + } - foreach (var id in toRemove) - { - _subscriptions.Remove(id); - } + foreach (var info in toShutdown) + info.KillSwitch.Shutdown(); - if (toRemove.Count > 0) - { - _logger.LogDebug( - "Removed {Count} subscriptions for disconnected subscriber", toRemove.Count); - } + if (toShutdown.Count > 0) + { + _logger.LogDebug( + "Removed {Count} subscriptions for disconnected subscriber", toShutdown.Count); } } @@ -153,28 +168,9 @@ public class SiteStreamManager : ISiteStreamSubscriber get { lock (_lock) { return _subscriptions.Count; } } } - private void ForwardToSubscribers(string instanceName, object message) - { - lock (_lock) - { - foreach (var sub in _subscriptions.Values) - { - if (sub.InstanceName == instanceName) - { - // Fire-and-forget to subscriber - sub.Subscriber.Tell(message); - } - } - } - } - private record SubscriptionInfo( string InstanceName, IActorRef Subscriber, + IKillSwitch KillSwitch, DateTimeOffset SubscribedAt); } - -/// -/// Marker interface for events published to the site stream. -/// -public interface ISiteStreamEvent { } diff --git a/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs index 02f3d1e..5ec5ab1 100644 --- a/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs +++ b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs @@ -49,17 +49,25 @@ public class HealthReportSenderTests Assert.True(transport.SentReports.Count >= 2, $"Expected at least 2 reports, got {transport.SentReports.Count}"); - // Verify monotonic sequence numbers starting at 1 + // Verify strictly-monotonic sequence numbers and matching site id for (int i = 0; i < transport.SentReports.Count; i++) { - Assert.Equal(i + 1, transport.SentReports[i].SequenceNumber); + if (i > 0) + { + Assert.True( + transport.SentReports[i].SequenceNumber > transport.SentReports[i - 1].SequenceNumber, + $"Sequence numbers not strictly increasing at index {i}"); + } Assert.Equal("site-A", transport.SentReports[i].SiteId); } } [Fact] - public async Task SequenceNumberStartsAtOne() + public async Task FirstReportSequenceExceedsStartupUnixMs() { + // Reports are seeded with Unix-ms at construction so a freshly-active + // node always sorts after the prior active. Verify the first emitted + // sequence is at least the startup epoch. var transport = new FakeTransport(); var collector = new SiteHealthCollector(); collector.SetActiveNode(true); @@ -68,6 +76,7 @@ public class HealthReportSenderTests ReportInterval = TimeSpan.FromMilliseconds(50) }); + var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var sender = new HealthReportSender( collector, transport, @@ -85,7 +94,9 @@ public class HealthReportSenderTests catch (OperationCanceledException) { } Assert.True(transport.SentReports.Count >= 1); - Assert.Equal(1, transport.SentReports[0].SequenceNumber); + Assert.True( + transport.SentReports[0].SequenceNumber >= beforeCtor, + $"First sequence {transport.SentReports[0].SequenceNumber} should be >= startup epoch {beforeCtor}"); } [Fact] @@ -126,19 +137,21 @@ public class HealthReportSenderTests } [Fact] - public void InitialSequenceNumberIsZero() + public void InitialSequenceNumberSeededWithUnixMs() { var transport = new FakeTransport(); var collector = new SiteHealthCollector(); var options = Options.Create(new HealthMonitoringOptions()); + var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var sender = new HealthReportSender( collector, transport, options, NullLogger.Instance, new FakeSiteIdentityProvider()); + var afterCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - Assert.Equal(0, sender.CurrentSequenceNumber); + Assert.InRange(sender.CurrentSequenceNumber, beforeCtor, afterCtor); } }