feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding

The Test Run sandbox and Monaco analysis modelled a script API that had
drifted from the site runtime's ScriptGlobals, so real scripts failed to
compile in Test Run. Realign both to the runtime surface
(Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the
duplicate ScriptHost stub so the two cannot diverge again.

- Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call)
  accept an anonymous object instead of a hand-built dictionary, via a
  shared ScriptArgs normalizer; existing dictionary calls still compile.
- Test Run can optionally bind to a deployed instance, so Instance/
  Attributes/CallScript route to it cross-site; adds site-side
  RouteToGetAttributes/RouteToSetAttributes handlers.
- Adds Test Run panels to the API method and template script editors.
- Fixes the TestDatabaseQuery seed script, which queried a table that
  never existed.

Also commits unrelated in-progress work already in the tree: the health
monitoring report loop, site streaming changes, and the Admin/Design
data-connection and SMTP page reorganization.
This commit is contained in:
Joseph Doherty
2026-05-16 03:37:56 -04:00
parent d7b05b40e9
commit 295150751f
50 changed files with 2926 additions and 550 deletions

195
infra/mssql/seed-config.sql Normal file
View File

@@ -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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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;

View File

@@ -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"
}
]
}
]
}

124
infra/reseed.sh Executable file
View File

@@ -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"

View File

@@ -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"

220
infra/tools/dump_seed.py Executable file
View File

@@ -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()