6 Commits

Author SHA1 Message Date
Joseph Doherty 295150751f 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.
2026-05-16 03:37:56 -04:00
Joseph Doherty d7b05b40e9 fix(host): drop UseStaticFiles so MapStaticAssets controls caching
UseStaticFiles middleware ran before the MapStaticAssets endpoints and
served static assets (monaco-init.js, site.css, etc.) with no
Cache-Control header. Browsers then heuristically cached them and kept
serving stale copies across deploys — e.g. the Monaco editor ran an old
monaco-init.js that did not send the script kind, so inbound API method
scripts were analysed against the wrong globals and 'Route' was flagged
as undefined.

MapStaticAssets alone now serves every static asset, tagging
non-fingerprinted files with Cache-Control: no-cache so the browser
always revalidates via ETag.
2026-05-15 12:29:14 -04:00
Joseph Doherty e54c4a6c2e feat(ui/auth): use a minimal layout for the login page
The login page previously rendered inside MainLayout, showing the full
nav sidebar and the authenticated-user footer. It now uses a bare
LoginLayout (no nav, no session-expiry watchdog, no dialog host) and
just renders its own centred card.
2026-05-15 12:16:36 -04:00
Joseph Doherty fc18239b97 fix(ui/auth): stop /login redirect loop when the session is expired
SessionExpiry renders inside MainLayout, which also wraps the login
page. For a user with a still-present auth cookie but an expired
expires_at claim, it redirected /login back to /login indefinitely.
It now skips the redirect when already on the login page.
2026-05-15 12:14:57 -04:00
Joseph Doherty 1d5465f31c fix(deployment): instance delete fully removes the record
Deleting an instance only undeployed it from the site and set the state
to NotDeployed, leaving an orphan record that could never be removed —
the state-transition matrix rejected delete from NotDeployed.

Delete now removes the instance record entirely (deployment history,
snapshot, attribute/alarm overrides, and connection bindings go with
it), and is permitted from any state.
2026-05-15 12:05:13 -04:00
Joseph Doherty 17e24ddd20 fix(site-event-log): record script errors and route queries to the active node
Script execution failures were only written to Serilog, never to the
site event log — SiteRuntime did not reference the SiteEventLogging
project. ScriptExecutionActor now resolves ISiteEventLogger and emits a
'script'/'Error' event on timeout and exception.

