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:
195
infra/mssql/seed-config.sql
Normal file
195
infra/mssql/seed-config.sql
Normal 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;
|
||||||
@@ -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
124
infra/reseed.sh
Executable 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"
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Tear down ScadaLink test infrastructure.
|
# 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:
|
# Usage:
|
||||||
# ./teardown.sh Stop containers and delete the SQL data volume
|
# ./teardown.sh Stop containers and delete the SQL data volume
|
||||||
# ./teardown.sh --images Also remove downloaded Docker images
|
# ./teardown.sh --images Also remove downloaded Docker images
|
||||||
@@ -44,4 +49,9 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Teardown complete."
|
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
220
infra/tools/dump_seed.py
Executable 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()
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/connections">Connections</NavLink>
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
<NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/smtp">SMTP Configuration</NavLink>
|
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@page "/design/smtp"
|
@page "/admin/smtp"
|
||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
|
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||||
@inject INotificationRepository NotificationRepository
|
@inject INotificationRepository NotificationRepository
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Entities.InboundApi
|
@using ScadaLink.Commons.Entities.InboundApi
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject IInboundApiRepository InboundApiRepository
|
@inject IInboundApiRepository InboundApiRepository
|
||||||
|
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
<label class="form-label">Script</label>
|
<label class="form-label">Script</label>
|
||||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||||
Language="csharp" Height="320px"
|
Language="csharp" Height="320px"
|
||||||
|
ScriptKind="ScadaLink.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
||||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
||||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
||||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||||
@@ -91,10 +94,92 @@
|
|||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||||
|
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||||
|
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_showTestRun)
|
||||||
|
{
|
||||||
|
<div class="card mt-3" id="test-run-panel">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<span class="fw-semibold">Test Run</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||||
|
<strong>Heads up:</strong>
|
||||||
|
runs the script as typed (unsaved edits included) against the supplied
|
||||||
|
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
|
||||||
|
routing needs a deployed site reachable over the cluster transport.
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Parameter values</label>
|
||||||
|
<ParameterValueForm ParameterDefinitions="@_params"
|
||||||
|
Values="_paramValues"
|
||||||
|
ValuesChanged="@(v => _paramValues = v)" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-3">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Running…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Run</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
@if (_runResult.Success)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-success mb-1">
|
||||||
|
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-danger mb-1">
|
||||||
|
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||||
|
</label>
|
||||||
|
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||||
|
@if (_runResult.Markers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<ul class="small text-danger mt-2 mb-0">
|
||||||
|
@foreach (var m in _runResult.Markers)
|
||||||
|
{
|
||||||
|
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||||
|
{
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label small mb-1">Console output</label>
|
||||||
|
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,6 +199,12 @@
|
|||||||
private List<ApiKey> _allKeys = new();
|
private List<ApiKey> _allKeys = new();
|
||||||
private HashSet<int> _selectedKeyIds = new();
|
private HashSet<int> _selectedKeyIds = new();
|
||||||
|
|
||||||
|
private bool _showTestRun;
|
||||||
|
private bool _running;
|
||||||
|
private Dictionary<string, object?> _paramValues = new();
|
||||||
|
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||||
|
private CancellationTokenSource? _runCts;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -200,4 +291,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
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"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@page "/admin/connections/create"
|
@page "/design/connections/create"
|
||||||
@page "/admin/connections/{Id:int}/edit"
|
@page "/design/connections/{Id:int}/edit"
|
||||||
@page "/admin/data-connections/create"
|
@page "/design/data-connections/create"
|
||||||
@page "/admin/data-connections/{Id:int}/edit"
|
@page "/design/data-connections/{Id:int}/edit"
|
||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
@using ScadaLink.Commons.Serialization
|
@using ScadaLink.Commons.Serialization
|
||||||
@using ScadaLink.Commons.Validators
|
@using ScadaLink.Commons.Validators
|
||||||
@using ScadaLink.CentralUI.Components.Forms
|
@using ScadaLink.CentralUI.Components.Forms
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
await SiteRepository.AddDataConnectionAsync(conn);
|
await SiteRepository.AddDataConnectionAsync(conn);
|
||||||
}
|
}
|
||||||
await SiteRepository.SaveChangesAsync();
|
await SiteRepository.SaveChangesAsync();
|
||||||
NavigationManager.NavigateTo("/admin/connections");
|
NavigationManager.NavigateTo("/design/connections");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -237,5 +237,5 @@
|
|||||||
_formFailoverRetryCount = 3;
|
_formFailoverRetryCount = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
|
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
@page "/admin/connections"
|
@page "/design/connections"
|
||||||
@page "/admin/data-connections"
|
@page "/design/data-connections"
|
||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item"
|
<button class="dropdown-item"
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
|
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button class="dropdown-item"
|
<button class="dropdown-item"
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
|
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
|
|
||||||
private void AddConnectionForSite(int siteId)
|
private void AddConnectionForSite(int siteId)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo($"/admin/connections/create?siteId={siteId}");
|
NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSearchChanged()
|
private void OnSearchChanged()
|
||||||
@@ -14,8 +14,6 @@
|
|||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Integration Definitions</h4>
|
<h4 class="mb-0">Integration Definitions</h4>
|
||||||
<a class="btn btn-outline-secondary btn-sm"
|
|
||||||
href="/design/smtp">Email configuration →</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
@@ -67,15 +65,6 @@
|
|||||||
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
|
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link @(_tab == "apikeys" ? "active" : "")"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="@(_tab == "apikeys" ? "true" : "false")"
|
|
||||||
aria-controls="int-tab-apikeys"
|
|
||||||
@onclick='() => _tab = "apikeys"'>
|
|
||||||
API Keys <span class="badge bg-secondary">@_apiKeys.Count</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@if (_tab == "extsys")
|
@if (_tab == "extsys")
|
||||||
@@ -94,10 +83,6 @@
|
|||||||
{
|
{
|
||||||
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
|
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
|
||||||
}
|
}
|
||||||
else if (_tab == "apikeys")
|
|
||||||
{
|
|
||||||
<div role="tabpanel" id="int-tab-apikeys">@RenderApiKeys()</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,14 +107,6 @@
|
|||||||
? _dbConnections
|
? _dbConnections
|
||||||
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||||
|
|
||||||
// API Keys
|
|
||||||
private List<ApiKey> _apiKeys = new();
|
|
||||||
private string _apiKeySearch = "";
|
|
||||||
private IEnumerable<ApiKey> FilteredApiKeys =>
|
|
||||||
string.IsNullOrWhiteSpace(_apiKeySearch)
|
|
||||||
? _apiKeys
|
|
||||||
: _apiKeys.Where(k => k.Name?.Contains(_apiKeySearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
|
||||||
|
|
||||||
// Notification Lists
|
// Notification Lists
|
||||||
private List<NotificationList> _notificationLists = new();
|
private List<NotificationList> _notificationLists = new();
|
||||||
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
|
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
|
||||||
@@ -171,7 +148,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||||
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _errorMessage = ex.Message; }
|
catch (Exception ex) { _errorMessage = ex.Message; }
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@@ -478,67 +454,4 @@
|
|||||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== API Keys ====
|
|
||||||
private RenderFragment RenderApiKeys() => __builder =>
|
|
||||||
{
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<h5 class="mb-0">API Keys</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_apiKeys.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<p class="mb-3">No API keys configured. Add your first API key from the Admin section.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="mb-3" style="max-width: 320px;">
|
|
||||||
<input class="form-control form-control-sm"
|
|
||||||
placeholder="Filter by name…"
|
|
||||||
@bind="_apiKeySearch" @bind:event="oninput" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!FilteredApiKeys.Any())
|
|
||||||
{
|
|
||||||
<p class="text-muted small">No API keys match the filter.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
@foreach (var key in FilteredApiKeys)
|
|
||||||
{
|
|
||||||
<div class="col-lg-6 col-12" @key="key.Id">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<h5 class="card-title mb-0">@key.Name</h5>
|
|
||||||
<span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">
|
|
||||||
@(key.IsEnabled ? "Enabled" : "Disabled")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<button class="btn btn-outline-primary btn-sm"
|
|
||||||
@onclick="() => ToggleApiKeyEnabled(key)">
|
|
||||||
@(key.IsEnabled ? "Disable" : "Enable")
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
@inject SharedScriptService SharedScriptService
|
@inject SharedScriptService SharedScriptService
|
||||||
|
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
@@ -62,10 +63,92 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
|
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
|
||||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
|
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
|
||||||
|
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_showTestRun)
|
||||||
|
{
|
||||||
|
<div class="card mb-3" id="test-run-panel">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||||
|
<strong>Heads up:</strong>
|
||||||
|
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||||
|
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||||
|
<code>Attributes</code> and <code>CallScript</code> still throw.
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Parameter values</label>
|
||||||
|
<ParameterValueForm ParameterDefinitions="@_formParameters"
|
||||||
|
Values="_paramValues"
|
||||||
|
ValuesChanged="@(v => _paramValues = v)" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-3">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Running…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Run</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
@if (_runResult.Success)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-success mb-1">
|
||||||
|
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-danger mb-1">
|
||||||
|
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||||
|
</label>
|
||||||
|
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||||
|
@if (_runResult.Markers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<ul class="small text-danger mt-2 mb-0">
|
||||||
|
@foreach (var m in _runResult.Markers)
|
||||||
|
{
|
||||||
|
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||||
|
{
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label small mb-1">Console output</label>
|
||||||
|
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,6 +166,12 @@
|
|||||||
private MonacoEditor? _editor;
|
private MonacoEditor? _editor;
|
||||||
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
|
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
|
||||||
|
|
||||||
|
private bool _showTestRun;
|
||||||
|
private bool _running;
|
||||||
|
private Dictionary<string, object?> _paramValues = new();
|
||||||
|
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||||
|
private CancellationTokenSource? _runCts;
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
private async Task<string> GetCurrentUserAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Basic syntax check: balanced braces/brackets/parens.
|
/// Basic syntax check: balanced braces/brackets/parens.
|
||||||
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
|
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/design/templates/{Id:int}"
|
@page "/design/templates/{Id:int}"
|
||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Instances
|
||||||
@using ScadaLink.Commons.Entities.Templates
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@@ -8,7 +9,9 @@
|
|||||||
@using ScadaLink.TemplateEngine.Validation
|
@using ScadaLink.TemplateEngine.Validation
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject ICentralUiRepository CentralUiRepository
|
||||||
@inject TemplateService TemplateService
|
@inject TemplateService TemplateService
|
||||||
|
@inject ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@@ -106,6 +109,15 @@
|
|||||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
|
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
|
||||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
|
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
|
||||||
|
|
||||||
|
// Script modal Test Run state.
|
||||||
|
private bool _showScriptTestRun;
|
||||||
|
private bool _scriptRunning;
|
||||||
|
private Dictionary<string, object?> _scriptParamValues = new();
|
||||||
|
private ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult? _scriptRunResult;
|
||||||
|
private CancellationTokenSource? _scriptRunCts;
|
||||||
|
private List<Instance> _deployedInstances = new();
|
||||||
|
private string _scriptBindInstance = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Editor's Parent.* context. Empty for base templates (no owner exists);
|
/// Editor's Parent.* context. Empty for base templates (no owner exists);
|
||||||
/// exactly one entry for derived templates — the slot-owner resolved from
|
/// exactly one entry for derived templates — the slot-owner resolved from
|
||||||
@@ -185,6 +197,13 @@
|
|||||||
_editorChildren = await BuildChildContextsAsync(_compositions);
|
_editorChildren = await BuildChildContextsAsync(_compositions);
|
||||||
_editorParents = await BuildParentContextsAsync(Id);
|
_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;
|
_validationResult = null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -926,8 +945,117 @@
|
|||||||
{
|
{
|
||||||
<div class="text-danger small mt-2">@_scriptFormError</div>
|
<div class="text-danger small mt-2">@_scriptFormError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (_showScriptTestRun)
|
||||||
|
{
|
||||||
|
<div class="card mt-3" id="script-test-run-panel">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||||
|
<strong>Heads up:</strong>
|
||||||
|
runs the script as typed (unsaved edits included) against the supplied
|
||||||
|
<code>Parameters</code>.
|
||||||
|
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||||
|
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||||
|
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
|
||||||
|
@if (_deployedInstances.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="form-text">
|
||||||
|
No running instances of this template.
|
||||||
|
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="_scriptBindInstance">
|
||||||
|
<option value="">— None (Instance/Attributes throw) —</option>
|
||||||
|
@foreach (var inst in _deployedInstances)
|
||||||
|
{
|
||||||
|
<option value="@inst.UniqueName">@inst.UniqueName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Routes <code>Instance.GetAttribute/SetAttribute</code>,
|
||||||
|
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
|
||||||
|
<code>CallScript</code> to the selected live instance.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Parameter values</label>
|
||||||
|
<ParameterValueForm ParameterDefinitions="@_scriptParameters"
|
||||||
|
Values="_scriptParamValues"
|
||||||
|
ValuesChanged="@(v => _scriptParamValues = v)" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-3">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="RunScriptInSandboxAsync" disabled="@_scriptRunning">
|
||||||
|
@if (_scriptRunning)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Running…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Run</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (_scriptRunResult != null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@_scriptRunResult.DurationMs ms</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_scriptRunResult != null)
|
||||||
|
{
|
||||||
|
@if (_scriptRunResult.Success)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-success mb-1">
|
||||||
|
Return value <span class="badge bg-light text-dark ms-1">@_scriptRunResult.ReturnTypeName</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ReturnValueJson</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-danger mb-1">
|
||||||
|
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_scriptRunResult.ErrorKind)</span>
|
||||||
|
</label>
|
||||||
|
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_scriptRunResult.Error</pre>
|
||||||
|
@if (_scriptRunResult.Markers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<ul class="small text-danger mt-2 mb-0">
|
||||||
|
@foreach (var m in _scriptRunResult.Markers)
|
||||||
|
{
|
||||||
|
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_scriptRunResult.ConsoleOutput))
|
||||||
|
{
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label small mb-1">Console output</label>
|
||||||
|
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ConsoleOutput</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleScriptTestRunPanel">
|
||||||
|
@(_showScriptTestRun ? "Hide Test Run" : "Test Run")
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
|
||||||
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
|
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1341,6 +1469,7 @@
|
|||||||
_scriptReturn = null;
|
_scriptReturn = null;
|
||||||
_scriptIsLocked = false;
|
_scriptIsLocked = false;
|
||||||
_scriptModalTab = "code";
|
_scriptModalTab = "code";
|
||||||
|
ResetScriptTestRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BeginEditScript(TemplateScript script)
|
private void BeginEditScript(TemplateScript script)
|
||||||
@@ -1356,6 +1485,7 @@
|
|||||||
_scriptReturn = script.ReturnDefinition;
|
_scriptReturn = script.ReturnDefinition;
|
||||||
_scriptIsLocked = script.IsLocked;
|
_scriptIsLocked = script.IsLocked;
|
||||||
_scriptModalTab = "code";
|
_scriptModalTab = "code";
|
||||||
|
ResetScriptTestRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelScriptForm()
|
private void CancelScriptForm()
|
||||||
@@ -1363,8 +1493,69 @@
|
|||||||
_showScriptForm = false;
|
_showScriptForm = false;
|
||||||
_editScriptId = null;
|
_editScriptId = null;
|
||||||
_scriptFormError = 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()
|
private async Task SaveScript()
|
||||||
{
|
{
|
||||||
if (_selectedTemplate == null) return;
|
if (_selectedTemplate == null) return;
|
||||||
|
|||||||
@@ -51,10 +51,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Per-site detail cards *@
|
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
|
||||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
@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}";
|
var detailsCollapseId = $"site-details-{siteId}";
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||||
@@ -67,10 +68,12 @@
|
|||||||
{
|
{
|
||||||
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
||||||
}
|
}
|
||||||
<strong class="fs-5">@siteName (@siteId)</strong>
|
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" /> | Seq: @state.LastSequenceNumber
|
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" />
|
||||||
|
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||||
|
| Seq: @state.LastSequenceNumber
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime globals surface the script is analyzed against. Defaults to
|
||||||
|
/// template/shared-script globals; set to <c>InboundApi</c> on the API
|
||||||
|
/// method editor so <c>Route</c> and <c>Parameters</c> type-check.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parameter names declared on the form (derived from the SchemaBuilder's
|
/// Parameter names declared on the form (derived from the SchemaBuilder's
|
||||||
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
|
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
|
||||||
@@ -148,7 +155,8 @@
|
|||||||
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
|
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
|
||||||
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
|
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
|
||||||
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
|
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
|
||||||
Parent);
|
Parent,
|
||||||
|
ScriptKind);
|
||||||
|
|
||||||
private async Task FormatAsync()
|
private async Task FormatAsync()
|
||||||
{
|
{
|
||||||
@@ -189,5 +197,6 @@
|
|||||||
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
|
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
|
||||||
ScriptAnalysis.AttributeShape[] SelfAttributes,
|
ScriptAnalysis.AttributeShape[] SelfAttributes,
|
||||||
ScriptAnalysis.CompositionContext[] Children,
|
ScriptAnalysis.CompositionContext[] Children,
|
||||||
ScriptAnalysis.CompositionContext? Parent);
|
ScriptAnalysis.CompositionContext? Parent,
|
||||||
|
ScriptAnalysis.ScriptKind ScriptKind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
{
|
||||||
|
<div class="text-muted small fst-italic">No parameters declared.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
@foreach (var shape in Shapes)
|
||||||
|
{
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label small mb-0" for="@FieldId(shape)">
|
||||||
|
<code>@shape.Name</code>
|
||||||
|
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
@RenderInput(shape)
|
||||||
|
@if (_parseErrors.TryGetValue(shape.Name, out var err))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1">@err</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? ParameterDefinitions { get; set; }
|
||||||
|
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
||||||
|
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyList<ParameterShape> Shapes =>
|
||||||
|
ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, string> _rawText = new();
|
||||||
|
private readonly Dictionary<string, string> _parseErrors = new();
|
||||||
|
|
||||||
|
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
|
||||||
|
|
||||||
|
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
|
||||||
|
{
|
||||||
|
switch (shape.Type)
|
||||||
|
{
|
||||||
|
case "Boolean":
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
|
||||||
|
checked="@AsBool(shape.Name)"
|
||||||
|
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Integer":
|
||||||
|
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
|
||||||
|
value="@AsRaw(shape.Name)"
|
||||||
|
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Float":
|
||||||
|
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
|
||||||
|
value="@AsRaw(shape.Name)"
|
||||||
|
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "String":
|
||||||
|
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
|
||||||
|
value="@AsRaw(shape.Name)"
|
||||||
|
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // Object, List, List<...>, unknown
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
|
||||||
|
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
|
||||||
|
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,17 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
|
|||||||
public interface ISharedScriptCatalog
|
public interface ISharedScriptCatalog
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
|
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SharedScriptSource(string Name, string Code, string? ParameterDefinitions, string? ReturnDefinition);
|
||||||
|
|
||||||
public class SharedScriptCatalog : ISharedScriptCatalog
|
public class SharedScriptCatalog : ISharedScriptCatalog
|
||||||
{
|
{
|
||||||
private readonly SharedScriptService _service;
|
private readonly SharedScriptService _service;
|
||||||
@@ -24,4 +33,12 @@ public class SharedScriptCatalog : ISharedScriptCatalog
|
|||||||
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
|
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SharedScriptSource?> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/ScadaLink.CentralUI/ScriptAnalysis/InboundScriptHost.cs
Normal file
56
src/ScadaLink.CentralUI/ScriptAnalysis/InboundScriptHost.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class InboundScriptHost
|
||||||
|
{
|
||||||
|
public ScriptParameters Parameters { get; init; } = new();
|
||||||
|
|
||||||
|
public RouteHelper Route { get; } = new();
|
||||||
|
|
||||||
|
public System.Threading.CancellationToken CancellationToken { get; }
|
||||||
|
|
||||||
|
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
|
||||||
|
public class RouteHelper
|
||||||
|
{
|
||||||
|
public RouteTarget To(string instanceCode) => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary>
|
||||||
|
public class RouteTarget
|
||||||
|
{
|
||||||
|
public System.Threading.Tasks.Task<object?> Call(
|
||||||
|
string scriptName,
|
||||||
|
object? parameters = null,
|
||||||
|
System.Threading.CancellationToken cancellationToken = default) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||||
|
|
||||||
|
public System.Threading.Tasks.Task<object?> GetAttribute(
|
||||||
|
string attributeName,
|
||||||
|
System.Threading.CancellationToken cancellationToken = default) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||||
|
|
||||||
|
public System.Threading.Tasks.Task<IReadOnlyDictionary<string, object?>> GetAttributes(
|
||||||
|
IEnumerable<string> attributeNames,
|
||||||
|
System.Threading.CancellationToken cancellationToken = default) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>(
|
||||||
|
new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
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<string, string> attributeValues,
|
||||||
|
System.Threading.CancellationToken cancellationToken = default) =>
|
||||||
|
System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs
Normal file
118
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User-facing surface for <c>ExternalSystem.Call</c> /
|
||||||
|
/// <c>ExternalSystem.CachedCall</c> 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
|
||||||
|
/// <see cref="ScriptSandboxException"/>; with a real client wired in (a Test
|
||||||
|
/// Run) calls hit the live HTTP path.
|
||||||
|
/// </summary>
|
||||||
|
public class SandboxExternalHelper
|
||||||
|
{
|
||||||
|
private readonly IExternalSystemClient? _client;
|
||||||
|
private readonly string _instanceName;
|
||||||
|
|
||||||
|
public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_instanceName = instanceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ExternalCallResult> Call(
|
||||||
|
string systemName,
|
||||||
|
string methodName,
|
||||||
|
IReadOnlyDictionary<string, object?>? 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<ExternalCallResult> CachedCall(
|
||||||
|
string systemName,
|
||||||
|
string methodName,
|
||||||
|
IReadOnlyDictionary<string, object?>? 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<DbConnection> 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<string, object?>? 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<NotificationResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime globals for an inbound API method Test Run. Mirrors
|
||||||
|
/// <see cref="InboundScriptHost"/>'s public surface so the same user code that
|
||||||
|
/// compiles for diagnostics also compiles against this type — but every
|
||||||
|
/// <c>Route</c> accessor throws <see cref="ScriptSandboxException"/> 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 <c>Parameters</c> still work, matching how <see cref="SandboxScriptHost"/>
|
||||||
|
/// throws on <c>Attributes</c> for shared scripts.
|
||||||
|
/// </summary>
|
||||||
|
public class SandboxInboundScriptHost
|
||||||
|
{
|
||||||
|
public ScriptParameters Parameters { get; init; } = new();
|
||||||
|
|
||||||
|
public CancellationToken CancellationToken { get; init; }
|
||||||
|
|
||||||
|
public RouteAccessor Route { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
|
||||||
|
public class RouteAccessor
|
||||||
|
{
|
||||||
|
public RouteTarget To(string instanceCode) => new(instanceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror of ScadaLink.InboundAPI.RouteTarget — every call throws.</summary>
|
||||||
|
public class RouteTarget
|
||||||
|
{
|
||||||
|
private readonly string _instanceCode;
|
||||||
|
|
||||||
|
internal RouteTarget(string instanceCode) => _instanceCode = instanceCode;
|
||||||
|
|
||||||
|
public Task<object?> Call(
|
||||||
|
string scriptName,
|
||||||
|
object? parameters = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw Unavailable($"Call(\"{scriptName}\")");
|
||||||
|
|
||||||
|
public Task<object?> GetAttribute(
|
||||||
|
string attributeName,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw Unavailable($"GetAttribute(\"{attributeName}\")");
|
||||||
|
|
||||||
|
public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
|
||||||
|
IEnumerable<string> 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<string, string> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backs the Test Run sandbox <c>Instance</c> when the run is bound to a real
|
||||||
|
/// deployed instance. Routes attribute reads/writes and sibling-script calls to
|
||||||
|
/// the instance cross-site via <see cref="CommunicationService"/> — the same
|
||||||
|
/// transport the inbound API's <c>Route.To()</c> uses. All calls run under the
|
||||||
|
/// Test Run's cancellation token, so the sandbox timeout still applies.
|
||||||
|
/// </summary>
|
||||||
|
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<object?> 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<string, string> { [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<object?> CallScriptAsync(
|
||||||
|
string canonicalScriptName, IReadOnlyDictionary<string, object?>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// <see cref="Kind"/> selects which globals surface the script is compiled
|
||||||
|
/// and run against — template/shared scripts see <see cref="SandboxScriptHost"/>,
|
||||||
|
/// inbound API method scripts see <see cref="SandboxInboundScriptHost"/>.
|
||||||
|
/// <see cref="BindInstanceUniqueName"/>, when set, binds the run to a deployed
|
||||||
|
/// instance so <c>Instance</c>/<c>Attributes</c> access routes to it cross-site
|
||||||
|
/// instead of throwing. Ignored for inbound API scripts.
|
||||||
|
/// </summary>
|
||||||
|
public record SandboxRunRequest(
|
||||||
|
string Code,
|
||||||
|
Dictionary<string, JsonElement>? Parameters,
|
||||||
|
int? TimeoutSeconds,
|
||||||
|
ScriptKind Kind = ScriptKind.Template,
|
||||||
|
string? BindInstanceUniqueName = null);
|
||||||
|
|
||||||
|
public enum SandboxErrorKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
CompileError,
|
||||||
|
SandboxLimitation,
|
||||||
|
RuntimeError,
|
||||||
|
Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a Test Run. <see cref="Markers"/> carries Roslyn diagnostics
|
||||||
|
/// when <see cref="ErrorKind"/> is CompileError so the UI can display them
|
||||||
|
/// the same way it does for the editor's live problems panel.
|
||||||
|
/// </summary>
|
||||||
|
public record SandboxRunResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReturnValueJson,
|
||||||
|
string? ReturnTypeName,
|
||||||
|
string ConsoleOutput,
|
||||||
|
string? Error,
|
||||||
|
SandboxErrorKind ErrorKind,
|
||||||
|
long DurationMs,
|
||||||
|
IReadOnlyList<DiagnosticMarker>? Markers);
|
||||||
236
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
Normal file
236
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxScriptHost.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime
|
||||||
|
/// <c>ScriptGlobals</c> 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 — <c>Instance.GetAttribute/SetAttribute/CallScript</c>,
|
||||||
|
/// <c>Attributes</c>, <c>Children</c>, <c>Parent</c> — need a live deployed
|
||||||
|
/// instance. With no instance bound they throw <see cref="ScriptSandboxException"/>;
|
||||||
|
/// with one bound (see <see cref="SandboxInstanceContext"/>) they route to it.
|
||||||
|
///
|
||||||
|
/// <c>ExternalSystem</c>, <c>Database</c>, <c>Notify</c>, and
|
||||||
|
/// <c>Scripts.CallShared</c> run against central's real services and fire for
|
||||||
|
/// real — they do not depend on a bound instance.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backs the sandbox <c>Instance</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISandboxInstanceGateway
|
||||||
|
{
|
||||||
|
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
|
||||||
|
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
|
||||||
|
Task<object?> CallScriptAsync(
|
||||||
|
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.ScriptRuntimeContext</c> —
|
||||||
|
/// the <c>Instance</c> 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. <c>ExternalSystem</c>/<c>Database</c>/
|
||||||
|
/// <c>Notify</c>/<c>Scripts</c> run against central's real services regardless
|
||||||
|
/// of binding.
|
||||||
|
/// </summary>
|
||||||
|
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, "<sandbox>");
|
||||||
|
Database = database ?? new SandboxDatabaseHelper(null, "<sandbox>");
|
||||||
|
Notify = notify ?? new SandboxNotifyHelper(null, "<sandbox>");
|
||||||
|
Scripts = scripts ?? new SandboxScriptCallHelper(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<object?> 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<object?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sandbox mirror of <c>ScriptRuntimeContext.ScriptCallHelper</c> —
|
||||||
|
/// <c>Scripts.CallShared(...)</c>. Compiles and runs the named shared script in
|
||||||
|
/// the same sandbox via the wired delegate.
|
||||||
|
/// </summary>
|
||||||
|
public class SandboxScriptCallHelper
|
||||||
|
{
|
||||||
|
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared;
|
||||||
|
|
||||||
|
public SandboxScriptCallHelper(
|
||||||
|
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
|
||||||
|
{
|
||||||
|
_callShared = callShared;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<object?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.AttributeAccessor</c> —
|
||||||
|
/// scope-aware <c>Attributes["X"]</c> access anchored at a canonical-name prefix.
|
||||||
|
/// </summary>
|
||||||
|
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<object?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.CompositionAccessor</c> —
|
||||||
|
/// a view of one composition: its attributes plus an invokable <c>CallScript</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<object?> CallScript(string scriptName, object? parameters = null)
|
||||||
|
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.ChildrenAccessor</c> —
|
||||||
|
/// dictionary-style access to child compositions.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct exception so the Test Run pipeline can label sandbox-only
|
||||||
|
/// limitations differently from genuine runtime errors in user code.
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptSandboxException : Exception
|
||||||
|
{
|
||||||
|
public ScriptSandboxException(string message) : base(message) { }
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which runtime globals surface a script is analyzed against. Template and
|
||||||
|
/// shared scripts see <see cref="SandboxScriptHost"/> (mirroring the site
|
||||||
|
/// runtime's ScriptGlobals); inbound API method scripts see
|
||||||
|
/// <see cref="InboundScriptHost"/> (with <c>Route</c> and <c>Parameters</c>).
|
||||||
|
/// </summary>
|
||||||
|
public enum ScriptKind
|
||||||
|
{
|
||||||
|
Template,
|
||||||
|
InboundApi
|
||||||
|
}
|
||||||
|
|
||||||
public record DiagnoseRequest(
|
public record DiagnoseRequest(
|
||||||
string Code,
|
string Code,
|
||||||
IReadOnlyList<string>? DeclaredParameters = null,
|
IReadOnlyList<string>? DeclaredParameters = null,
|
||||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||||
IReadOnlyList<CompositionContext>? Children = null,
|
IReadOnlyList<CompositionContext>? Children = null,
|
||||||
CompositionContext? Parent = null);
|
CompositionContext? Parent = null,
|
||||||
|
ScriptKind Kind = ScriptKind.Template);
|
||||||
|
|
||||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||||
|
|
||||||
@@ -31,7 +44,8 @@ public record CompletionsRequest(
|
|||||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||||
IReadOnlyList<CompositionContext>? Children = null,
|
IReadOnlyList<CompositionContext>? Children = null,
|
||||||
CompositionContext? Parent = null);
|
CompositionContext? Parent = null,
|
||||||
|
ScriptKind Kind = ScriptKind.Template);
|
||||||
|
|
||||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public static class ScriptAnalysisEndpoints
|
|||||||
group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) =>
|
group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) =>
|
||||||
Results.Ok(svc.InlayHints(req)));
|
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;
|
return endpoints;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
@@ -7,13 +10,16 @@ using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using Microsoft.CodeAnalysis.Text;
|
using Microsoft.CodeAnalysis.Text;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
||||||
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
|
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
|
||||||
/// in the shape Monaco's provider APIs expect.
|
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
|
||||||
|
/// completions in the shape Monaco's provider APIs expect.
|
||||||
///
|
///
|
||||||
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
|
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
|
||||||
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
|
/// 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:
|
/// Beyond plain C# analysis, layers SCADA-specific extensions:
|
||||||
/// - In-string completion of Parameters["..."] keys (from the request's
|
/// - In-string completion of Parameters["..."] keys (from the request's
|
||||||
/// DeclaredParameters), CallShared("...") names (from
|
/// DeclaredParameters), Scripts.CallShared("...") names (from
|
||||||
/// <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
|
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
|
||||||
/// (from the request's SiblingScripts).
|
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
|
||||||
|
/// (from the request's SiblingScripts / Children / Parent).
|
||||||
/// - Forbidden-API diagnostic for the documented script trust model,
|
/// - Forbidden-API diagnostic for the documented script trust model,
|
||||||
/// resolved against the SemanticModel so user identifiers that happen
|
/// resolved against the SemanticModel so user identifiers that happen
|
||||||
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
|
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
|
||||||
@@ -39,7 +46,9 @@ public class ScriptAnalysisService
|
|||||||
typeof(Enumerable).Assembly,
|
typeof(Enumerable).Assembly,
|
||||||
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
|
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
|
||||||
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
|
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
|
||||||
typeof(ScriptHost).Assembly)
|
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||||
|
typeof(Commons.Types.ScriptParameters).Assembly,
|
||||||
|
typeof(SandboxScriptHost).Assembly)
|
||||||
.AddImports(
|
.AddImports(
|
||||||
"System",
|
"System",
|
||||||
"System.Collections.Generic",
|
"System.Collections.Generic",
|
||||||
@@ -61,26 +70,46 @@ public class ScriptAnalysisService
|
|||||||
|
|
||||||
private readonly ISharedScriptCatalog _sharedScripts;
|
private readonly ISharedScriptCatalog _sharedScripts;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache)
|
public ScriptAnalysisService(
|
||||||
|
ISharedScriptCatalog sharedScripts,
|
||||||
|
IMemoryCache cache,
|
||||||
|
IServiceProvider services)
|
||||||
{
|
{
|
||||||
_sharedScripts = sharedScripts;
|
_sharedScripts = sharedScripts;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_services = services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Globals type a script of the given kind is compiled against.</summary>
|
||||||
|
private static Type GlobalsTypeFor(ScriptKind kind) =>
|
||||||
|
kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-enables the nullable annotation context for an analysis compilation.
|
||||||
|
/// Roslyn scripting defaults to a disabled nullable context, which makes any
|
||||||
|
/// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
|
||||||
|
/// <c>string?</c> legal without surfacing the nullable-flow warnings.
|
||||||
|
/// </summary>
|
||||||
|
private static Compilation WithNullableAnnotations(Compilation compilation) =>
|
||||||
|
compilation is CSharpCompilation cs
|
||||||
|
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
|
||||||
|
: compilation;
|
||||||
|
|
||||||
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(request.Code))
|
if (string.IsNullOrEmpty(request.Code))
|
||||||
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||||
|
|
||||||
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)
|
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
Script<object> script;
|
Script<object> script;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost));
|
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -91,7 +120,7 @@ public class ScriptAnalysisService
|
|||||||
return Cache(cacheKey, failure);
|
return Cache(cacheKey, failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
var compilation = script.GetCompilation();
|
var compilation = WithNullableAnnotations(script.GetCompilation());
|
||||||
var markers = compilation
|
var markers = compilation
|
||||||
.GetDiagnostics()
|
.GetDiagnostics()
|
||||||
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
|
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
|
||||||
@@ -104,8 +133,6 @@ public class ScriptAnalysisService
|
|||||||
var model = compilation.GetSemanticModel(tree);
|
var model = compilation.GetSemanticModel(tree);
|
||||||
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
||||||
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
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(FindUnknownAttributeKeys(tree, request));
|
||||||
markers.AddRange(FindUnknownChildren(tree, request.Children));
|
markers.AddRange(FindUnknownChildren(tree, request.Children));
|
||||||
}
|
}
|
||||||
@@ -113,6 +140,341 @@ public class ScriptAnalysisService
|
|||||||
return Cache(cacheKey, new DiagnoseResponse(markers));
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles and runs a script in the central process. The globals surface
|
||||||
|
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
|
||||||
|
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
|
||||||
|
/// scripts against <see cref="SandboxInboundScriptHost"/>.
|
||||||
|
/// Pure logic + the supplied Parameters always work.
|
||||||
|
/// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
|
||||||
|
/// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
|
||||||
|
/// central's real <see cref="IExternalSystemClient"/>,
|
||||||
|
/// <see cref="IDatabaseGateway"/>, and
|
||||||
|
/// <see cref="INotificationDeliveryService"/> — calls fire for real and
|
||||||
|
/// have production-equivalent side effects (HTTP, SQL, SMTP).
|
||||||
|
/// <c>CallShared</c> compiles and executes the named shared script in the
|
||||||
|
/// same sandbox, with a recursion limit of
|
||||||
|
/// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
|
||||||
|
/// because a shared script has no template siblings in this context.
|
||||||
|
/// For the SandboxInboundScriptHost surface, every <c>Route</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SandboxRunResult> 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<DiagnosticMarker>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<object> 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<IInstanceLocator>();
|
||||||
|
var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
|
||||||
|
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<IExternalSystemClient>();
|
||||||
|
var databaseGateway = _services.GetService<IDatabaseGateway>();
|
||||||
|
var notifyService = _services.GetService<INotificationDeliveryService>();
|
||||||
|
var external = new SandboxExternalHelper(externalClient, instanceLabel);
|
||||||
|
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
|
||||||
|
var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
|
||||||
|
|
||||||
|
var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
|
||||||
|
var compileCacheLock = new object();
|
||||||
|
var depth = 0;
|
||||||
|
|
||||||
|
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? 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<object>? 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<object> 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<string, object?>()),
|
||||||
|
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<string, object?> ConvertJsonParameters(
|
||||||
|
Dictionary<string, JsonElement>? parameters)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, object?>(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 ($"\"<unserializable: {ex.Message}>\"", 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)
|
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
|
||||||
{
|
{
|
||||||
_cache.Set(key, value, new MemoryCacheEntryOptions
|
_cache.Set(key, value, new MemoryCacheEntryOptions
|
||||||
@@ -137,7 +499,7 @@ public class ScriptAnalysisService
|
|||||||
Script<object> script;
|
Script<object> script;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
|
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
|
||||||
}
|
}
|
||||||
catch
|
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)
|
if (owner is InvocationExpressionSyntax inv)
|
||||||
{
|
{
|
||||||
var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
var call = ClassifyScriptCall(inv);
|
||||||
var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
|
switch (call.Kind)
|
||||||
var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;
|
{
|
||||||
|
case ScriptCallKind.Shared:
|
||||||
if (calleeName == "CallShared")
|
|
||||||
{
|
{
|
||||||
var shapes = await _sharedScripts.GetShapesAsync();
|
var shapes = await _sharedScripts.GetShapesAsync();
|
||||||
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
|
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||||
}
|
}
|
||||||
|
case ScriptCallKind.Sibling:
|
||||||
if (calleeName == "CallScript")
|
|
||||||
{
|
|
||||||
// Children["X"].CallScript("..." or Parent.CallScript("...
|
|
||||||
if (calleeMa != null)
|
|
||||||
{
|
|
||||||
// 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<CompositionContext>())
|
|
||||||
.FirstOrDefault(c => c.Name == compName);
|
|
||||||
if (comp != null)
|
|
||||||
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
|
|
||||||
return new List<CompletionItem>();
|
|
||||||
}
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain CallScript("...") — siblings
|
|
||||||
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
||||||
.Select(s => MakeCallCompletion(s, "sibling script"))
|
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||||
.ToList();
|
case ScriptCallKind.Parent:
|
||||||
|
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
|
||||||
|
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||||
|
case ScriptCallKind.Child:
|
||||||
|
{
|
||||||
|
var comp = (request.Children ?? Array.Empty<CompositionContext>())
|
||||||
|
.FirstOrDefault(c => c.Name == call.CompositionName);
|
||||||
|
return comp != null
|
||||||
|
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
|
||||||
|
: new List<CompletionItem>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,24 +638,25 @@ public class ScriptAnalysisService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a Monaco snippet that fills the call after the name, e.g.
|
/// Builds a Monaco snippet that fills the call after the name, e.g.
|
||||||
/// <c>Greet", ${1:name}, ${2:count})</c>. The JS provider extends the
|
/// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
|
||||||
/// completion range over the auto-closed <c>")</c> if Monaco inserted
|
/// provider extends the completion range over the auto-closed <c>")</c> if
|
||||||
/// one, so the snippet replaces the rest of the call cleanly.
|
/// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
|
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;
|
string insertText;
|
||||||
int insertRules;
|
const int insertAsSnippet = 4;
|
||||||
if (shape.Parameters.Count == 0)
|
if (shape.Parameters.Count == 0)
|
||||||
{
|
{
|
||||||
insertText = shape.Name + "\")";
|
insertText = shape.Name + "\")";
|
||||||
insertRules = 4;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
|
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
|
||||||
insertText = $"{shape.Name}\", {args})";
|
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
|
||||||
insertRules = 4;
|
insertText = $"{shape.Name}\", new {{ {entries} }})";
|
||||||
}
|
}
|
||||||
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
|
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
|
||||||
var returnType = shape.ReturnType ?? "void";
|
var returnType = shape.ReturnType ?? "void";
|
||||||
@@ -324,7 +665,7 @@ public class ScriptAnalysisService
|
|||||||
InsertText: insertText,
|
InsertText: insertText,
|
||||||
Detail: $"{detail} ({paramList}) -> {returnType}",
|
Detail: $"{detail} ({paramList}) -> {returnType}",
|
||||||
Kind: "Method",
|
Kind: "Method",
|
||||||
InsertTextRules: insertRules);
|
InsertTextRules: insertAsSnippet);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FormatResponse Format(FormatRequest request)
|
public FormatResponse Format(FormatRequest request)
|
||||||
@@ -348,51 +689,14 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public InlayHintsResponse InlayHints(InlayHintsRequest request)
|
/// <summary>
|
||||||
{
|
/// Parameter-name inlay hints are obsolete under the runtime call API:
|
||||||
if (string.IsNullOrEmpty(request.Code))
|
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
|
||||||
return new InlayHintsResponse(Array.Empty<InlayHint>());
|
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
|
||||||
|
/// already self-labelling — there are no positional arguments to annotate.
|
||||||
var script = TryParse(request.Code);
|
/// </summary>
|
||||||
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
|
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
|
||||||
var (tree, _) = script.Value;
|
new(Array.Empty<InlayHint>());
|
||||||
|
|
||||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
|
||||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
|
||||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
var hints = new List<InlayHint>();
|
|
||||||
foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HoverResponse Hover(HoverRequest request)
|
public HoverResponse Hover(HoverRequest request)
|
||||||
{
|
{
|
||||||
@@ -429,19 +733,15 @@ public class ScriptAnalysisService
|
|||||||
|
|
||||||
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
|
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;
|
var rawName = token.ValueText;
|
||||||
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
|
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
|
||||||
|
|
||||||
ScriptShape? shape = null;
|
var shape = ResolveCalledShape(
|
||||||
if (calleeName == "CallShared")
|
call, rawName, request.SiblingScripts, request.Children, request.Parent);
|
||||||
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);
|
|
||||||
|
|
||||||
if (shape == null) return new HoverResponse(null);
|
if (shape == null) return new HoverResponse(null);
|
||||||
return new HoverResponse(FormatHover(shape, calleeName!));
|
return new HoverResponse(FormatHover(shape, call));
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
|
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
|
||||||
@@ -471,24 +771,20 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
if (inv == null) return empty;
|
if (inv == null) return empty;
|
||||||
|
|
||||||
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
var call = ClassifyScriptCall(inv);
|
||||||
if (calleeName is not ("CallShared" or "CallScript")) return empty;
|
if (call.Kind == ScriptCallKind.None) return empty;
|
||||||
|
|
||||||
// First argument is the name literal; pull it out.
|
// First argument is the name literal; pull it out.
|
||||||
if (inv.ArgumentList.Arguments.Count < 1) return empty;
|
if (inv.ArgumentList.Arguments.Count < 1) return empty;
|
||||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||||
var scriptName = nameArg?.Token.ValueText ?? "";
|
var scriptName = nameArg?.Token.ValueText ?? "";
|
||||||
|
|
||||||
ScriptShape? shape = null;
|
var shape = ResolveCalledShape(
|
||||||
if (calleeName == "CallShared")
|
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
|
||||||
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
|
|
||||||
.FirstOrDefault(s => s.Name == scriptName);
|
|
||||||
else if (request.SiblingScripts != null)
|
|
||||||
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
|
|
||||||
if (shape == null) return empty;
|
if (shape == null) return empty;
|
||||||
|
|
||||||
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
|
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) : "") + ")";
|
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
|
||||||
|
|
||||||
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
|
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
|
||||||
@@ -514,7 +810,7 @@ public class ScriptAnalysisService
|
|||||||
if (string.IsNullOrEmpty(code)) return null;
|
if (string.IsNullOrEmpty(code)) return null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost));
|
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
|
||||||
var compilation = s.GetCompilation();
|
var compilation = s.GetCompilation();
|
||||||
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
||||||
return tree == null ? null : (tree, compilation);
|
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
|
var ps = shape.Parameters.Count == 0
|
||||||
? "(no parameters)"
|
? "(no parameters)"
|
||||||
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
|
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
|
||||||
var rt = shape.ReturnType ?? "void";
|
var rt = shape.ReturnType ?? "void";
|
||||||
var kind = callee == "CallShared" ? "shared script" : "sibling script";
|
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
|
||||||
return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
||||||
@@ -583,52 +878,85 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
private enum ScriptCallKind { None, Shared, Sibling, Child, Parent }
|
||||||
|
|
||||||
|
/// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
|
||||||
|
private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies an invocation against the runtime call surface:
|
||||||
|
/// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
|
||||||
|
/// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
|
||||||
|
/// The first argument of each is the called script's name literal.
|
||||||
|
/// </summary>
|
||||||
|
private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
|
||||||
{
|
{
|
||||||
var root = tree.GetRoot();
|
if (inv.Expression is not MemberAccessExpressionSyntax ma)
|
||||||
|
return new ScriptCallInfo(ScriptCallKind.None, null);
|
||||||
|
|
||||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
var method = ma.Name.Identifier.ValueText;
|
||||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
|
||||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
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 (ma.Expression is IdentifierNameSyntax iid)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var span = inv.GetLocation().GetLineSpan().Span;
|
if (iid.Identifier.ValueText == "Instance")
|
||||||
var expected = expectedRequired == expectedTotal
|
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
|
||||||
? expectedTotal.ToString()
|
if (iid.Identifier.ValueText == "Parent")
|
||||||
: $"{expectedRequired}–{expectedTotal}";
|
return new ScriptCallInfo(ScriptCallKind.Parent, null);
|
||||||
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 (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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Short description of what the call targets, for completions/hover.</summary>
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
|
||||||
|
private ScriptShape? ResolveCalledShape(
|
||||||
|
ScriptCallInfo call,
|
||||||
|
string scriptName,
|
||||||
|
IReadOnlyList<ScriptShape>? siblings,
|
||||||
|
IReadOnlyList<CompositionContext>? 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
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SCADA006 — flag <c>Attributes["typo"]</c>,
|
/// SCADA006 — flag <c>Attributes["typo"]</c>,
|
||||||
/// <c>Children["X"].Attributes["typo"]</c>, and
|
/// <c>Children["X"].Attributes["typo"]</c>, and
|
||||||
@@ -758,112 +1086,6 @@ public class ScriptAnalysisService
|
|||||||
return new(AttributeContextKind.None, null);
|
return new(AttributeContextKind.None, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
|
||||||
{
|
|
||||||
var root = tree.GetRoot();
|
|
||||||
|
|
||||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
|
||||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
|
||||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when a literal of <paramref name="literal"/> is acceptable for a
|
|
||||||
/// parameter declared as <paramref name="declared"/>. Object/List always
|
|
||||||
/// accept (we don't introspect collection literals); Null is acceptable
|
|
||||||
/// for any non-value type.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||||
{
|
{
|
||||||
var root = tree.GetRoot();
|
var root = tree.GetRoot();
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public class ScriptHost
|
|
||||||
{
|
|
||||||
public IReadOnlyDictionary<string, object?> Parameters { get; init; } =
|
|
||||||
new Dictionary<string, object?>();
|
|
||||||
|
|
||||||
/// <summary>Invokes another shared script by name and returns its result.</summary>
|
|
||||||
public object? CallShared(string name, params object?[] args) => null;
|
|
||||||
|
|
||||||
/// <summary>Invokes another script on the same template and returns its result.</summary>
|
|
||||||
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<object?> GetAsync(string name) =>
|
|
||||||
System.Threading.Tasks.Task.FromResult<object?>(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<object?> CallScript(string name, params object?[] args) =>
|
|
||||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ChildrenBag
|
|
||||||
{
|
|
||||||
public CompositionBag this[string compositionName] => new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,23 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background-color: var(--bs-dark);
|
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 {
|
.sidebar .nav-link {
|
||||||
color: var(--bs-gray-500);
|
color: var(--bs-gray-500);
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
@@ -51,7 +64,7 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,20 +40,23 @@
|
|||||||
async function lookupContext(model) {
|
async function lookupContext(model) {
|
||||||
const empty = {
|
const empty = {
|
||||||
declaredParameters: [], siblingScripts: [], declaredParameterShapes: [],
|
declaredParameters: [], siblingScripts: [], declaredParameterShapes: [],
|
||||||
selfAttributes: [], children: [], parent: null
|
selfAttributes: [], children: [], parent: null, scriptKind: 0
|
||||||
};
|
};
|
||||||
for (const key in editors) {
|
for (const key in editors) {
|
||||||
if (editors[key].editor.getModel() === model) {
|
if (editors[key].editor.getModel() === model) {
|
||||||
try {
|
try {
|
||||||
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
|
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
|
||||||
if (got) {
|
if (got) {
|
||||||
|
const kind = got.ScriptKind != null ? got.ScriptKind
|
||||||
|
: (got.scriptKind != null ? got.scriptKind : 0);
|
||||||
return {
|
return {
|
||||||
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
||||||
siblingScripts: got.SiblingScripts || got.siblingScripts || [],
|
siblingScripts: got.SiblingScripts || got.siblingScripts || [],
|
||||||
declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [],
|
declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [],
|
||||||
selfAttributes: got.SelfAttributes || got.selfAttributes || [],
|
selfAttributes: got.SelfAttributes || got.selfAttributes || [],
|
||||||
children: got.Children || got.children || [],
|
children: got.Children || got.children || [],
|
||||||
parent: got.Parent || got.parent || null
|
parent: got.Parent || got.parent || null,
|
||||||
|
scriptKind: kind
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) { /* fall through */ }
|
} catch (e) { /* fall through */ }
|
||||||
@@ -82,7 +85,8 @@
|
|||||||
siblingScripts: ctx.siblingScripts,
|
siblingScripts: ctx.siblingScripts,
|
||||||
selfAttributes: ctx.selfAttributes,
|
selfAttributes: ctx.selfAttributes,
|
||||||
children: ctx.children,
|
children: ctx.children,
|
||||||
parent: ctx.parent
|
parent: ctx.parent,
|
||||||
|
kind: ctx.scriptKind
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) return { suggestions: [] };
|
if (!resp.ok) return { suggestions: [] };
|
||||||
@@ -269,7 +273,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
code: model.getValue(),
|
code: model.getValue(),
|
||||||
declaredParameters: ctx.declaredParameters,
|
declaredParameters: ctx.declaredParameters,
|
||||||
siblingScripts: ctx.siblingScripts
|
siblingScripts: ctx.siblingScripts,
|
||||||
|
kind: ctx.scriptKind
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) return [];
|
if (!resp.ok) return [];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public record AlarmStateChanged(
|
|||||||
string AlarmName,
|
string AlarmName,
|
||||||
AlarmState State,
|
AlarmState State,
|
||||||
int Priority,
|
int Priority,
|
||||||
DateTimeOffset Timestamp)
|
DateTimeOffset Timestamp) : ISiteStreamEvent
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
|
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ public record AttributeValueChanged(
|
|||||||
string AttributeName,
|
string AttributeName,
|
||||||
object? Value,
|
object? Value,
|
||||||
string Quality,
|
string Quality,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp) : ISiteStreamEvent;
|
||||||
|
|||||||
10
src/ScadaLink.Commons/Messages/Streaming/ISiteStreamEvent.cs
Normal file
10
src/ScadaLink.Commons/Messages/Streaming/ISiteStreamEvent.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ScadaLink.Commons.Messages.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for events published to the site-wide stream
|
||||||
|
/// (attribute value changes and alarm state changes).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISiteStreamEvent
|
||||||
|
{
|
||||||
|
string InstanceUniqueName { get; }
|
||||||
|
}
|
||||||
52
src/ScadaLink.Commons/Types/ScriptArgs.cs
Normal file
52
src/ScadaLink.Commons/Types/ScriptArgs.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes the loosely-typed <c>parameters</c> argument of a script call
|
||||||
|
/// (<c>Scripts.CallShared</c>, <c>Instance.CallScript</c>,
|
||||||
|
/// <c>Children["X"].CallScript</c>, <c>Parent.CallScript</c>,
|
||||||
|
/// <c>Route.To().Call</c>) into the dictionary the runtime carries.
|
||||||
|
///
|
||||||
|
/// Accepts: <c>null</c>; an existing dictionary; or any object whose public
|
||||||
|
/// properties become the parameter entries — so callers can pass an anonymous
|
||||||
|
/// object, <c>new { name = "Bob", count = 3 }</c>, instead of building a
|
||||||
|
/// <c>Dictionary<string, object?></c> by hand.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScriptArgs
|
||||||
|
{
|
||||||
|
public static IReadOnlyDictionary<string, object?>? Normalize(object? parameters)
|
||||||
|
{
|
||||||
|
switch (parameters)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return null;
|
||||||
|
case IReadOnlyDictionary<string, object?> roDict:
|
||||||
|
return roDict;
|
||||||
|
case IDictionary<string, object?> dict:
|
||||||
|
return new Dictionary<string, object?>(dict);
|
||||||
|
case IDictionary raw:
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, object?>();
|
||||||
|
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<string, object?>();
|
||||||
|
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (prop.GetIndexParameters().Length > 0) continue;
|
||||||
|
bag[prop.Name] = prop.GetValue(parameters);
|
||||||
|
}
|
||||||
|
return bag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,8 +108,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
// Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager
|
// Pattern 6a: Debug Snapshot (one-shot) — forward to Deployment Manager
|
||||||
Receive<DebugSnapshotRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
Receive<DebugSnapshotRequest>(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<RouteToCallRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
Receive<RouteToCallRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||||
|
Receive<RouteToGetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||||
|
Receive<RouteToSetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
|
||||||
|
|
||||||
// Pattern 7: Remote Queries
|
// Pattern 7: Remote Queries
|
||||||
Receive<EventLogQueryRequest>(msg =>
|
Receive<EventLogQueryRequest>(msg =>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
|||||||
SiteId = report.SiteId,
|
SiteId = report.SiteId,
|
||||||
LatestReport = report,
|
LatestReport = report,
|
||||||
LastReportReceivedAt = now,
|
LastReportReceivedAt = now,
|
||||||
|
LastHeartbeatAt = now,
|
||||||
LastSequenceNumber = report.SequenceNumber,
|
LastSequenceNumber = report.SequenceNumber,
|
||||||
IsOnline = true
|
IsOnline = true
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,7 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
|||||||
var wasOffline = !existing.IsOnline;
|
var wasOffline = !existing.IsOnline;
|
||||||
existing.LatestReport = report;
|
existing.LatestReport = report;
|
||||||
existing.LastReportReceivedAt = now;
|
existing.LastReportReceivedAt = now;
|
||||||
|
existing.LastHeartbeatAt = now;
|
||||||
existing.LastSequenceNumber = report.SequenceNumber;
|
existing.LastSequenceNumber = report.SequenceNumber;
|
||||||
existing.IsOnline = true;
|
existing.IsOnline = true;
|
||||||
|
|
||||||
@@ -86,8 +88,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
|||||||
if (!_siteStates.TryGetValue(siteId, out var state))
|
if (!_siteStates.TryGetValue(siteId, out var state))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (receivedAt > state.LastReportReceivedAt)
|
if (receivedAt > state.LastHeartbeatAt)
|
||||||
state.LastReportReceivedAt = receivedAt;
|
state.LastHeartbeatAt = receivedAt;
|
||||||
|
|
||||||
if (!state.IsOnline)
|
if (!state.IsOnline)
|
||||||
{
|
{
|
||||||
@@ -141,12 +143,15 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
|||||||
var state = kvp.Value;
|
var state = kvp.Value;
|
||||||
if (!state.IsOnline) continue;
|
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)
|
if (elapsed > _options.OfflineTimeout)
|
||||||
{
|
{
|
||||||
state.IsOnline = false;
|
state.IsOnline = false;
|
||||||
_logger.LogWarning(
|
_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);
|
state.SiteId, elapsed.TotalSeconds, _options.OfflineTimeout.TotalSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs
Normal file
82
src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ScadaLink.HealthMonitoring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central-side counterpart to <see cref="HealthReportSender"/>.
|
||||||
|
/// Periodically builds a SiteHealthReport for the central cluster itself
|
||||||
|
/// (siteId = <see cref="CentralSiteId"/>) 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.
|
||||||
|
/// </summary>
|
||||||
|
public class CentralHealthReportLoop : BackgroundService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reserved siteId used to represent the central cluster in the
|
||||||
|
/// shared CentralHealthAggregator keyspace.
|
||||||
|
/// </summary>
|
||||||
|
public const string CentralSiteId = "central";
|
||||||
|
|
||||||
|
private readonly ISiteHealthCollector _collector;
|
||||||
|
private readonly ICentralHealthAggregator _aggregator;
|
||||||
|
private readonly IClusterNodeProvider _clusterNodeProvider;
|
||||||
|
private readonly HealthMonitoringOptions _options;
|
||||||
|
private readonly ILogger<CentralHealthReportLoop> _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<HealthMonitoringOptions> options,
|
||||||
|
ILogger<CentralHealthReportLoop> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,13 @@ public class HealthReportSender : BackgroundService
|
|||||||
private readonly string _siteId;
|
private readonly string _siteId;
|
||||||
private readonly StoreAndForwardStorage? _sfStorage;
|
private readonly StoreAndForwardStorage? _sfStorage;
|
||||||
private readonly IClusterNodeProvider? _clusterNodeProvider;
|
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(
|
public HealthReportSender(
|
||||||
ISiteHealthCollector collector,
|
ISiteHealthCollector collector,
|
||||||
|
|||||||
@@ -9,4 +9,11 @@ namespace ScadaLink.HealthMonitoring;
|
|||||||
public interface IClusterNodeProvider
|
public interface IClusterNodeProvider
|
||||||
{
|
{
|
||||||
IReadOnlyList<NodeStatus> GetClusterNodes();
|
IReadOnlyList<NodeStatus> GetClusterNodes();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
bool SelfIsPrimary { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ public static class ServiceCollectionExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register central-side health aggregation services.
|
/// Register central-side health aggregation services. Includes the
|
||||||
|
/// <see cref="CentralHealthReportLoop"/> that generates a self-report
|
||||||
|
/// for the central cluster so it appears on /monitoring/health.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services)
|
public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<CentralHealthAggregator>();
|
services.AddSingleton<CentralHealthAggregator>();
|
||||||
services.AddSingleton<ICentralHealthAggregator>(sp => sp.GetRequiredService<CentralHealthAggregator>());
|
services.AddSingleton<ICentralHealthAggregator>(sp => sp.GetRequiredService<CentralHealthAggregator>());
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
|
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
|
||||||
|
services.AddHostedService<CentralHealthReportLoop>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,21 @@ public class SiteHealthState
|
|||||||
{
|
{
|
||||||
public required string SiteId { get; init; }
|
public required string SiteId { get; init; }
|
||||||
public SiteHealthReport LatestReport { get; set; } = null!;
|
public SiteHealthReport LatestReport { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time the latest full <see cref="SiteHealthReport"/> was processed.
|
||||||
|
/// Used by the UI to surface report staleness during failover.
|
||||||
|
/// </summary>
|
||||||
public DateTimeOffset LastReportReceivedAt { get; set; }
|
public DateTimeOffset LastReportReceivedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; set; }
|
||||||
|
|
||||||
public long LastSequenceNumber { get; set; }
|
public long LastSequenceNumber { get; set; }
|
||||||
public bool IsOnline { get; set; }
|
public bool IsOnline { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ akka {{
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RegisterCentralActors()
|
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<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
|
||||||
|
centralHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname);
|
||||||
|
|
||||||
var siteClientFactory = new DefaultSiteClientFactory();
|
var siteClientFactory = new DefaultSiteClientFactory();
|
||||||
var centralCommActor = _actorSystem!.ActorOf(
|
var centralCommActor = _actorSystem!.ActorOf(
|
||||||
Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteClientFactory)),
|
Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteClientFactory)),
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ public class AkkaClusterNodeProvider : IClusterNodeProvider
|
|||||||
_siteRole = siteRole;
|
_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<NodeStatus> GetClusterNodes()
|
public IReadOnlyList<NodeStatus> GetClusterNodes()
|
||||||
{
|
{
|
||||||
var system = _akkaService.ActorSystem;
|
var system = _akkaService.ActorSystem;
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ try
|
|||||||
builder.Services.AddSingleton<AkkaHostedService>();
|
builder.Services.AddSingleton<AkkaHostedService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||||
|
|
||||||
|
// Cluster node status provider scoped to the Central role — feeds the
|
||||||
|
// CentralHealthReportLoop so the central cluster appears on /monitoring/health.
|
||||||
|
builder.Services.AddSingleton<IClusterNodeProvider>(sp =>
|
||||||
|
{
|
||||||
|
var akkaService = sp.GetRequiredService<AkkaHostedService>();
|
||||||
|
return new AkkaClusterNodeProvider(akkaService, "Central");
|
||||||
|
});
|
||||||
|
|
||||||
// Options binding
|
// Options binding
|
||||||
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
|
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
|
||||||
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.InboundApi;
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
|
|
||||||
namespace ScadaLink.InboundAPI;
|
namespace ScadaLink.InboundAPI;
|
||||||
@@ -51,18 +52,20 @@ public class RouteTarget
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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. <paramref name="parameters"/> may be a dictionary or an
|
||||||
|
/// anonymous object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<object?> Call(
|
public async Task<object?> Call(
|
||||||
string scriptName,
|
string scriptName,
|
||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
object? parameters = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
var request = new RouteToCallRequest(
|
var request = new RouteToCallRequest(
|
||||||
correlationId, _instanceCode, scriptName, parameters, DateTimeOffset.UtcNow);
|
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var response = await _communicationService.RouteToCallAsync(
|
var response = await _communicationService.RouteToCallAsync(
|
||||||
siteId, request, cancellationToken);
|
siteId, request, cancellationToken);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ScadaLink.Commons.Messages.Artifacts;
|
|||||||
using ScadaLink.Commons.Messages.DebugView;
|
using ScadaLink.Commons.Messages.DebugView;
|
||||||
using ScadaLink.Commons.Messages.Deployment;
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
using ScadaLink.Commons.Messages.InboundApi;
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
|
using ScadaLink.Commons.Messages.Instance;
|
||||||
using ScadaLink.Commons.Messages.Lifecycle;
|
using ScadaLink.Commons.Messages.Lifecycle;
|
||||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
@@ -81,6 +82,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
// Inbound API Route.To().Call() — route to Instance Actors
|
// Inbound API Route.To().Call() — route to Instance Actors
|
||||||
Receive<RouteToCallRequest>(RouteInboundApiCall);
|
Receive<RouteToCallRequest>(RouteInboundApiCall);
|
||||||
|
Receive<RouteToGetAttributesRequest>(RouteInboundApiGetAttributes);
|
||||||
|
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
|
||||||
|
|
||||||
// Internal startup messages
|
// Internal startup messages
|
||||||
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
|
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
|
||||||
@@ -567,6 +570,75 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void RouteInboundApiGetAttributes(RouteToGetAttributesRequest request)
|
||||||
|
{
|
||||||
|
if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
|
||||||
|
{
|
||||||
|
Sender.Tell(new RouteToGetAttributesResponse(
|
||||||
|
request.CorrelationId, new Dictionary<string, object?>(), 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<GetAttributeResponse>(
|
||||||
|
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<string, object?>();
|
||||||
|
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<string, object?>(), false,
|
||||||
|
t.Exception?.GetBaseException().Message ?? "Attribute read timed out",
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}).PipeTo(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
|
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
|
||||||
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
|
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
|
||||||
|
|||||||
@@ -216,26 +216,19 @@ public class InstanceActor : ReceiveActor
|
|||||||
PublishAndNotifyChildren(changed);
|
PublishAndNotifyChildren(changed);
|
||||||
|
|
||||||
// Persist asynchronously -- fire and forget since the actor is the source of truth
|
// Persist asynchronously -- fire and forget since the actor is the source of truth
|
||||||
var self = Self;
|
// and SetAttribute is called from scripts via Tell (no response consumer).
|
||||||
var sender = Sender;
|
var instanceName = _instanceUniqueName;
|
||||||
|
var attributeName = command.AttributeName;
|
||||||
|
var logger = _logger;
|
||||||
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
|
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
|
||||||
.ContinueWith(t =>
|
.ContinueWith(t =>
|
||||||
{
|
{
|
||||||
var success = t.IsCompletedSuccessfully;
|
logger.LogWarning(
|
||||||
var error = t.Exception?.GetBaseException().Message;
|
t.Exception?.GetBaseException(),
|
||||||
if (!success)
|
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
|
||||||
{
|
instanceName,
|
||||||
// Value is already in memory; log the persistence failure
|
attributeName);
|
||||||
// In-memory state is authoritative
|
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||||
}
|
|
||||||
return new SetStaticAttributeResponse(
|
|
||||||
command.CorrelationId,
|
|
||||||
_instanceUniqueName,
|
|
||||||
command.AttributeName,
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
DateTimeOffset.UtcNow);
|
|
||||||
}).PipeTo(sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class CompositionAccessor
|
|||||||
public string ResolveScript(string scriptName) =>
|
public string ResolveScript(string scriptName) =>
|
||||||
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
||||||
|
|
||||||
public Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
public Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||||
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Instance;
|
using ScadaLink.Commons.Messages.Instance;
|
||||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
namespace ScadaLink.SiteRuntime.Scripts;
|
namespace ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
@@ -116,8 +117,10 @@ public class ScriptRuntimeContext
|
|||||||
/// Calls a sibling script on the same instance by name (Ask pattern).
|
/// Calls a sibling script on the same instance by name (Ask pattern).
|
||||||
/// WP-20: Enforces recursion limit.
|
/// WP-20: Enforces recursion limit.
|
||||||
/// WP-22: Uses Ask pattern for CallScript.
|
/// WP-22: Uses Ask pattern for CallScript.
|
||||||
|
/// <paramref name="parameters"/> may be a dictionary or an anonymous object
|
||||||
|
/// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
public async Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||||
{
|
{
|
||||||
var nextDepth = _currentCallDepth + 1;
|
var nextDepth = _currentCallDepth + 1;
|
||||||
if (nextDepth > _maxCallDepth)
|
if (nextDepth > _maxCallDepth)
|
||||||
@@ -131,7 +134,7 @@ public class ScriptRuntimeContext
|
|||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
var request = new ScriptCallRequest(
|
var request = new ScriptCallRequest(
|
||||||
scriptName,
|
scriptName,
|
||||||
parameters,
|
ScriptArgs.Normalize(parameters),
|
||||||
nextDepth,
|
nextDepth,
|
||||||
correlationId);
|
correlationId);
|
||||||
|
|
||||||
@@ -200,10 +203,12 @@ public class ScriptRuntimeContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
||||||
/// WP-20: Enforces recursion limit.
|
/// WP-20: Enforces recursion limit.
|
||||||
|
/// <paramref name="parameters"/> may be a dictionary or an anonymous
|
||||||
|
/// object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<object?> CallShared(
|
public async Task<object?> CallShared(
|
||||||
string scriptName,
|
string scriptName,
|
||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
object? parameters = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var nextDepth = _currentCallDepth + 1;
|
var nextDepth = _currentCallDepth + 1;
|
||||||
@@ -215,7 +220,8 @@ public class ScriptRuntimeContext
|
|||||||
throw new InvalidOperationException(msg);
|
throw new InvalidOperationException(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
|
return await _library.ExecuteAsync(
|
||||||
|
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace ScadaLink.SiteRuntime.Streaming;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
|
/// 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.
|
/// 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).
|
/// Filterable by instance name for debug view (WP-25).
|
||||||
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
|
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
|
||||||
@@ -20,11 +21,13 @@ namespace ScadaLink.SiteRuntime.Streaming;
|
|||||||
public class SiteStreamManager : ISiteStreamSubscriber
|
public class SiteStreamManager : ISiteStreamSubscriber
|
||||||
{
|
{
|
||||||
private ActorSystem? _system;
|
private ActorSystem? _system;
|
||||||
|
private IMaterializer? _materializer;
|
||||||
private readonly int _bufferSize;
|
private readonly int _bufferSize;
|
||||||
private readonly ILogger<SiteStreamManager> _logger;
|
private readonly ILogger<SiteStreamManager> _logger;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
private IActorRef? _sourceActor;
|
private IActorRef? _sourceActor;
|
||||||
|
private Source<ISiteStreamEvent, NotUsed>? _hubSource;
|
||||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||||
|
|
||||||
public SiteStreamManager(
|
public SiteStreamManager(
|
||||||
@@ -36,64 +39,73 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// The ActorSystem is passed here rather than via the constructor so that
|
||||||
/// SiteStreamManager can be created by DI before the actor system exists.
|
/// SiteStreamManager can be created by DI before the actor system exists.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Initialize(ActorSystem system)
|
public void Initialize(ActorSystem system)
|
||||||
{
|
{
|
||||||
_system = system;
|
_system = system;
|
||||||
var materializer = _system.Materializer();
|
_materializer = _system.Materializer();
|
||||||
|
|
||||||
var source = Source.ActorRef<ISiteStreamEvent>(
|
var (sourceActor, hubSource) = Source.ActorRef<ISiteStreamEvent>(
|
||||||
_bufferSize,
|
_bufferSize,
|
||||||
OverflowStrategy.DropHead);
|
OverflowStrategy.DropHead)
|
||||||
|
.ToMaterialized(
|
||||||
|
BroadcastHub.Sink<ISiteStreamEvent>(bufferSize: 256),
|
||||||
|
Keep.Both)
|
||||||
|
.Run(_materializer);
|
||||||
|
|
||||||
var (actorRef, _) = source
|
_sourceActor = sourceActor;
|
||||||
.PreMaterialize(materializer);
|
_hubSource = hubSource;
|
||||||
|
|
||||||
_sourceActor = actorRef;
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize);
|
"SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// Fire-and-forget — never blocks the calling actor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void PublishAttributeValueChanged(AttributeValueChanged changed)
|
public void PublishAttributeValueChanged(AttributeValueChanged changed)
|
||||||
{
|
{
|
||||||
_sourceActor?.Tell(changed);
|
_sourceActor?.Tell(changed);
|
||||||
|
|
||||||
// Also forward to filtered subscribers
|
|
||||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// Fire-and-forget — never blocks the calling actor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void PublishAlarmStateChanged(AlarmStateChanged changed)
|
public void PublishAlarmStateChanged(AlarmStateChanged changed)
|
||||||
{
|
{
|
||||||
_sourceActor?.Tell(changed);
|
_sourceActor?.Tell(changed);
|
||||||
|
|
||||||
// Also forward to filtered subscribers
|
|
||||||
ForwardToSubscribers(changed.InstanceUniqueName, changed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-25: Subscribe to events for a specific instance (debug view).
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Subscribe(string instanceName, IActorRef subscriber)
|
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 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<ISiteStreamEvent>(), Keep.Right)
|
||||||
|
.To(Sink.ForEach<ISiteStreamEvent>(ev => capturedSubscriber.Tell(ev)))
|
||||||
|
.Run(_materializer);
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_subscriptions[subscriptionId] = new SubscriptionInfo(
|
_subscriptions[subscriptionId] = new SubscriptionInfo(
|
||||||
instanceName, subscriber, DateTimeOffset.UtcNow);
|
instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
@@ -104,44 +116,47 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-25: Unsubscribe from instance events.
|
/// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber
|
||||||
|
/// stream graph via its KillSwitch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Unsubscribe(string subscriptionId)
|
public bool Unsubscribe(string subscriptionId)
|
||||||
{
|
{
|
||||||
|
SubscriptionInfo? info;
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var removed = _subscriptions.Remove(subscriptionId);
|
if (!_subscriptions.Remove(subscriptionId, out info))
|
||||||
if (removed)
|
return false;
|
||||||
{
|
}
|
||||||
|
|
||||||
|
info.KillSwitch.Shutdown();
|
||||||
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
||||||
}
|
return true;
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-25: Remove all subscriptions for a specific subscriber actor.
|
/// WP-25: Remove all subscriptions for a specific subscriber actor.
|
||||||
/// Called when connection is interrupted.
|
/// Called when a connection is interrupted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RemoveSubscriber(IActorRef subscriber)
|
public void RemoveSubscriber(IActorRef subscriber)
|
||||||
{
|
{
|
||||||
|
List<SubscriptionInfo> toShutdown;
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var toRemove = _subscriptions
|
var matched = _subscriptions
|
||||||
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
|
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
|
||||||
.Select(kvp => kvp.Key)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
foreach (var kvp in matched)
|
||||||
foreach (var id in toRemove)
|
_subscriptions.Remove(kvp.Key);
|
||||||
{
|
toShutdown = matched.Select(kvp => kvp.Value).ToList();
|
||||||
_subscriptions.Remove(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toRemove.Count > 0)
|
foreach (var info in toShutdown)
|
||||||
|
info.KillSwitch.Shutdown();
|
||||||
|
|
||||||
|
if (toShutdown.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Removed {Count} subscriptions for disconnected subscriber", toRemove.Count);
|
"Removed {Count} subscriptions for disconnected subscriber", toShutdown.Count);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,28 +168,9 @@ public class SiteStreamManager : ISiteStreamSubscriber
|
|||||||
get { lock (_lock) { return _subscriptions.Count; } }
|
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(
|
private record SubscriptionInfo(
|
||||||
string InstanceName,
|
string InstanceName,
|
||||||
IActorRef Subscriber,
|
IActorRef Subscriber,
|
||||||
|
IKillSwitch KillSwitch,
|
||||||
DateTimeOffset SubscribedAt);
|
DateTimeOffset SubscribedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marker interface for events published to the site stream.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISiteStreamEvent { }
|
|
||||||
|
|||||||
@@ -49,17 +49,25 @@ public class HealthReportSenderTests
|
|||||||
Assert.True(transport.SentReports.Count >= 2,
|
Assert.True(transport.SentReports.Count >= 2,
|
||||||
$"Expected at least 2 reports, got {transport.SentReports.Count}");
|
$"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++)
|
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);
|
Assert.Equal("site-A", transport.SentReports[i].SiteId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 transport = new FakeTransport();
|
||||||
var collector = new SiteHealthCollector();
|
var collector = new SiteHealthCollector();
|
||||||
collector.SetActiveNode(true);
|
collector.SetActiveNode(true);
|
||||||
@@ -68,6 +76,7 @@ public class HealthReportSenderTests
|
|||||||
ReportInterval = TimeSpan.FromMilliseconds(50)
|
ReportInterval = TimeSpan.FromMilliseconds(50)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
var sender = new HealthReportSender(
|
var sender = new HealthReportSender(
|
||||||
collector,
|
collector,
|
||||||
transport,
|
transport,
|
||||||
@@ -85,7 +94,9 @@ public class HealthReportSenderTests
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
|
|
||||||
Assert.True(transport.SentReports.Count >= 1);
|
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]
|
[Fact]
|
||||||
@@ -126,19 +137,21 @@ public class HealthReportSenderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InitialSequenceNumberIsZero()
|
public void InitialSequenceNumberSeededWithUnixMs()
|
||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
var collector = new SiteHealthCollector();
|
var collector = new SiteHealthCollector();
|
||||||
var options = Options.Create(new HealthMonitoringOptions());
|
var options = Options.Create(new HealthMonitoringOptions());
|
||||||
|
|
||||||
|
var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
var sender = new HealthReportSender(
|
var sender = new HealthReportSender(
|
||||||
collector,
|
collector,
|
||||||
transport,
|
transport,
|
||||||
options,
|
options,
|
||||||
NullLogger<HealthReportSender>.Instance,
|
NullLogger<HealthReportSender>.Instance,
|
||||||
new FakeSiteIdentityProvider());
|
new FakeSiteIdentityProvider());
|
||||||
|
var afterCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
Assert.Equal(0, sender.CurrentSequenceNumber);
|
Assert.InRange(sender.CurrentSequenceNumber, beforeCtor, afterCtor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user