7 Commits

Author SHA1 Message Date
Joseph Doherty 6fb313cf58 feat(ui/templates): structured trigger editor for template scripts
The script add/edit modal exposed a script's trigger as two raw free-text
inputs — a type string and hand-written config JSON — with no validation
and no parity with the alarm trigger UI.

Replace them with a ScriptTriggerEditor component (mirroring
AlarmTriggerEditor): a trigger-type dropdown plus type-specific panels for
Interval, ValueChange, Conditional, and Call, a grouped attribute picker,
and an auto-generated hint. A ScriptTriggerConfigCodec round-trips the
TriggerConfiguration JSON the site runtime's ScriptActor consumes, tolerant
of legacy keys; an unrecognized stored type is preserved untouched in a
read-only panel.
2026-05-16 04:03:42 -04:00
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
63 changed files with 3557 additions and 581 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
# Tear down ScadaLink test infrastructure.
#
# Drops the MSSQL data volume by default, so the ScadaLinkConfig DB
# (templates, scripts, data connections, etc.) is wiped. Use
# infra/reseed.sh afterwards to restore the design state from
# infra/mssql/seed-config.sql.
#
# Usage:
# ./teardown.sh Stop containers and delete the SQL data volume
# ./teardown.sh --images Also remove downloaded Docker images
@@ -44,4 +49,9 @@ fi
echo ""
echo "Teardown complete."
echo "To start fresh: docker compose up -d && python tools/mssql_tool.py setup --script mssql/setup.sql"
echo ""
echo "To restore the full test cluster (infra + app + design seed + sites):"
echo " infra/reseed.sh"
echo ""
echo "To start only infra (no app, no seed):"
echo " cd infra && docker compose up -d"
+220
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>
</li>
<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 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>
</Authorized>
</AuthorizeView>
@@ -41,10 +41,10 @@
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
</li>
<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 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>
</Authorized>
</AuthorizeView>
@@ -1,8 +1,8 @@
@page "/design/smtp"
@page "/admin/smtp"
@using ScadaLink.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
@@ -3,8 +3,10 @@
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
@@ -78,6 +80,7 @@
<label class="form-label">Script</label>
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
Language="csharp" Height="320px"
ScriptKind="ScadaLink.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
@@ -91,10 +94,92 @@
<div class="d-flex gap-2">
<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>
</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>
@@ -114,6 +199,12 @@
private List<ApiKey> _allKeys = 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()
{
try
@@ -200,4 +291,53 @@
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
private async Task RunInSandboxAsync()
{
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
_running = true;
_runResult = null;
StateHasChanged();
try
{
var jsonParams = _paramValues.ToDictionary(
kv => kv.Key,
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
var request = new ScriptAnalysis.SandboxRunRequest(
_script, jsonParams, TimeoutSeconds: _timeoutSeconds,
Kind: ScriptAnalysis.ScriptKind.InboundApi);
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
}
catch (OperationCanceledException) { /* superseded by next Run click */ }
catch (Exception ex)
{
_runResult = new ScriptAnalysis.SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
DurationMs: 0,
Markers: null);
}
finally
{
_running = false;
StateHasChanged();
}
}
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
{
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
_ => "Error"
};
}
@@ -1,7 +1,7 @@
@page "/admin/connections/create"
@page "/admin/connections/{Id:int}/edit"
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@page "/design/connections/create"
@page "/design/connections/{Id:int}/edit"
@page "/design/data-connections/create"
@page "/design/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@@ -10,7 +10,7 @@
@using ScadaLink.Commons.Serialization
@using ScadaLink.Commons.Validators
@using ScadaLink.CentralUI.Components.Forms
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@@ -219,7 +219,7 @@
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/connections");
NavigationManager.NavigateTo("/design/connections");
}
catch (Exception ex)
{
@@ -237,5 +237,5 @@
_formFailoverRetryCount = 3;
}
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
}
@@ -1,9 +1,9 @@
@page "/admin/connections"
@page "/admin/data-connections"
@page "/design/connections"
@page "/design/data-connections"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
@@ -101,7 +101,7 @@
{
<li>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
</li>
@@ -128,7 +128,7 @@
else
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
<div class="dropdown-divider"></div>
@@ -253,7 +253,7 @@
private void AddConnectionForSite(int siteId)
{
NavigationManager.NavigateTo($"/admin/connections/create?siteId={siteId}");
NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}");
}
private void OnSearchChanged()
@@ -14,8 +14,6 @@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Integration Definitions</h4>
<a class="btn btn-outline-secondary btn-sm"
href="/design/smtp">Email configuration →</a>
</div>
<ToastNotification @ref="_toast" />
@@ -67,15 +65,6 @@
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
</button>
</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>
@if (_tab == "extsys")
@@ -94,10 +83,6 @@
{
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
}
else if (_tab == "apikeys")
{
<div role="tabpanel" id="int-tab-apikeys">@RenderApiKeys()</div>
}
}
</div>
@@ -122,14 +107,6 @@
? _dbConnections
: _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
private List<NotificationList> _notificationLists = new();
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
@@ -171,7 +148,6 @@
}
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex) { _errorMessage = ex.Message; }
_loading = false;
@@ -478,67 +454,4 @@
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== API Keys ====
private RenderFragment RenderApiKeys() => __builder =>
{
<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)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@@ -62,10 +63,92 @@
<div class="mt-3">
<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-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>
</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>
@@ -83,6 +166,12 @@
private MonacoEditor? _editor;
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()
{
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>
/// Basic syntax check: balanced braces/brackets/parens.
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
@@ -1,5 +1,6 @@
@page "/design/templates/{Id:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@@ -8,7 +9,9 @@
@using ScadaLink.TemplateEngine.Validation
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ICentralUiRepository CentralUiRepository
@inject TemplateService TemplateService
@inject ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
@@ -106,6 +109,15 @@
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
= 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>
/// Editor's Parent.* context. Empty for base templates (no owner exists);
/// exactly one entry for derived templates — the slot-owner resolved from
@@ -185,6 +197,13 @@
_editorChildren = await BuildChildContextsAsync(_compositions);
_editorParents = await BuildParentContextsAsync(Id);
// Deployed, running instances of this template — selectable as the
// bind target for a script Test Run.
_deployedInstances = (await CentralUiRepository.GetInstancesFilteredAsync(templateId: Id))
.Where(i => i.State == InstanceState.Enabled)
.OrderBy(i => i.UniqueName)
.ToList();
_validationResult = null;
}
catch (Exception ex)
@@ -855,13 +874,12 @@
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Type</label>
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
<div class="col-12">
<label class="form-label">Trigger</label>
<ScriptTriggerEditor TriggerType="@_scriptTriggerType"
TriggerConfig="@_scriptTriggerConfig"
Changed="@OnScriptTriggerChanged"
AvailableAttributes="@BuildAlarmAttributeChoices()" />
</div>
<div class="col-12">
<div class="form-check">
@@ -926,8 +944,117 @@
{
<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 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-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
</div>
@@ -1328,6 +1455,13 @@
else { _toast.ShowError(result.Error); }
}
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
private void OnScriptTriggerChanged(ScriptTriggerValue v)
{
_scriptTriggerType = v.TriggerType;
_scriptTriggerConfig = v.Config;
}
private void BeginAddScript()
{
_showScriptForm = true;
@@ -1341,6 +1475,7 @@
_scriptReturn = null;
_scriptIsLocked = false;
_scriptModalTab = "code";
ResetScriptTestRun();
}
private void BeginEditScript(TemplateScript script)
@@ -1356,6 +1491,7 @@
_scriptReturn = script.ReturnDefinition;
_scriptIsLocked = script.IsLocked;
_scriptModalTab = "code";
ResetScriptTestRun();
}
private void CancelScriptForm()
@@ -1363,8 +1499,69 @@
_showScriptForm = false;
_editScriptId = null;
_scriptFormError = null;
ResetScriptTestRun();
}
private void ResetScriptTestRun()
{
_showScriptTestRun = false;
_scriptRunning = false;
_scriptParamValues = new();
_scriptBindInstance = string.Empty;
_scriptRunResult = null;
_scriptRunCts?.Cancel();
_scriptRunCts = null;
}
private void ToggleScriptTestRunPanel() => _showScriptTestRun = !_showScriptTestRun;
private async Task RunScriptInSandboxAsync()
{
_scriptRunCts?.Cancel();
_scriptRunCts = new CancellationTokenSource();
_scriptRunning = true;
_scriptRunResult = null;
StateHasChanged();
try
{
var jsonParams = _scriptParamValues.ToDictionary(
kv => kv.Key,
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
var request = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunRequest(
_scriptCode, jsonParams, TimeoutSeconds: null,
BindInstanceUniqueName: string.IsNullOrEmpty(_scriptBindInstance) ? null : _scriptBindInstance);
_scriptRunResult = await AnalysisService.RunInSandboxAsync(request, _scriptRunCts.Token);
}
catch (OperationCanceledException) { /* superseded by next Run click */ }
catch (Exception ex)
{
_scriptRunResult = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
ErrorKind: ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError,
DurationMs: 0,
Markers: null);
}
finally
{
_scriptRunning = false;
StateHasChanged();
}
}
private static string ScriptErrorKindLabel(ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind kind) => kind switch
{
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
_ => "Error"
};
private async Task SaveScript()
{
if (_selectedTemplate == null) return;
@@ -1,4 +1,5 @@
@page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
@@ -51,10 +51,11 @@
</div>
</div>
@* Per-site detail cards *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key))
{
var siteName = GetSiteName(siteId);
var isCentral = siteId == CentralHealthReportLoop.CentralSiteId;
var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId);
var detailsCollapseId = $"site-details-{siteId}";
<div class="card mb-3">
<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>
}
<strong class="fs-5">@siteName (@siteId)</strong>
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
</div>
<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>
</div>
<div class="card-body p-3">
@@ -27,6 +27,13 @@
[Parameter] public bool ReadOnly { get; set; } = false;
[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>
/// Parameter names declared on the form (derived from the SchemaBuilder's
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
@@ -148,7 +155,8 @@
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
Parent);
Parent,
ScriptKind);
private async Task FormatAsync()
{
@@ -189,5 +197,6 @@
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
ScriptAnalysis.AttributeShape[] SelfAttributes,
ScriptAnalysis.CompositionContext[] Children,
ScriptAnalysis.CompositionContext? Parent);
ScriptAnalysis.CompositionContext? Parent,
ScriptAnalysis.ScriptKind ScriptKind);
}
@@ -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
};
}
}
@@ -0,0 +1,190 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Which kind of trigger a template script has. <see cref="None"/> is no
/// trigger; <see cref="Unknown"/> is a stored trigger-type string the runtime
/// does not recognize (preserved as-is by the editor).
/// </summary>
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Unknown }
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
/// <summary>Parsed/editable view of a script trigger's configuration.</summary>
internal sealed class ScriptTriggerModel
{
/// <summary>Interval period in milliseconds.</summary>
public long? IntervalMs { get; set; }
/// <summary>Monitored attribute (ValueChange + Conditional).</summary>
public string? AttributeName { get; set; }
/// <summary>Comparison operator (Conditional) — one of <see cref="ScriptTriggerConfigCodec.Operators"/>.</summary>
public string Operator { get; set; } = ">";
/// <summary>Comparison threshold (Conditional).</summary>
public double? Threshold { get; set; }
}
/// <summary>
/// Round-trip codec for a template script's <c>TriggerType</c> +
/// <c>TriggerConfiguration</c>, shared by <see cref="ScriptTriggerEditor"/> (UI
/// editing) and consumed at the site by <c>ScriptActor.ParseTriggerConfig</c>.
/// Serialized config shapes:
/// Interval { intervalMs }
/// ValueChange { attributeName }
/// Conditional { attributeName, operator, threshold }
/// Call { }
///
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
/// older configs survive a round-trip through the editor.
/// </summary>
internal static class ScriptTriggerConfigCodec
{
/// <summary>The six comparison operators <c>ScriptActor.EvaluateCondition</c> accepts.</summary>
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary>
internal static ScriptTriggerKind ParseKind(string? triggerType)
{
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None;
return triggerType.Trim().ToLowerInvariant() switch
{
"interval" => ScriptTriggerKind.Interval,
"valuechange" => ScriptTriggerKind.ValueChange,
"conditional" => ScriptTriggerKind.Conditional,
"call" => ScriptTriggerKind.Call,
_ => ScriptTriggerKind.Unknown
};
}
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{
ScriptTriggerKind.Interval => "Interval",
ScriptTriggerKind.ValueChange => "ValueChange",
ScriptTriggerKind.Conditional => "Conditional",
ScriptTriggerKind.Call => "Call",
_ => null
};
/// <summary>
/// Parses a trigger configuration JSON in the context of the given kind.
/// Returns a model with default values on null/empty/malformed input or for
/// missing keys — never throws.
/// </summary>
internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
{
var model = new ScriptTriggerModel();
if (string.IsNullOrWhiteSpace(json)) return model;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
switch (kind)
{
case ScriptTriggerKind.Interval:
model.IntervalMs = TryReadLong(root, "intervalMs");
break;
case ScriptTriggerKind.ValueChange:
model.AttributeName = TryReadAttributeName(root);
break;
case ScriptTriggerKind.Conditional:
model.AttributeName = TryReadAttributeName(root);
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
model.Operator = NormalizeOperator(op);
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
/// <summary>
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
/// expects. Returns null for None/Unknown (no structured config to emit).
/// </summary>
internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
{
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null;
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
switch (kind)
{
case ScriptTriggerKind.Interval:
if (model.IntervalMs.HasValue)
w.WriteNumber("intervalMs", model.IntervalMs.Value);
break;
case ScriptTriggerKind.ValueChange:
w.WriteString("attributeName", model.AttributeName ?? "");
break;
case ScriptTriggerKind.Conditional:
w.WriteString("attributeName", model.AttributeName ?? "");
w.WriteString("operator", model.Operator);
if (model.Threshold.HasValue)
w.WriteNumber("threshold", model.Threshold.Value);
break;
// Call → empty object.
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else "&gt;".</summary>
internal static string NormalizeOperator(string? raw)
{
var op = raw?.Trim();
return op != null && Array.IndexOf(Operators, op) >= 0 ? op : ">";
}
private static string? TryReadAttributeName(JsonElement root) =>
root.TryGetProperty("attributeName", out var a) ? a.GetString()
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
: null;
private static long? TryReadLong(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number when p.TryGetInt64(out var i) => i,
JsonValueKind.Number => (long)p.GetDouble(),
JsonValueKind.String when long.TryParse(
p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static double? TryReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(
p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
}
@@ -0,0 +1,339 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Globalization
@* Structured editor for a template script's trigger. Owns both the trigger-type
selector and the type-specific configuration, emitting (via Changed) the
canonical TriggerType string + the TriggerConfiguration JSON that
ScriptActor.ParseTriggerConfig consumes:
Interval { intervalMs }
ValueChange { attributeName }
Conditional { attributeName, operator, threshold }
Call { } *@
<div class="border rounded bg-white p-3">
@* ── Trigger type ──────────────────────────────────────────────────── *@
<div class="mb-3">
<label for="script-trigger-type" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Trigger type
</label>
<select id="script-trigger-type" class="form-select form-select-sm"
@bind="_kindValue" @bind:after="OnKindChanged">
<option value="None">— none (never runs automatically) —</option>
<option value="Interval">Interval — run on a fixed timer</option>
<option value="ValueChange">Value change — run when an attribute changes</option>
<option value="Conditional">Conditional — run when a condition is met</option>
<option value="Call">Call — run only when invoked by another script</option>
@if (_kind == ScriptTriggerKind.Unknown)
{
<optgroup label="Unrecognized">
<option value="Unknown">@_rawType (unknown)</option>
</optgroup>
}
</select>
</div>
@* ── Type-specific configuration ───────────────────────────────────── *@
@switch (_kind)
{
case ScriptTriggerKind.Interval:
@RenderInterval();
break;
case ScriptTriggerKind.ValueChange:
<div class="mb-1">@RenderAttributePicker("Monitored attribute")</div>
break;
case ScriptTriggerKind.Conditional:
@RenderConditional();
break;
case ScriptTriggerKind.Call:
<div class="small text-muted">
No automatic trigger — this script runs only when another script
invokes it via <code>Instance.CallScript("...")</code>.
</div>
break;
case ScriptTriggerKind.Unknown:
<div class="alert alert-warning py-2 small mb-0">
Unrecognized trigger type <code>@_rawType</code>. Its stored
configuration is shown below and left untouched — pick a known
trigger type above to reconfigure it.
<pre class="bg-light border rounded p-2 mt-2 mb-0">@(string.IsNullOrWhiteSpace(TriggerConfig) ? "(empty)" : TriggerConfig)</pre>
</div>
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
{
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
}
</div>
@code {
// ── Parameters ─────────────────────────────────────────────────────────
[Parameter] public string? TriggerType { get; set; }
[Parameter] public string? TriggerConfig { get; set; }
/// <summary>Raised whenever the type or config changes — emits both atomically.</summary>
[Parameter] public EventCallback<ScriptTriggerValue> Changed { get; set; }
/// <summary>Flattened attribute list (direct + inherited + composed) for the picker.</summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
Array.Empty<AlarmAttributeChoice>();
// ── Internal state ─────────────────────────────────────────────────────
private ScriptTriggerKind _kind;
private string _kindValue = "None";
private string? _rawType;
private ScriptTriggerModel _model = new();
// Last type/config seen on Parameters — distinguishes an external change
// (re-parse) from this component's own echo (skip).
private bool _seen;
private string? _lastType;
private string? _lastConfig;
// Text mirrors — @bind needs settable backing fields; kept in sync with the
// model so blank inputs round-trip blank rather than as 0.
private string _attributeName = string.Empty;
private string _operator = ">";
private string? _thresholdText;
private string? _intervalText;
private string _intervalUnit = "ms";
// ── Parse / serialize lifecycle ────────────────────────────────────────
protected override void OnParametersSet()
{
if (_seen && _lastType == TriggerType && _lastConfig == TriggerConfig) return;
_seen = true;
_lastType = TriggerType;
_lastConfig = TriggerConfig;
_rawType = TriggerType;
_kind = ScriptTriggerConfigCodec.ParseKind(TriggerType);
_kindValue = _kind.ToString();
_model = ScriptTriggerConfigCodec.Parse(TriggerConfig, _kind);
SyncMirrors();
}
private void SyncMirrors()
{
_attributeName = _model.AttributeName ?? string.Empty;
_operator = _model.Operator;
_thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture);
(_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs);
}
/// <summary>Chooses the largest whole unit (min/sec/ms) that represents the period exactly.</summary>
private static (string?, string) SplitInterval(long? ms)
{
if (ms is not { } v) return (null, "ms");
if (v >= 60000 && v % 60000 == 0) return ((v / 60000).ToString(CultureInfo.InvariantCulture), "min");
if (v >= 1000 && v % 1000 == 0) return ((v / 1000).ToString(CultureInfo.InvariantCulture), "sec");
return (v.ToString(CultureInfo.InvariantCulture), "ms");
}
private static long UnitFactor(string unit) => unit switch
{
"min" => 60000,
"sec" => 1000,
_ => 1
};
/// <summary>Serializes the current model and raises <see cref="Changed"/> once.</summary>
private async Task Emit()
{
var type = ScriptTriggerConfigCodec.KindToString(_kind);
var config = ScriptTriggerConfigCodec.Serialize(_model, _kind);
_lastType = type;
_lastConfig = config;
await Changed.InvokeAsync(new ScriptTriggerValue(type, config));
}
// ── Trigger type ───────────────────────────────────────────────────────
private async Task OnKindChanged()
{
if (!Enum.TryParse<ScriptTriggerKind>(_kindValue, out var newKind)
|| newKind == ScriptTriggerKind.Unknown)
{
_kindValue = _kind.ToString();
return;
}
// Carry the attribute name across a ValueChange <-> Conditional switch.
var preservedAttr = _model.AttributeName;
_kind = newKind;
_model = new ScriptTriggerModel();
if (newKind is ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
_model.AttributeName = preservedAttr;
SyncMirrors();
await Emit();
}
// ── Interval ───────────────────────────────────────────────────────────
private RenderFragment RenderInterval() => __builder =>
{
<div class="row g-2 align-items-end" style="max-width: 420px;">
<div class="col-7">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Run every</label>
<input type="number" min="1" step="1" class="form-control form-control-sm"
placeholder="period"
@bind="_intervalText" @bind:event="oninput" @bind:after="OnIntervalChanged" />
</div>
<div class="col-5">
<select class="form-select form-select-sm"
@bind="_intervalUnit" @bind:after="OnIntervalChanged">
<option value="ms">milliseconds</option>
<option value="sec">seconds</option>
<option value="min">minutes</option>
</select>
</div>
</div>
};
private async Task OnIntervalChanged()
{
_model.IntervalMs =
long.TryParse(_intervalText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && n > 0
? n * UnitFactor(_intervalUnit)
: null;
await Emit();
}
// ── Conditional ────────────────────────────────────────────────────────
private RenderFragment RenderConditional() => __builder =>
{
<div class="mb-2">@RenderAttributePicker("Monitored attribute")</div>
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Operator</label>
<select class="form-select form-select-sm"
@bind="_operator" @bind:after="OnOperatorChanged">
@foreach (var op in ScriptTriggerConfigCodec.Operators)
{
<option value="@op">@op — @OperatorLabel(op)</option>
}
</select>
</div>
<div class="col-md-7">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Threshold</label>
<input type="number" step="any" class="form-control form-control-sm"
placeholder="numeric value"
@bind="_thresholdText" @bind:event="oninput" @bind:after="OnThresholdChanged" />
</div>
</div>
};
private async Task OnOperatorChanged()
{
_model.Operator = ScriptTriggerConfigCodec.NormalizeOperator(_operator);
await Emit();
}
private async Task OnThresholdChanged()
{
_model.Threshold =
double.TryParse(_thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)
? v
: null;
await Emit();
}
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
private RenderFragment RenderAttributePicker(string label) => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">@label</label>
<select class="form-select form-select-sm"
@bind="_attributeName" @bind:after="OnAttributeChanged">
<option value="">— select attribute —</option>
@{
var groups = AvailableAttributes
.GroupBy(c => c.Source)
.OrderBy(g => SourceOrder(g.Key))
.ToList();
}
@foreach (var grp in groups)
{
<optgroup label="@grp.Key">
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
{
<option value="@choice.CanonicalName">@choice.CanonicalName &nbsp;(@choice.DataType)</option>
}
</optgroup>
}
@* Keep a saved-but-missing attribute selectable so it stays visible. *@
@if (!string.IsNullOrEmpty(_attributeName)
&& !AvailableAttributes.Any(c => string.Equals(c.CanonicalName, _attributeName, StringComparison.Ordinal)))
{
<optgroup label="Unknown">
<option value="@_attributeName">@_attributeName (not on this template)</option>
</optgroup>
}
</select>
};
private async Task OnAttributeChanged()
{
_model.AttributeName = _attributeName;
await Emit();
}
private static int SourceOrder(string source) => source switch
{
"Direct" => 0,
"Inherited" => 1,
"Composed" => 2,
_ => 3
};
// ── Hint ───────────────────────────────────────────────────────────────
private string BuildHint()
{
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
? "the selected attribute"
: $"\"{_model.AttributeName}\"";
return _kind switch
{
ScriptTriggerKind.Interval =>
_model.IntervalMs is { } ms
? $"Runs every {_intervalText} {UnitLabel(_intervalUnit)} ({ms} ms)."
: "Runs on a fixed timer — set the period above.",
ScriptTriggerKind.ValueChange =>
$"Runs whenever {attr} changes value.",
ScriptTriggerKind.Conditional =>
_model.Threshold is { } t
? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
_ => string.Empty
};
}
private static string UnitLabel(string unit) => unit switch
{
"min" => "minute(s)",
"sec" => "second(s)",
_ => "millisecond(s)"
};
private static string OperatorLabel(string op) => op switch
{
">" => "greater than",
">=" => "at least",
"<" => "less than",
"<=" => "at most",
"==" => "equals",
"!=" => "not equal",
_ => ""
};
}
@@ -7,6 +7,11 @@
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();
if (auth.User.Identity?.IsAuthenticated != true) return;
@@ -9,8 +9,17 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
public interface ISharedScriptCatalog
{
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
{
private readonly SharedScriptService _service;
@@ -24,4 +33,12 @@ public class SharedScriptCatalog : ISharedScriptCatalog
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
.ToList();
}
public async Task<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;
/// <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(
string Code,
IReadOnlyList<string>? DeclaredParameters = null,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
@@ -31,7 +44,8 @@ public record CompletionsRequest(
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
@@ -31,6 +31,9 @@ public static class ScriptAnalysisEndpoints
group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.InlayHints(req)));
group.MapPost("/run", async (SandboxRunRequest req, ScriptAnalysisService svc, HttpContext http) =>
Results.Ok(await svc.RunInSandboxAsync(req, http.RequestAborted)));
return endpoints;
}
}
@@ -1,5 +1,8 @@
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
@@ -7,13 +10,16 @@ using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
/// in the shape Monaco's provider APIs expect.
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
/// <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
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
@@ -23,9 +29,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
///
/// Beyond plain C# analysis, layers SCADA-specific extensions:
/// - In-string completion of Parameters["..."] keys (from the request's
/// DeclaredParameters), CallShared("...") names (from
/// <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
/// (from the request's SiblingScripts).
/// DeclaredParameters), Scripts.CallShared("...") names (from
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
/// (from the request's SiblingScripts / Children / Parent).
/// - Forbidden-API diagnostic for the documented script trust model,
/// resolved against the SemanticModel so user identifiers that happen
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
@@ -39,7 +46,9 @@ public class ScriptAnalysisService
typeof(Enumerable).Assembly,
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
typeof(ScriptHost).Assembly)
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.ScriptParameters).Assembly,
typeof(SandboxScriptHost).Assembly)
.AddImports(
"System",
"System.Collections.Generic",
@@ -61,26 +70,46 @@ public class ScriptAnalysisService
private readonly ISharedScriptCatalog _sharedScripts;
private readonly IMemoryCache _cache;
private readonly IServiceProvider _services;
public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache)
public ScriptAnalysisService(
ISharedScriptCatalog sharedScripts,
IMemoryCache cache,
IServiceProvider services)
{
_sharedScripts = sharedScripts;
_cache = cache;
_services = services;
}
/// <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)
{
if (string.IsNullOrEmpty(request.Code))
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)
return cached;
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost));
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch (Exception ex)
{
@@ -91,7 +120,7 @@ public class ScriptAnalysisService
return Cache(cacheKey, failure);
}
var compilation = script.GetCompilation();
var compilation = WithNullableAnnotations(script.GetCompilation());
var markers = compilation
.GetDiagnostics()
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
@@ -104,8 +133,6 @@ public class ScriptAnalysisService
var model = compilation.GetSemanticModel(tree);
markers.AddRange(FindForbiddenApiUsages(tree, model));
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts));
markers.AddRange(FindUnknownAttributeKeys(tree, request));
markers.AddRange(FindUnknownChildren(tree, request.Children));
}
@@ -113,6 +140,341 @@ public class ScriptAnalysisService
return Cache(cacheKey, new DiagnoseResponse(markers));
}
private const int SandboxMaxTimeoutSeconds = 10;
private const int SandboxDefaultTimeoutSeconds = 5;
private const int SandboxMaxConsoleChars = 32_000;
private const int SandboxMaxReturnJsonChars = 32_000;
private const int SandboxMaxCallSharedDepth = 16;
/// <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)
{
_cache.Set(key, value, new MemoryCacheEntryOptions
@@ -137,7 +499,7 @@ public class ScriptAnalysisService
Script<object> script;
try
{
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch
{
@@ -242,54 +604,32 @@ public class ScriptAnalysisService
}
}
// CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...")
// Scripts.CallShared("...") / Instance.CallScript("...") /
// Children["X"].CallScript("...") / Parent.CallScript("...")
if (owner is InvocationExpressionSyntax inv)
{
var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;
if (calleeName == "CallShared")
var call = ClassifyScriptCall(inv);
switch (call.Kind)
{
case ScriptCallKind.Shared:
{
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
}
if (calleeName == "CallScript")
{
// Children["X"].CallScript("..." or Parent.CallScript("...
if (calleeMa != null)
{
// Children["X"].CallScript
if (calleeMa.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid
&& cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
{
var compName = cLit.Token.ValueText;
var comp = (request.Children ?? Array.Empty<CompositionContext>())
.FirstOrDefault(c => c.Name == compName);
if (comp != null)
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
return new List<CompletionItem>();
}
// Parent.CallScript
if (calleeMa.Expression is IdentifierNameSyntax pid
&& pid.Identifier.ValueText == "Parent"
&& request.Parent != null)
{
return request.Parent.Scripts
.Select(s => MakeCallCompletion(s, "parent script"))
.ToList();
}
}
// Plain CallScript("...") — siblings
case ScriptCallKind.Sibling:
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, "sibling script"))
.ToList();
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Parent:
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Child:
{
var comp = (request.Children ?? Array.Empty<CompositionContext>())
.FirstOrDefault(c => c.Name == call.CompositionName);
return comp != null
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
: new List<CompletionItem>();
}
}
}
@@ -298,24 +638,25 @@ public class ScriptAnalysisService
/// <summary>
/// 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
/// completion range over the auto-closed <c>")</c> if Monaco inserted
/// one, so the snippet replaces the rest of the call cleanly.
/// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
/// provider extends the completion range over the auto-closed <c>")</c> if
/// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
/// </summary>
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
{
// The runtime call API takes the arguments as an anonymous object; the
// snippet emits one member per declared parameter.
string insertText;
int insertRules;
const int insertAsSnippet = 4;
if (shape.Parameters.Count == 0)
{
insertText = shape.Name + "\")";
insertRules = 4;
}
else
{
var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", {args})";
insertRules = 4;
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", new {{ {entries} }})";
}
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
var returnType = shape.ReturnType ?? "void";
@@ -324,7 +665,7 @@ public class ScriptAnalysisService
InsertText: insertText,
Detail: $"{detail} ({paramList}) -> {returnType}",
Kind: "Method",
InsertTextRules: insertRules);
InsertTextRules: insertAsSnippet);
}
public FormatResponse Format(FormatRequest request)
@@ -348,51 +689,14 @@ public class ScriptAnalysisService
}
}
public InlayHintsResponse InlayHints(InlayHintsRequest request)
{
if (string.IsNullOrEmpty(request.Code))
return new InlayHintsResponse(Array.Empty<InlayHint>());
var script = TryParse(request.Code);
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
var (tree, _) = script.Value;
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);
}
/// <summary>
/// Parameter-name inlay hints are obsolete under the runtime call API:
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
/// already self-labelling — there are no positional arguments to annotate.
/// </summary>
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>());
public HoverResponse Hover(HoverRequest request)
{
@@ -429,19 +733,15 @@ public class ScriptAnalysisService
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
var call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
var rawName = token.ValueText;
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == rawName);
else if (calleeName == "CallScript" && request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName);
var shape = ResolveCalledShape(
call, rawName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return new HoverResponse(null);
return new HoverResponse(FormatHover(shape, calleeName!));
return new HoverResponse(FormatHover(shape, call));
}
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
@@ -471,24 +771,20 @@ public class ScriptAnalysisService
}
if (inv == null) return empty;
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (calleeName is not ("CallShared" or "CallScript")) return empty;
var call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return empty;
// First argument is the name literal; pull it out.
if (inv.ArgumentList.Arguments.Count < 1) return empty;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
var scriptName = nameArg?.Token.ValueText ?? "";
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName);
else if (request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
var shape = ResolveCalledShape(
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return empty;
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
var label = $"{calleeName}(\"{shape.Name}\"" +
var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
@@ -514,7 +810,7 @@ public class ScriptAnalysisService
if (string.IsNullOrEmpty(code)) return null;
try
{
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost));
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
var compilation = s.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
return tree == null ? null : (tree, compilation);
@@ -525,14 +821,13 @@ public class ScriptAnalysisService
}
}
private static string FormatHover(ScriptShape shape, string callee)
private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
{
var ps = shape.Parameters.Count == 0
? "(no parameters)"
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
var rt = shape.ReturnType ?? "void";
var kind = callee == "CallShared" ? "shared script" : "sibling script";
return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
}
private static List<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;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
var method = ma.Name.Identifier.ValueText;
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 (callee is not ("CallShared" or "CallScript")) continue;
if (inv.ArgumentList.Arguments.Count < 1) continue;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var scriptName = nameArg.Token.ValueText;
if (string.IsNullOrEmpty(scriptName)) continue;
ScriptShape? shape = callee == "CallShared"
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
: siblings?.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) continue;
var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name
var expectedRequired = shape.Parameters.Count(p => p.Required);
var expectedTotal = shape.Parameters.Count;
if (passedCount < expectedRequired || passedCount > expectedTotal)
if (ma.Expression is IdentifierNameSyntax iid)
{
var span = inv.GetLocation().GetLineSpan().Span;
var expected = expectedRequired == expectedTotal
? expectedTotal.ToString()
: $"{expectedRequired}{expectedTotal}";
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.",
Code: "SCADA004");
if (iid.Identifier.ValueText == "Instance")
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
if (iid.Identifier.ValueText == "Parent")
return new ScriptCallInfo(ScriptCallKind.Parent, null);
}
if (ma.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
}
return new ScriptCallInfo(ScriptCallKind.None, null);
}
/// <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>
/// SCADA006 — flag <c>Attributes["typo"]</c>,
/// <c>Children["X"].Attributes["typo"]</c>, and
@@ -758,112 +1086,6 @@ public class ScriptAnalysisService
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)
{
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 {
min-width: 220px;
max-width: 220px;
min-height: 100vh;
height: 100vh;
background-color: var(--bs-dark);
}
/* Keep the sidebar pinned to the viewport on lg+ so it stays visible even
when the main content scrolls past 100vh. The wrapper is the flex child
of MainLayout; align-self prevents the flex row from stretching it. */
@media (min-width: 992px) {
#sidebar-collapse {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
z-index: 1020;
}
}
.sidebar .nav-link {
color: var(--bs-gray-500);
padding: 0.4rem 1rem;
@@ -51,7 +64,7 @@
.sidebar {
min-width: 100%;
max-width: 100%;
min-height: auto;
height: auto;
}
}
@@ -40,20 +40,23 @@
async function lookupContext(model) {
const empty = {
declaredParameters: [], siblingScripts: [], declaredParameterShapes: [],
selfAttributes: [], children: [], parent: null
selfAttributes: [], children: [], parent: null, scriptKind: 0
};
for (const key in editors) {
if (editors[key].editor.getModel() === model) {
try {
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
if (got) {
const kind = got.ScriptKind != null ? got.ScriptKind
: (got.scriptKind != null ? got.scriptKind : 0);
return {
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
siblingScripts: got.SiblingScripts || got.siblingScripts || [],
declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [],
selfAttributes: got.SelfAttributes || got.selfAttributes || [],
children: got.Children || got.children || [],
parent: got.Parent || got.parent || null
parent: got.Parent || got.parent || null,
scriptKind: kind
};
}
} catch (e) { /* fall through */ }
@@ -82,7 +85,8 @@
siblingScripts: ctx.siblingScripts,
selfAttributes: ctx.selfAttributes,
children: ctx.children,
parent: ctx.parent
parent: ctx.parent,
kind: ctx.scriptKind
})
});
if (!resp.ok) return { suggestions: [] };
@@ -269,7 +273,8 @@
body: JSON.stringify({
code: model.getValue(),
declaredParameters: ctx.declaredParameters,
siblingScripts: ctx.siblingScripts
siblingScripts: ctx.siblingScripts,
kind: ctx.scriptKind
})
});
if (!resp.ok) return [];
@@ -34,5 +34,12 @@ public interface IDeploymentManagerRepository
Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, 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);
}
@@ -7,7 +7,7 @@ public record AlarmStateChanged(
string AlarmName,
AlarmState State,
int Priority,
DateTimeOffset Timestamp)
DateTimeOffset Timestamp) : ISiteStreamEvent
{
/// <summary>
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
@@ -6,4 +6,4 @@ public record AttributeValueChanged(
string AttributeName,
object? Value,
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
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<RouteToGetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
Receive<RouteToSetAttributesRequest>(msg => _deploymentManagerProxy.Forward(msg));
// Pattern 7: Remote Queries
Receive<EventLogQueryRequest>(msg =>
@@ -189,6 +189,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
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)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
@@ -282,7 +282,9 @@ public class DeploymentService
}
/// <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).
/// </summary>
public async Task<Result<InstanceLifecycleResponse>> DeleteInstanceAsync(
@@ -309,12 +311,10 @@ public class DeploymentService
if (response.Success)
{
// Remove deployed snapshot
await _repository.DeleteDeployedSnapshotAsync(instanceId, cancellationToken);
// Set state to NotDeployed (or the instance record could be deleted entirely by higher layers)
instance.State = InstanceState.NotDeployed;
await _repository.UpdateInstanceAsync(instance, cancellationToken);
// Delete means delete: remove the instance record entirely.
// Deployment records, snapshot, overrides, and connection bindings
// are removed with it (see repository implementation).
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
@@ -7,11 +7,12 @@ namespace ScadaLink.DeploymentManager;
///
/// State | Deploy | Disable | Enable | Delete
/// ----------|--------|---------|--------|-------
/// NotDeploy | OK | NO | NO | NO
/// NotDeploy | OK | NO | NO | OK
/// Enabled | OK | OK | NO | OK
/// Disabled | OK* | NO | OK | OK
///
/// * Deploy on a Disabled instance also enables it.
/// Delete removes the instance record entirely; it is valid from any state.
/// </summary>
public static class StateTransitionValidator
{
@@ -25,7 +26,7 @@ public static class StateTransitionValidator
currentState == InstanceState.Disabled;
public static bool CanDelete(InstanceState currentState) =>
currentState is InstanceState.Enabled or InstanceState.Disabled;
currentState is InstanceState.NotDeployed or InstanceState.Enabled or InstanceState.Disabled;
/// <summary>
/// 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,
LatestReport = report,
LastReportReceivedAt = now,
LastHeartbeatAt = now,
LastSequenceNumber = report.SequenceNumber,
IsOnline = true
};
@@ -64,6 +65,7 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
var wasOffline = !existing.IsOnline;
existing.LatestReport = report;
existing.LastReportReceivedAt = now;
existing.LastHeartbeatAt = now;
existing.LastSequenceNumber = report.SequenceNumber;
existing.IsOnline = true;
@@ -86,8 +88,8 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
if (!_siteStates.TryGetValue(siteId, out var state))
return;
if (receivedAt > state.LastReportReceivedAt)
state.LastReportReceivedAt = receivedAt;
if (receivedAt > state.LastHeartbeatAt)
state.LastHeartbeatAt = receivedAt;
if (!state.IsOnline)
{
@@ -141,12 +143,15 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
var state = kvp.Value;
if (!state.IsOnline) continue;
var elapsed = now - state.LastReportReceivedAt;
// Use LastHeartbeatAt — heartbeats arrive every ~5s from any
// healthy site node, so OfflineTimeout only fires when no node
// can reach central, not during single-node failovers.
var elapsed = now - state.LastHeartbeatAt;
if (elapsed > _options.OfflineTimeout)
{
state.IsOnline = false;
_logger.LogWarning(
"Site {SiteId} marked offline — no report for {Elapsed}s (timeout: {Timeout}s)",
"Site {SiteId} marked offline — no signal for {Elapsed}s (timeout: {Timeout}s)",
state.SiteId, elapsed.TotalSeconds, _options.OfflineTimeout.TotalSeconds);
}
}
@@ -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 StoreAndForwardStorage? _sfStorage;
private readonly IClusterNodeProvider? _clusterNodeProvider;
private long _sequenceNumber;
// Seeded with Unix-ms at construction so reports from a freshly-active
// node always sort after reports from any prior active node for the same
// site. Without this seeding, failover would silently drop the new
// active's first reports because their per-process counter starts below
// the prior active's last sequence number.
private long _sequenceNumber = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public HealthReportSender(
ISiteHealthCollector collector,
@@ -9,4 +9,11 @@ namespace ScadaLink.HealthMonitoring;
public interface IClusterNodeProvider
{
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>
/// 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>
public static IServiceCollection AddCentralHealthAggregation(this IServiceCollection services)
{
services.AddSingleton<CentralHealthAggregator>();
services.AddSingleton<ICentralHealthAggregator>(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService<CentralHealthReportLoop>();
return services;
}
@@ -9,7 +9,21 @@ public class SiteHealthState
{
public required string SiteId { get; init; }
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; }
/// <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 bool IsOnline { get; set; }
}
+25 -5
View File
@@ -175,6 +175,11 @@ akka {{
/// </summary>
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 centralCommActor = _actorSystem!.ActorOf(
Props.Create(() => new CentralCommunicationActor(_serviceProvider, siteClientFactory)),
@@ -306,14 +311,29 @@ akka {{
// Register local handlers with SiteCommunicationActor
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>();
if (eventLogQueryService != null)
{
var eventLogHandler = _actorSystem.ActorOf(
Props.Create(() => new SiteEventLogging.EventLogHandlerActor(eventLogQueryService)),
"event-log-handler");
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.EventLog, eventLogHandler));
var eventLogSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new SiteEventLogging.EventLogHandlerActor(eventLogQueryService)),
terminationMessage: PoisonPill.Instance,
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
@@ -20,6 +20,19 @@ public class AkkaClusterNodeProvider : IClusterNodeProvider
_siteRole = siteRole;
}
public bool SelfIsPrimary
{
get
{
var system = _akkaService.ActorSystem;
if (system == null) return false;
var cluster = Cluster.Get(system);
if (cluster.SelfMember.Status != MemberStatus.Up) return false;
var leader = cluster.State.Leader;
return leader != null && leader.Equals(cluster.SelfAddress);
}
}
public IReadOnlyList<NodeStatus> GetClusterNodes()
{
var system = _akkaService.ActorSystem;
+8 -1
View File
@@ -94,6 +94,14 @@ try
builder.Services.AddSingleton<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
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
@@ -117,7 +125,6 @@ try
}
// Middleware pipeline
app.UseStaticFiles();
app.UseWebSockets();
app.UseRouting();
app.UseAuthentication();
+6 -3
View File
@@ -1,5 +1,6 @@
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
namespace ScadaLink.InboundAPI;
@@ -51,18 +52,20 @@ public class RouteTarget
}
/// <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>
public async Task<object?> Call(
string scriptName,
IReadOnlyDictionary<string, object?>? parameters = null,
object? parameters = null,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
var correlationId = Guid.NewGuid().ToString();
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, parameters, DateTimeOffset.UtcNow);
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
var response = await _communicationService.RouteToCallAsync(
siteId, request, cancellationToken);
@@ -4,6 +4,7 @@ using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types.Enums;
@@ -81,6 +82,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Inbound API Route.To().Call() — route to Instance Actors
Receive<RouteToCallRequest>(RouteInboundApiCall);
Receive<RouteToGetAttributesRequest>(RouteInboundApiGetAttributes);
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
// Internal startup messages
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>
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
@@ -216,26 +216,19 @@ public class InstanceActor : ReceiveActor
PublishAndNotifyChildren(changed);
// Persist asynchronously -- fire and forget since the actor is the source of truth
var self = Self;
var sender = Sender;
// and SetAttribute is called from scripts via Tell (no response consumer).
var instanceName = _instanceUniqueName;
var attributeName = command.AttributeName;
var logger = _logger;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
.ContinueWith(t =>
{
var success = t.IsCompletedSuccessfully;
var error = t.Exception?.GetBaseException().Message;
if (!success)
{
// Value is already in memory; log the persistence failure
// In-memory state is authoritative
}
return new SetStaticAttributeResponse(
command.CorrelationId,
_instanceUniqueName,
command.AttributeName,
success,
error,
DateTimeOffset.UtcNow);
}).PipeTo(sender);
logger.LogWarning(
t.Exception?.GetBaseException(),
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
instanceName,
attributeName);
}, TaskContinuationOptions.OnlyOnFaulted);
}
/// <summary>
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
using ScadaLink.HealthMonitoring;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors;
@@ -71,6 +72,9 @@ public class ScriptExecutionActor : ReceiveActor
_ = Task.Run(async () =>
{
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);
try
{
@@ -125,6 +129,10 @@ public class ScriptExecutionActor : ReceiveActor
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
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())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
@@ -135,10 +143,13 @@ public class ScriptExecutionActor : ReceiveActor
catch (Exception ex)
{
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}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
@@ -24,6 +24,7 @@
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
<ProjectReference Include="../ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj" />
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
</ItemGroup>
@@ -61,7 +61,7 @@ public class CompositionAccessor
public string ResolveScript(string 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);
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
namespace ScadaLink.SiteRuntime.Scripts;
@@ -116,8 +117,10 @@ public class ScriptRuntimeContext
/// Calls a sibling script on the same instance by name (Ask pattern).
/// WP-20: Enforces recursion limit.
/// WP-22: Uses Ask pattern for CallScript.
/// <paramref name="parameters"/> may be a dictionary or an anonymous object
/// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
/// </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;
if (nextDepth > _maxCallDepth)
@@ -131,7 +134,7 @@ public class ScriptRuntimeContext
var correlationId = Guid.NewGuid().ToString();
var request = new ScriptCallRequest(
scriptName,
parameters,
ScriptArgs.Normalize(parameters),
nextDepth,
correlationId);
@@ -200,10 +203,12 @@ public class ScriptRuntimeContext
/// <summary>
/// WP-17: Executes a shared script inline (direct method call, not actor message).
/// 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>
public async Task<object?> CallShared(
string scriptName,
IReadOnlyDictionary<string, object?>? parameters = null,
object? parameters = null,
CancellationToken cancellationToken = default)
{
var nextDepth = _currentCallDepth + 1;
@@ -215,7 +220,8 @@ public class ScriptRuntimeContext
throw new InvalidOperationException(msg);
}
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
return await _library.ExecuteAsync(
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
@@ -11,7 +11,8 @@ namespace ScadaLink.SiteRuntime.Streaming;
/// <summary>
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell.
/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow.
/// A BroadcastHub fans events out to per-subscriber graphs, each filtered by
/// instance name and bounded by a drop-oldest buffer.
///
/// Filterable by instance name for debug view (WP-25).
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
@@ -20,11 +21,13 @@ namespace ScadaLink.SiteRuntime.Streaming;
public class SiteStreamManager : ISiteStreamSubscriber
{
private ActorSystem? _system;
private IMaterializer? _materializer;
private readonly int _bufferSize;
private readonly ILogger<SiteStreamManager> _logger;
private readonly object _lock = new();
private IActorRef? _sourceActor;
private Source<ISiteStreamEvent, NotUsed>? _hubSource;
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
public SiteStreamManager(
@@ -36,64 +39,73 @@ public class SiteStreamManager : ISiteStreamSubscriber
}
/// <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
/// SiteStreamManager can be created by DI before the actor system exists.
/// </summary>
public void Initialize(ActorSystem system)
{
_system = system;
var materializer = _system.Materializer();
_materializer = _system.Materializer();
var source = Source.ActorRef<ISiteStreamEvent>(
var (sourceActor, hubSource) = Source.ActorRef<ISiteStreamEvent>(
_bufferSize,
OverflowStrategy.DropHead);
OverflowStrategy.DropHead)
.ToMaterialized(
BroadcastHub.Sink<ISiteStreamEvent>(bufferSize: 256),
Keep.Both)
.Run(_materializer);
var (actorRef, _) = source
.PreMaterialize(materializer);
_sourceActor = actorRef;
_sourceActor = sourceActor;
_hubSource = hubSource;
_logger.LogInformation(
"SiteStreamManager initialized with buffer size {BufferSize}", _bufferSize);
"SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize);
}
/// <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.
/// </summary>
public void PublishAttributeValueChanged(AttributeValueChanged changed)
{
_sourceActor?.Tell(changed);
// Also forward to filtered subscribers
ForwardToSubscribers(changed.InstanceUniqueName, changed);
}
/// <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.
/// </summary>
public void PublishAlarmStateChanged(AlarmStateChanged changed)
{
_sourceActor?.Tell(changed);
// Also forward to filtered subscribers
ForwardToSubscribers(changed.InstanceUniqueName, changed);
}
/// <summary>
/// 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>
public string Subscribe(string instanceName, IActorRef subscriber)
{
if (_hubSource is null || _materializer is null)
throw new InvalidOperationException("SiteStreamManager.Initialize must be called before Subscribe");
var subscriptionId = Guid.NewGuid().ToString();
var capturedInstance = instanceName;
var capturedSubscriber = subscriber;
var killSwitch = _hubSource
.Where(ev => ev.InstanceUniqueName == capturedInstance)
.Buffer(_bufferSize, OverflowStrategy.DropHead)
.ViaMaterialized(KillSwitches.Single<ISiteStreamEvent>(), Keep.Right)
.To(Sink.ForEach<ISiteStreamEvent>(ev => capturedSubscriber.Tell(ev)))
.Run(_materializer);
lock (_lock)
{
_subscriptions[subscriptionId] = new SubscriptionInfo(
instanceName, subscriber, DateTimeOffset.UtcNow);
instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow);
}
_logger.LogDebug(
@@ -104,44 +116,47 @@ public class SiteStreamManager : ISiteStreamSubscriber
}
/// <summary>
/// WP-25: Unsubscribe from instance events.
/// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber
/// stream graph via its KillSwitch.
/// </summary>
public bool Unsubscribe(string subscriptionId)
{
SubscriptionInfo? info;
lock (_lock)
{
var removed = _subscriptions.Remove(subscriptionId);
if (removed)
{
if (!_subscriptions.Remove(subscriptionId, out info))
return false;
}
info.KillSwitch.Shutdown();
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
}
return removed;
}
return true;
}
/// <summary>
/// WP-25: Remove all subscriptions for a specific subscriber actor.
/// Called when connection is interrupted.
/// Called when a connection is interrupted.
/// </summary>
public void RemoveSubscriber(IActorRef subscriber)
{
List<SubscriptionInfo> toShutdown;
lock (_lock)
{
var toRemove = _subscriptions
var matched = _subscriptions
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
.Select(kvp => kvp.Key)
.ToList();
foreach (var id in toRemove)
{
_subscriptions.Remove(id);
foreach (var kvp in matched)
_subscriptions.Remove(kvp.Key);
toShutdown = matched.Select(kvp => kvp.Value).ToList();
}
if (toRemove.Count > 0)
foreach (var info in toShutdown)
info.KillSwitch.Shutdown();
if (toShutdown.Count > 0)
{
_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; } }
}
private void ForwardToSubscribers(string instanceName, object message)
{
lock (_lock)
{
foreach (var sub in _subscriptions.Values)
{
if (sub.InstanceName == instanceName)
{
// Fire-and-forget to subscriber
sub.Subscriber.Tell(message);
}
}
}
}
private record SubscriptionInfo(
string InstanceName,
IActorRef Subscriber,
IKillSwitch KillSwitch,
DateTimeOffset SubscribedAt);
}
/// <summary>
/// Marker interface for events published to the site stream.
/// </summary>
public interface ISiteStreamEvent { }
@@ -188,15 +188,14 @@ public class DeploymentServiceTests
}
[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);
_repo.GetInstanceByIdAsync(1).Returns((Instance?)null);
var result = await _service.DeleteInstanceAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not allowed", result.Error);
Assert.Contains("not found", result.Error);
}
// ── WP-8: Deployment comparison ──
@@ -73,9 +73,9 @@ public class StateTransitionValidatorTests
}
[Fact]
public void CanDelete_WhenNotDeployed_ReturnsFalse()
public void CanDelete_WhenNotDeployed_ReturnsTrue()
{
Assert.False(StateTransitionValidator.CanDelete(InstanceState.NotDeployed));
Assert.True(StateTransitionValidator.CanDelete(InstanceState.NotDeployed));
}
// ── ValidateTransition ──
@@ -103,10 +103,10 @@ public class StateTransitionValidatorTests
}
[Fact]
public void ValidateTransition_InvalidDeleteOnNotDeployed_ReturnsError()
public void ValidateTransition_ValidDeleteOnNotDeployed_ReturnsNull()
{
var error = StateTransitionValidator.ValidateTransition(InstanceState.NotDeployed, "delete");
Assert.NotNull(error);
Assert.Null(error);
}
[Fact]
@@ -49,17 +49,25 @@ public class HealthReportSenderTests
Assert.True(transport.SentReports.Count >= 2,
$"Expected at least 2 reports, got {transport.SentReports.Count}");
// Verify monotonic sequence numbers starting at 1
// Verify strictly-monotonic sequence numbers and matching site id
for (int i = 0; i < transport.SentReports.Count; i++)
{
Assert.Equal(i + 1, transport.SentReports[i].SequenceNumber);
if (i > 0)
{
Assert.True(
transport.SentReports[i].SequenceNumber > transport.SentReports[i - 1].SequenceNumber,
$"Sequence numbers not strictly increasing at index {i}");
}
Assert.Equal("site-A", transport.SentReports[i].SiteId);
}
}
[Fact]
public async Task SequenceNumberStartsAtOne()
public async Task FirstReportSequenceExceedsStartupUnixMs()
{
// Reports are seeded with Unix-ms at construction so a freshly-active
// node always sorts after the prior active. Verify the first emitted
// sequence is at least the startup epoch.
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
collector.SetActiveNode(true);
@@ -68,6 +76,7 @@ public class HealthReportSenderTests
ReportInterval = TimeSpan.FromMilliseconds(50)
});
var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var sender = new HealthReportSender(
collector,
transport,
@@ -85,7 +94,9 @@ public class HealthReportSenderTests
catch (OperationCanceledException) { }
Assert.True(transport.SentReports.Count >= 1);
Assert.Equal(1, transport.SentReports[0].SequenceNumber);
Assert.True(
transport.SentReports[0].SequenceNumber >= beforeCtor,
$"First sequence {transport.SentReports[0].SequenceNumber} should be >= startup epoch {beforeCtor}");
}
[Fact]
@@ -126,19 +137,21 @@ public class HealthReportSenderTests
}
[Fact]
public void InitialSequenceNumberIsZero()
public void InitialSequenceNumberSeededWithUnixMs()
{
var transport = new FakeTransport();
var collector = new SiteHealthCollector();
var options = Options.Create(new HealthMonitoringOptions());
var beforeCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var sender = new HealthReportSender(
collector,
transport,
options,
NullLogger<HealthReportSender>.Instance,
new FakeSiteIdentityProvider());
var afterCtor = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Assert.Equal(0, sender.CurrentSequenceNumber);
Assert.InRange(sender.CurrentSequenceNumber, beforeCtor, afterCtor);
}
}