The event-log query handler was a per-node actor bound to that node's
local SQLite. A ClusterClient query could land on the standby (which
records no events) and return nothing. The handler is now a cluster
singleton with a proxy, so queries always reach the active node.
2026-05-15 12:04:59 -04:00
61 changed files with 3015 additions and 574 deletions
+195
View File
@@ -0,0 +1,195 @@
-- ScadaLink design-data seed.
-- Auto-generated by infra/tools/dump_seed.py against ScadaLinkConfig.
-- Replays the design-time configuration (templates, scripts,
-- data connections, external systems). Idempotent: deletes
-- existing rows in the covered tables before inserting.
--
-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,
-- InstanceConnectionBindings, notifications, SMTP, API keys,
-- areas, LDAP mappings.
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
BEGIN TRAN;
-- Wipe existing design + dependent rows so the seed is idempotent.
-- Order matters: dependents first.
DELETE FROM DeployedConfigSnapshots;
DELETE FROM DeploymentRecords;
DELETE FROM InstanceAlarmOverrides;
DELETE FROM InstanceAttributeOverrides;
DELETE FROM InstanceConnectionBindings;
DELETE FROM Instances;
DELETE FROM ExternalSystemMethods;
DELETE FROM ExternalSystemDefinitions;
DELETE FROM DataConnections;
DELETE FROM SharedScripts;
DELETE FROM TemplateCompositions;
UPDATE TemplateAlarms SET OnTriggerScriptId = NULL;
DELETE FROM TemplateAlarms;
DELETE FROM TemplateScripts;
DELETE FROM TemplateAttributes;
UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL;
DELETE FROM Templates;
UPDATE TemplateFolders SET ParentFolderId = NULL;
DELETE FROM TemplateFolders;
-- TemplateFolders (1 rows)
SET IDENTITY_INSERT [TemplateFolders] ON;
INSERT INTO [TemplateFolders] ([Id], [Name], [ParentFolderId], [SortOrder]) VALUES (1002, N'Test', NULL, 0);
SET IDENTITY_INSERT [TemplateFolders] OFF;
-- Templates (18 rows)
SET IDENTITY_INSERT [Templates] ON;
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1, N'Base Device', N'Root template for all devices', NULL, NULL, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2, N'Pump', N'Centrifugal pump template', 1, NULL, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (3, N'Sensor Module', N'Reusable sensor feature module', NULL, 1002, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (4, N'Motor Controller', N'Motor with OPC UA tags from test server', NULL, 1002, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (5, N'Variable Speed Motor', N'VFD motor extending Motor Controller with sensor composition', 4, NULL, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1002, N'Tank Monitor', N'Tank level and temperature monitoring module', NULL, NULL, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2003, N'Pump.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 1);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2004, N'Variable Speed Motor.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 2);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2005, N'Motor Controller.CoolingTank', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1002);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2006, N'Motor Controller.CoolingTank2', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1003);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2007, N'aaa', NULL, 3, NULL, 0, NULL);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2008, N'Pump.AlarmSensor', N'Reusable sensor feature module', 3, NULL, 1, 1004);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2012, N'Tank Monitor.DrivePump', N'Centrifugal pump template', 2, NULL, 1, 1008);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2013, N'Tank Monitor.DrivePump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1009);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2014, N'Tank Monitor.DrivePump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1010);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2018, N'Motor Controller.Pump', N'Centrifugal pump template', 2, NULL, 1, 1014);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2019, N'Motor Controller.Pump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1015);
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2020, N'Motor Controller.Pump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1016);
SET IDENTITY_INSERT [Templates] OFF;
-- TemplateAttributes (48 rows)
SET IDENTITY_INSERT [TemplateAttributes] ON;
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'Status', N'Offline', N'String', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2, 1, N'Temperature', N'0.0', N'Double', 0, NULL, N'ns=3;s=Temperature', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (9, 3, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (10, 3, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (11, 5, N'MaxRPM', N'3600', N'Double', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (12, 5, N'MinRPM', N'0', N'Double', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'Weather', N'Unknown', N'String', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'Greeting', N'', N'String', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'Goodbye', N'', N'String', 0, NULL, NULL, 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1005, 1002, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1006, 1002, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1007, 1002, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1008, 1002, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2009, 4, N'TestBool', NULL, N'Boolean', 0, NULL, N'ns=3;s=TestChildObject.TestBool', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2010, 4, N'TestInt', NULL, N'Int32', 0, NULL, N'ns=3;s=TestChildObject.TestInt', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2011, 4, N'TestFloat', NULL, N'Float', 0, NULL, N'ns=3;s=TestChildObject.TestFloat', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2012, 4, N'TestDouble', NULL, N'Double', 0, NULL, N'ns=3;s=TestChildObject.TestDouble', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2013, 4, N'TestString', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestString', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2014, 4, N'TestDateTime', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTime', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2015, 4, N'TestBoolArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestBoolArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2016, 4, N'TestDateTimeArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTimeArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2017, 4, N'TestDoubleArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDoubleArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2018, 4, N'TestFloatArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestFloatArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2019, 4, N'TestIntArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestIntArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2020, 4, N'TestStringArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestStringArray', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2021, 4, N'ScanTime', NULL, N'String', 0, NULL, N'ns=3;s=DevAppEngine.Scheduler.ScanTime', 0, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3009, 2003, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3010, 2003, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3011, 2004, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3012, 2004, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3013, 2005, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3014, 2005, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3015, 2005, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3016, 2005, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3017, 2006, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3018, 2006, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3019, 2006, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3020, 2006, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3021, 2008, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3022, 2008, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3025, 2013, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3026, 2013, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3027, 2014, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3028, 2014, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3033, 2019, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3034, 2019, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3035, 2020, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3036, 2020, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
SET IDENTITY_INSERT [TemplateAttributes] OFF;
-- TemplateScripts (12 rows)
SET IDENTITY_INSERT [TemplateScripts] ON;
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'CheckTemp', 0, N'var temp = Instance.GetAttribute("Temperature");
if (temp.Value > 90.0) {
Instance.SetAttribute("Status", "HighTemp");
}', N'ValueChange', NULL, NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'TestExternalSystem', 0, N'var parms = new Dictionary<string, object?> { ["a"] = 2, ["b"] = 3 }; var result = await ExternalSystem.Call("Test REST API", "Add", parms); Instance.SetAttribute("Status", "API result: " + result.Response.result);', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'TestDatabaseQuery', 0, N'var conn = await Database.Connection("Machine Data DB"); var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM TagHistory"; var count = await cmd.ExecuteScalarAsync(); conn.Dispose(); Instance.SetAttribute("Status", "DB: " + count + " rows");', N'Interval', N'{"intervalMs":60000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'UpdateWeather', 0, N'var weather = await Scripts.CallShared("GetWeather"); Instance.SetAttribute("Weather", weather?.ToString() ?? "Unknown");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1005, 4, N'UpdateGreeting', 0, N'var parms = new Dictionary<string, object?> { ["name"] = "BOB" }; var greeting = await Scripts.CallShared("Greet", parms); Instance.SetAttribute("Greeting", greeting?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1007, 4, N'SayGoodbye', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Goodbye {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1008, 4, N'UpdateGoodbye', 0, N'var parms = new Dictionary<string, object?> { ["Name"] = "Bob" }; var result = await Instance.CallScript("SayGoodbye", parms); Instance.SetAttribute("Goodbye", result?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1009, 4, N'Hello', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Hello {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1010, 4, N'SendEmailAlert', 0, N'await Notify.To("Engineering Alerts").Send("Motor Status Update", "Motor check-in at " + DateTimeOffset.UtcNow.ToString("HH:mm:ss") + " UTC");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1011, 1002, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 0, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1012, 2005, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0);
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1013, 2006, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0);
SET IDENTITY_INSERT [TemplateScripts] OFF;
-- TemplateAlarms (4 rows)
SET IDENTITY_INSERT [TemplateAlarms] ON;
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1, 1, N'HighTemp', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Temperature","high":95.0}', NULL);
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1002, 1002, N'HighLevel', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Level","high":80}', NULL);
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1003, 2, N'RatePump', NULL, 750, 0, N'RateOfChange', N'{"attributeName":"AlarmSensor.SensorReading","thresholdPerSecond":25,"windowSeconds":2,"direction":"falling"}', NULL);
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1004, 2, N'TempLevels', NULL, 500, 0, N'HiLo', N'{"attributeName":"AlarmSensor.SensorReading","loLo":-10,"lo":5,"hi":80,"hiHi":100,"loLoPriority":900,"loPriority":600,"hiPriority":600,"hiHiPriority":900,"hiDeadband":3,"hiHiDeadband":5,"hiMessage":"Temperature high — investigate","hiHiMessage":"CRITICAL: shut down immediately"}', NULL);
SET IDENTITY_INSERT [TemplateAlarms] OFF;
-- TemplateCompositions (11 rows)
SET IDENTITY_INSERT [TemplateCompositions] ON;
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1, 2, 2003, N'TempSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (2, 5, 2004, N'TempSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1002, 4, 2005, N'CoolingTank');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1003, 4, 2006, N'CoolingTank2');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1004, 2, 2008, N'AlarmSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1008, 1002, 2012, N'DrivePump');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1009, 2012, 2013, N'TempSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1010, 2012, 2014, N'AlarmSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1014, 4, 2018, N'Pump');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1015, 2018, 2019, N'TempSensor');
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1016, 2018, 2020, N'AlarmSensor');
SET IDENTITY_INSERT [TemplateCompositions] OFF;
-- SharedScripts (2 rows)
SET IDENTITY_INSERT [SharedScripts] ON;
INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, N'GetWeather', N'var conditions = new[]
{
"Sunny",
"Cloudy",
"Rainy",
"Stormy",
"Windy",
"Foggy",
"Snowy",
"Clear"
};
var temps = new Random().Next(-10, 40);
var condition = conditions[new Random().Next(conditions.Length)];
return $"{condition}, {temps}°C";', NULL, N'{"type":"string"}');
INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (2, N'Greet', N'var name = (string)(Parameters?["name"] ?? "World"); return $"Hello, {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', N'{"type":"string"}');
SET IDENTITY_INSERT [SharedScripts] OFF;
-- DataConnections (3 rows)
SET IDENTITY_INSERT [DataConnections] ON;
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (1, N'OPC PLC Simulator', N'OpcUa', N'{"endpointUrl":"opc.tcp://scadalink-opcua:50000","securityMode":"none","autoAcceptUntrustedCerts":true,"sessionTimeoutMs":60000,"operationTimeoutMs":15000,"publishingIntervalMs":1000,"samplingIntervalMs":1000,"queueSize":10,"keepAliveCount":10,"lifetimeCount":30,"maxNotificationsPerPublish":100,"discardOldest":true,"subscriptionPriority":0,"subscriptionDisplayName":"ScadaLink","timestampsToReturn":"source","deadband":null,"userIdentity":null,"heartbeat":null}', 1, NULL, 3);
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3014, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 2, NULL, 3);
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3015, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 3, NULL, 3);
SET IDENTITY_INSERT [DataConnections] OFF;
-- ExternalSystemDefinitions (1 rows)
SET IDENTITY_INSERT [ExternalSystemDefinitions] ON;
INSERT INTO [ExternalSystemDefinitions] ([Id], [Name], [EndpointUrl], [AuthType], [AuthConfiguration], [MaxRetries], [RetryDelay]) VALUES (1, N'Test REST API', N'http://scadalink-restapi:5200', N'ApiKey', N'scadalink-test-key-1', 0, '00:00:00.000000');
SET IDENTITY_INSERT [ExternalSystemDefinitions] OFF;
-- ExternalSystemMethods (1 rows)
SET IDENTITY_INSERT [ExternalSystemMethods] ON;
INSERT INTO [ExternalSystemMethods] ([Id], [ExternalSystemDefinitionId], [Name], [HttpMethod], [Path], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, 1, N'Add', N'POST', N'/api/Add', N'{"a":"number","b":"number"}', N'{"result":"number"}');
SET IDENTITY_INSERT [ExternalSystemMethods] OFF;
COMMIT;
+152
View File
@@ -170,6 +170,158 @@
] ]
} }
] ]
},
{
"Folder": "DevAppEngine",
"NodeList": [],
"FolderList": [
{
"Folder": "Scheduler",
"NodeList": [
{
"NodeId": "DevAppEngine.Scheduler.ScanTime",
"Name": "ScanTime",
"DataType": "DateTime",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Current scan time for DevAppEngine"
}
]
}
]
},
{
"Folder": "Sensor",
"NodeList": [
{
"NodeId": "Sensor.Reading",
"Name": "Reading",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Generic sensor reading"
}
]
},
{
"Folder": "Misc",
"NodeList": [
{
"NodeId": "Temperature",
"Name": "Temperature",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Standalone Temperature tag (Base Device default)"
}
]
},
{
"Folder": "TestChildObject",
"NodeList": [
{
"NodeId": "TestChildObject.TestBool",
"Name": "TestBool",
"DataType": "Boolean",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar Boolean"
},
{
"NodeId": "TestChildObject.TestBoolArray",
"Name": "TestBoolArray",
"DataType": "Boolean",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test Boolean array"
},
{
"NodeId": "TestChildObject.TestDateTime",
"Name": "TestDateTime",
"DataType": "DateTime",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar DateTime"
},
{
"NodeId": "TestChildObject.TestDateTimeArray",
"Name": "TestDateTimeArray",
"DataType": "DateTime",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test DateTime array"
},
{
"NodeId": "TestChildObject.TestDouble",
"Name": "TestDouble",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar Double"
},
{
"NodeId": "TestChildObject.TestDoubleArray",
"Name": "TestDoubleArray",
"DataType": "Double",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test Double array"
},
{
"NodeId": "TestChildObject.TestFloat",
"Name": "TestFloat",
"DataType": "Float",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar Float"
},
{
"NodeId": "TestChildObject.TestFloatArray",
"Name": "TestFloatArray",
"DataType": "Float",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test Float array"
},
{
"NodeId": "TestChildObject.TestInt",
"Name": "TestInt",
"DataType": "Int32",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar Int32"
},
{
"NodeId": "TestChildObject.TestIntArray",
"Name": "TestIntArray",
"DataType": "Int32",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test Int32 array"
},
{
"NodeId": "TestChildObject.TestString",
"Name": "TestString",
"DataType": "String",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test scalar String"
},
{
"NodeId": "TestChildObject.TestStringArray",
"Name": "TestStringArray",
"DataType": "String",
"ValueRank": 1,
"ArrayDimensions": [4],
"AccessLevel": "CurrentReadOrWrite",
"Description": "Test String array"
}
]
} }
] ]
} }
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Full reseed of the ScadaLink test cluster.
#
# Tears down infra + app containers, drops the MSSQL volume, brings
# everything back, lets EF Core migrations create the schema, replays
# infra/mssql/seed-config.sql for templates/scripts/data-connections, and
# re-seeds sites via docker/seed-sites.sh.
#
# Usage:
# infra/reseed.sh Full reseed (default seed file)
# infra/reseed.sh --seed PATH Replay a different seed SQL
# infra/reseed.sh --skip-teardown Replay seed against running stack
#
# Prerequisites:
# - Docker / OrbStack running
# - Python 3 with pymssql (used by infra/tools/mssql_tool.py + dump_seed.py)
# - Built scadalink:latest image (docker/build.sh — deploy.sh runs it)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SEED_FILE="$SCRIPT_DIR/mssql/seed-config.sql"
SKIP_TEARDOWN=false
MGMT_URL="http://localhost:9000"
while [ $# -gt 0 ]; do
case "$1" in
--seed)
SEED_FILE="$2"
shift 2
;;
--skip-teardown)
SKIP_TEARDOWN=true
shift
;;
-h|--help)
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [ ! -f "$SEED_FILE" ]; then
echo "Seed file not found: $SEED_FILE" >&2
exit 1
fi
echo "=== ScadaLink Reseed ==="
echo "Seed file: $SEED_FILE"
echo ""
if ! $SKIP_TEARDOWN; then
echo "--- Stage 1/6: tear down application containers ---"
"$PROJECT_ROOT/docker/teardown.sh"
echo ""
echo "--- Stage 2/6: wipe site SQLite state ---"
shopt -s nullglob
for d in "$PROJECT_ROOT"/docker/site-*/data; do
rm -rf "$d"/*
echo " cleared $d"
done
shopt -u nullglob
echo ""
echo "--- Stage 3/6: tear down infra (drops MSSQL volume) ---"
(cd "$SCRIPT_DIR" && docker compose down -v)
echo ""
echo "--- Stage 4/6: bring infra back up ---"
(cd "$SCRIPT_DIR" && docker compose up -d)
echo " Waiting for MSSQL to accept connections..."
until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -Q "SELECT 1" >/dev/null 2>&1; do
sleep 2
done
echo " MSSQL ready."
echo " Waiting for setup.sql to create ScadaLinkConfig..."
until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
-Q "IF DB_ID('ScadaLinkConfig') IS NULL THROW 50000, 'not ready', 1;" \
>/dev/null 2>&1; do
sleep 2
done
echo " ScadaLinkConfig present."
echo ""
echo "--- Stage 5/6: deploy central + site nodes ---"
"$PROJECT_ROOT/docker/deploy.sh"
fi
echo ""
echo "--- Stage 6a/6: wait for central cluster /health/ready ---"
until curl -fs "$MGMT_URL/health/ready" >/dev/null 2>&1; do
sleep 2
done
echo " Central cluster ready (EF Core migrations applied)."
echo ""
echo "--- Stage 6b/6: seed sites (CLI) ---"
# Sites must exist before the design seed: DataConnections.SiteId FKs to Sites.
"$PROJECT_ROOT/docker/seed-sites.sh"
echo ""
echo "--- Stage 6c/6: replay seed SQL ---"
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -d ScadaLinkConfig -b < "$SEED_FILE"
echo " Seed replayed."
echo ""
echo "=== Reseed complete ==="
echo ""
echo "Verify:"
echo " $PROJECT_ROOT/src/ScadaLink.CLI/bin/Debug/net*/ScadaLink.CLI --url $MGMT_URL --username multi-role --password password template list"
echo ""
echo "To refresh the seed file from the current DB state:"
echo " python3 $SCRIPT_DIR/tools/dump_seed.py --output $SEED_FILE"
+11 -1
View 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
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Dump design tables from ScadaLinkConfig to a replayable SQL seed file.
Usage:
python3 infra/tools/dump_seed.py --output infra/mssql/seed-config.sql
Tables covered (insert order; reverse for delete):
TemplateFolders, Templates, TemplateAttributes, TemplateScripts,
TemplateAlarms, TemplateCompositions, SharedScripts, DataConnections,
ExternalSystemDefinitions, ExternalSystemMethods
Excluded by design (per-environment, not design-time): Sites (seeded via
seed-sites.sh), Instances + InstanceConnectionBindings + InstanceOverrides,
NotificationLists/Recipients, SmtpConfigurations, ApiKeys, Areas,
SiteScopeRules, LdapGroupMappings, DataProtectionKeys, audit, deployment.
"""
import argparse
import datetime
import sys
import pymssql
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 1433
DEFAULT_USER = "sa"
DEFAULT_PASSWORD = "ScadaLink_Dev1#"
DEFAULT_DATABASE = "ScadaLinkConfig"
INSERT_ORDER = [
"TemplateFolders",
"Templates",
"TemplateAttributes",
"TemplateScripts",
"TemplateAlarms",
"TemplateCompositions",
"SharedScripts",
"DataConnections",
"ExternalSystemDefinitions",
"ExternalSystemMethods",
]
# Identity columns get IDENTITY_INSERT wrapped around inserts and are kept in
# the column list. All listed tables happen to use Id as their identity.
IDENTITY_TABLES = set(INSERT_ORDER)
# Templates has self-FK Templates.ParentTemplateId; emit a single batch that
# inserts shallow rows first then deeper ones. pymssql returns rows in Id order
# from our ORDER BY, which matches insertion order for this schema (parent Id
# is always less than child Id in the live data).
def quote(value):
if value is None:
return "NULL"
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (bytes, bytearray)):
return "0x" + value.hex()
if isinstance(value, datetime.datetime):
return "'" + value.isoformat(sep=" ", timespec="microseconds") + "'"
if isinstance(value, datetime.date):
return "'" + value.isoformat() + "'"
if isinstance(value, datetime.time):
return "'" + value.isoformat(timespec="microseconds") + "'"
if isinstance(value, datetime.timedelta):
total = value.total_seconds()
hours, rem = divmod(int(total), 3600)
minutes, seconds = divmod(rem, 60)
micros = value.microseconds
return "'{:02d}:{:02d}:{:02d}.{:06d}'".format(hours, minutes, seconds, micros)
text = str(value).replace("'", "''")
return "N'" + text + "'"
def get_columns(cursor, table):
cursor.execute(
"""
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = %s
ORDER BY ORDINAL_POSITION
""",
(table,),
)
return [row[0] for row in cursor.fetchall()]
def dump(args):
conn = pymssql.connect(
server=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
)
cursor = conn.cursor()
out = []
out.append("-- ScadaLink design-data seed.")
out.append("-- Auto-generated by infra/tools/dump_seed.py against " + args.database + ".")
out.append("-- Replays the design-time configuration (templates, scripts,")
out.append("-- data connections, external systems). Idempotent: deletes")
out.append("-- existing rows in the covered tables before inserting.")
out.append("--")
out.append("-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,")
out.append("-- InstanceConnectionBindings, notifications, SMTP, API keys,")
out.append("-- areas, LDAP mappings.")
out.append("")
out.append("SET NOCOUNT ON;")
out.append("SET XACT_ABORT ON;")
# sqlcmd defaults QUOTED_IDENTIFIER OFF; EF Core's filtered indexes
# and computed columns require ON, so force it here.
out.append("SET QUOTED_IDENTIFIER ON;")
out.append("BEGIN TRAN;")
out.append("")
# Wipe in reverse FK order. Beyond the design tables themselves, we also
# clear instance + deployment rows because they FK to Templates and
# DataConnections; without this, an idempotent replay against a populated
# DB fails on the FK to DataConnections. On a fresh reseed (after
# teardown.sh) these tables are already empty so the DELETEs are no-ops.
out.append("-- Wipe existing design + dependent rows so the seed is idempotent.")
out.append("-- Order matters: dependents first.")
delete_order = [
# Dependents on Instances / DataConnections / Sites.
"DeployedConfigSnapshots",
"DeploymentRecords",
"InstanceAlarmOverrides",
"InstanceAttributeOverrides",
"InstanceConnectionBindings",
"Instances",
# Design tables themselves.
"ExternalSystemMethods",
"ExternalSystemDefinitions",
"DataConnections",
"SharedScripts",
"TemplateCompositions",
# Alarms reference scripts via OnTriggerScriptId; null it first so we
# can delete scripts without FK violations.
"UPDATE TemplateAlarms SET OnTriggerScriptId = NULL",
"TemplateAlarms",
"TemplateScripts",
"TemplateAttributes",
# Templates is self-referential and references TemplateCompositions
# (OwnerCompositionId); null parent links first.
"UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL",
"Templates",
# Folders is self-referential too.
"UPDATE TemplateFolders SET ParentFolderId = NULL",
"TemplateFolders",
]
for step in delete_order:
if step.startswith("UPDATE "):
out.append(step + ";")
else:
out.append("DELETE FROM " + step + ";")
out.append("")
for table in INSERT_ORDER:
columns = get_columns(cursor, table)
if not columns:
print("Skipping {} (no columns found)".format(table), file=sys.stderr)
continue
# Order by Id so self-referential rows insert in dependency order
# (in the live data, parent Id < child Id by construction).
order_clause = "ORDER BY Id" if "Id" in columns else ""
cursor.execute(
"SELECT [{}] FROM [{}] {}".format("], [".join(columns), table, order_clause)
)
rows = cursor.fetchall()
out.append("-- " + table + " (" + str(len(rows)) + " rows)")
if not rows:
continue
col_list = ", ".join("[" + c + "]" for c in columns)
identity = table in IDENTITY_TABLES
if identity:
out.append("SET IDENTITY_INSERT [{}] ON;".format(table))
for row in rows:
values = ", ".join(quote(v) for v in row)
out.append(
"INSERT INTO [{}] ({}) VALUES ({});".format(table, col_list, values)
)
if identity:
out.append("SET IDENTITY_INSERT [{}] OFF;".format(table))
out.append("")
out.append("COMMIT;")
out.append("")
sql = "\n".join(out)
with open(args.output, "w") as f:
f.write(sql)
print("Wrote " + args.output + " (" + str(sum(1 for line in out if line.startswith('INSERT'))) + " inserts).")
cursor.close()
conn.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--user", default=DEFAULT_USER)
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument("--database", default=DEFAULT_DATABASE)
parser.add_argument("--output", required=True, help="Path to write seed SQL")
args = parser.parse_args()
dump(args)
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
@inherits LayoutComponentBase
@* Minimal layout for the login page: no nav sidebar, no session-expiry
watchdog, no dialog host. The page renders its own centred card. *@
@Body
@@ -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;
@@ -1,4 +1,5 @@
@page "/login" @page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@@ -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
};
}
}
@@ -7,6 +7,11 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// The login page uses the same layout, so this component renders there
// too. Redirecting /login → /login would loop ("too many redirects").
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
var auth = await AuthStateProvider.GetAuthenticationStateAsync(); var auth = await AuthStateProvider.GetAuthenticationStateAsync();
if (auth.User.Identity?.IsAuthenticated != true) return; if (auth.User.Identity?.IsAuthenticated != true) return;
@@ -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);
}
} }
@@ -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;
}
}
@@ -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);
@@ -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;
if (calleeName == "CallShared")
{ {
var shapes = await _sharedScripts.GetShapesAsync(); case ScriptCallKind.Shared:
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
}
if (calleeName == "CallScript")
{
// Children["X"].CallScript("..." or Parent.CallScript("...
if (calleeMa != null)
{ {
// Children["X"].CallScript var shapes = await _sharedScripts.GetShapesAsync();
if (calleeMa.Expression is ElementAccessExpressionSyntax childElem return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
&& childElem.Expression is IdentifierNameSyntax cid }
&& cid.Identifier.ValueText == "Children" case ScriptCallKind.Sibling:
&& childElem.ArgumentList.Arguments.Count == 1 return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
&& cLit.IsKind(SyntaxKind.StringLiteralExpression)) case ScriptCallKind.Parent:
{ return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
var compName = cLit.Token.ValueText; .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
var comp = (request.Children ?? Array.Empty<CompositionContext>()) case ScriptCallKind.Child:
.FirstOrDefault(c => c.Name == compName); {
if (comp != null) var comp = (request.Children ?? Array.Empty<CompositionContext>())
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList(); .FirstOrDefault(c => c.Name == call.CompositionName);
return new List<CompletionItem>(); return comp != null
} ? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
// Parent.CallScript : new List<CompletionItem>();
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>())
.Select(s => MakeCallCompletion(s, "sibling script"))
.ToList();
} }
} }
@@ -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();
}
}
+15 -2
View File
@@ -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 [];
@@ -34,5 +34,12 @@ public interface IDeploymentManagerRepository
Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default); Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default);
Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default); Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an instance and everything that depends on it: deployment
/// records, deployed config snapshot, attribute/alarm overrides, and
/// connection bindings.
/// </summary>
Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
@@ -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;
@@ -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
View 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&lt;string, object?&gt;</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 =>
@@ -189,6 +189,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
{
// DeploymentRecords have a Restrict FK to Instance — remove them
// explicitly first. The snapshot, overrides, and connection bindings
// are configured with cascade delete and go with the instance.
var records = await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.ToListAsync(cancellationToken);
if (records.Count > 0)
{
_dbContext.DeploymentRecords.RemoveRange(records);
}
var instance = await _dbContext.Set<Instance>()
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
if (instance != null)
{
_dbContext.Set<Instance>().Remove(instance);
}
}
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
return await _dbContext.SaveChangesAsync(cancellationToken); return await _dbContext.SaveChangesAsync(cancellationToken);
@@ -282,7 +282,9 @@ public class DeploymentService
} }
/// <summary> /// <summary>
/// WP-6: Delete an instance. Stops actor, removes config. S&amp;F NOT cleared. /// WP-6: Delete an instance. Stops the site actor, removes site config, and
/// removes the central instance record (deployment history, snapshot,
/// overrides, and connection bindings go with it). S&amp;F NOT cleared.
/// Delete fails if site unreachable (30s timeout via CommunicationOptions). /// Delete fails if site unreachable (30s timeout via CommunicationOptions).
/// </summary> /// </summary>
public async Task<Result<InstanceLifecycleResponse>> DeleteInstanceAsync( public async Task<Result<InstanceLifecycleResponse>> DeleteInstanceAsync(
@@ -309,12 +311,10 @@ public class DeploymentService
if (response.Success) if (response.Success)
{ {
// Remove deployed snapshot // Delete means delete: remove the instance record entirely.
await _repository.DeleteDeployedSnapshotAsync(instanceId, cancellationToken); // Deployment records, snapshot, overrides, and connection bindings
// are removed with it (see repository implementation).
// Set state to NotDeployed (or the instance record could be deleted entirely by higher layers) await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
instance.State = InstanceState.NotDeployed;
await _repository.UpdateInstanceAsync(instance, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);
} }
@@ -7,11 +7,12 @@ namespace ScadaLink.DeploymentManager;
/// ///
/// State | Deploy | Disable | Enable | Delete /// State | Deploy | Disable | Enable | Delete
/// ----------|--------|---------|--------|------- /// ----------|--------|---------|--------|-------
/// NotDeploy | OK | NO | NO | NO /// NotDeploy | OK | NO | NO | OK
/// Enabled | OK | OK | NO | OK /// Enabled | OK | OK | NO | OK
/// Disabled | OK* | NO | OK | OK /// Disabled | OK* | NO | OK | OK
/// ///
/// * Deploy on a Disabled instance also enables it. /// * Deploy on a Disabled instance also enables it.
/// Delete removes the instance record entirely; it is valid from any state.
/// </summary> /// </summary>
public static class StateTransitionValidator public static class StateTransitionValidator
{ {
@@ -25,7 +26,7 @@ public static class StateTransitionValidator
currentState == InstanceState.Disabled; currentState == InstanceState.Disabled;
public static bool CanDelete(InstanceState currentState) => public static bool CanDelete(InstanceState currentState) =>
currentState is InstanceState.Enabled or InstanceState.Disabled; currentState is InstanceState.NotDeployed or InstanceState.Enabled or InstanceState.Disabled;
/// <summary> /// <summary>
/// Returns a human-readable error message if the transition is invalid, or null if valid. /// Returns a human-readable error message if the transition is invalid, or null if valid.
@@ -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);
} }
} }
@@ -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; }
} }
+25 -5
View File
@@ -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)),
@@ -306,14 +311,29 @@ akka {{
// Register local handlers with SiteCommunicationActor // Register local handlers with SiteCommunicationActor
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy)); siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy));
// Event log handler — bridges Akka to IEventLogQueryService // Event log handler — cluster singleton so queries always reach the
// active node. The event log is node-local SQLite and is not
// replicated; only the active node records events. A per-node handler
// would let a ClusterClient query land on the standby and find nothing.
var eventLogQueryService = _serviceProvider.GetService<SiteEventLogging.IEventLogQueryService>(); var eventLogQueryService = _serviceProvider.GetService<SiteEventLogging.IEventLogQueryService>();
if (eventLogQueryService != null) if (eventLogQueryService != null)
{ {
var eventLogHandler = _actorSystem.ActorOf( var eventLogSingletonProps = ClusterSingletonManager.Props(
Props.Create(() => new SiteEventLogging.EventLogHandlerActor(eventLogQueryService)), singletonProps: Props.Create(() => new SiteEventLogging.EventLogHandlerActor(eventLogQueryService)),
"event-log-handler"); terminationMessage: PoisonPill.Instance,
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.EventLog, eventLogHandler)); settings: ClusterSingletonManagerSettings.Create(_actorSystem)
.WithRole(siteRole)
.WithSingletonName("event-log-handler"));
_actorSystem.ActorOf(eventLogSingletonProps, "event-log-handler-singleton");
var eventLogProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/event-log-handler-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithRole(siteRole)
.WithSingletonName("event-log-handler"));
var eventLogProxy = _actorSystem.ActorOf(eventLogProxyProps, "event-log-handler-proxy");
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.EventLog, eventLogProxy));
} }
// Parked message handler — bridges Akka to StoreAndForwardService // Parked message handler — bridges Akka to StoreAndForwardService
@@ -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;
+8 -1
View File
@@ -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"));
@@ -117,7 +125,6 @@ try
} }
// Middleware pipeline // Middleware pipeline
app.UseStaticFiles();
app.UseWebSockets(); app.UseWebSockets();
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
+6 -3
View File
@@ -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>
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types; using ScadaLink.Commons.Types;
using ScadaLink.HealthMonitoring; using ScadaLink.HealthMonitoring;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime.Scripts; using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors; namespace ScadaLink.SiteRuntime.Actors;
@@ -71,6 +72,9 @@ public class ScriptExecutionActor : ReceiveActor
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
IServiceScope? serviceScope = null; IServiceScope? serviceScope = null;
// ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
using var cts = new CancellationTokenSource(timeout); using var cts = new CancellationTokenSource(timeout);
try try
{ {
@@ -125,6 +129,10 @@ public class ScriptExecutionActor : ReceiveActor
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s"; var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning(errorMsg); logger.LogWarning(errorMsg);
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
if (!replyTo.IsNobody()) if (!replyTo.IsNobody())
{ {
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
@@ -135,10 +143,13 @@ public class ScriptExecutionActor : ReceiveActor
catch (Exception ex) catch (Exception ex)
{ {
healthCollector?.IncrementScriptError(); healthCollector?.IncrementScriptError();
// WP-32: Failures logged to site event log; script NOT disabled after failure // WP-32: Failures recorded to site event log; script NOT disabled after failure.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}"; var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName); logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
if (!replyTo.IsNobody()) if (!replyTo.IsNobody())
{ {
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg)); replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
@@ -24,6 +24,7 @@
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" /> <ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" /> <ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" /> <ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
<ProjectReference Include="../ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj" />
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" /> <ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
</ItemGroup> </ItemGroup>
@@ -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;
{
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
}
return removed;
} }
info.KillSwitch.Shutdown();
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
return true;
} }
/// <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)
_subscriptions.Remove(kvp.Key);
toShutdown = matched.Select(kvp => kvp.Value).ToList();
}
foreach (var id in toRemove) foreach (var info in toShutdown)
{ info.KillSwitch.Shutdown();
_subscriptions.Remove(id);
}
if (toRemove.Count > 0) 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 { }
@@ -188,15 +188,14 @@ public class DeploymentServiceTests
} }
[Fact] [Fact]
public async Task DeleteInstanceAsync_WhenNotDeployed_ReturnsTransitionError() public async Task DeleteInstanceAsync_InstanceNotFound_ReturnsFailure()
{ {
var instance = new Instance("TestInst") { Id = 1, SiteId = 1, State = InstanceState.NotDeployed }; _repo.GetInstanceByIdAsync(1).Returns((Instance?)null);
_repo.GetInstanceByIdAsync(1).Returns(instance);
var result = await _service.DeleteInstanceAsync(1, "admin"); var result = await _service.DeleteInstanceAsync(1, "admin");
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("not allowed", result.Error); Assert.Contains("not found", result.Error);
} }
// ── WP-8: Deployment comparison ── // ── WP-8: Deployment comparison ──
@@ -73,9 +73,9 @@ public class StateTransitionValidatorTests
} }
[Fact] [Fact]
public void CanDelete_WhenNotDeployed_ReturnsFalse() public void CanDelete_WhenNotDeployed_ReturnsTrue()
{ {
Assert.False(StateTransitionValidator.CanDelete(InstanceState.NotDeployed)); Assert.True(StateTransitionValidator.CanDelete(InstanceState.NotDeployed));
} }
// ── ValidateTransition ── // ── ValidateTransition ──
@@ -103,10 +103,10 @@ public class StateTransitionValidatorTests
} }
[Fact] [Fact]
public void ValidateTransition_InvalidDeleteOnNotDeployed_ReturnsError() public void ValidateTransition_ValidDeleteOnNotDeployed_ReturnsNull()
{ {
var error = StateTransitionValidator.ValidateTransition(InstanceState.NotDeployed, "delete"); var error = StateTransitionValidator.ValidateTransition(InstanceState.NotDeployed, "delete");
Assert.NotNull(error); Assert.Null(error);
} }
[Fact] [Fact]
@@ -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);
} }
} }