diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index c72d623..8d3558b 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -35,6 +35,70 @@ The `IsAlarm` flag originates from the `AlarmExtension` primitive in the Galaxy For each alarm attribute, the code verifies that a corresponding `InAlarm` sub-attribute variable node exists in `_tagToVariableNode` (constructed from `FullTagReference + ".InAlarm"`). If the variable node is missing, the alarm is skipped -- this prevents creating orphaned alarm conditions for attributes whose extension primitives were not published. +## Template-Based Alarm Object Filter + +When large galaxies contain more alarm-bearing objects than clients need, `OpcUa.AlarmFilter.ObjectFilters` restricts alarm condition creation to a subset of objects selected by **template name pattern**. The filter is applied at both alarm creation sites -- the full build in `BuildAddressSpace` and the subtree rebuild path triggered by Galaxy redeployment -- so the included set is recomputed on every rebuild against the fresh hierarchy. + +### Matching rules + +- `*` is the only wildcard (glob-style, zero or more characters). All other regex metacharacters are escaped and matched literally. +- Matching is case-insensitive. +- The leading `$` used by Galaxy template `tag_name` values is normalized away on both the stored chain entry and the operator pattern, so `TestMachine*` matches the stored `$TestMachine`. +- Each configured entry may itself be comma-separated for operator convenience (`"TestMachine*, Pump_*"`). +- An empty list disables the filter and restores the prior behavior: every alarm-bearing object is tracked when `AlarmTrackingEnabled=true`. + +### What gets included + +Every Galaxy object whose **template derivation chain** contains any template matching any pattern is included. The chain walks `gobject.derived_from_gobject_id` from the instance through its immediate template and each ancestor template, up to `$Object`. An instance of `TestCoolMachine` whose chain is `$TestCoolMachine -> $TestMachine -> $UserDefined` matches the pattern `TestMachine` via the ancestor hit. + +Inclusion propagates down the **containment hierarchy**: if an object matches, all of its descendants are included as well, regardless of their own template chains. This lets operators target a parent and pick up all its alarm-bearing children with one pattern. + +Each object is evaluated exactly once. Overlapping matches (multiple patterns hit, or both an ancestor and descendant match independently) never produce duplicate alarm condition subscriptions -- the filter operates on object identity via a `HashSet` of included `GobjectId` values. + +### Resolution algorithm + +`AlarmObjectFilter.ResolveIncludedObjects(hierarchy)` runs once per build: + +1. Compile each pattern into a regex with `IgnoreCase | CultureInvariant | Compiled`. +2. Build a `parent -> children` map from the hierarchy. Orphans (parent id not in the hierarchy) are treated as roots. +3. BFS from each root with a `(nodeId, parentIncluded)` queue and a `visited` set for cycle defense. +4. At each node: if the parent was included OR any chain entry matches any pattern, add the node and mark its subtree as included. +5. Return the `HashSet` of included object IDs. When no patterns are configured the filter is disabled and the method returns `null`, which the alarm loop treats as "no filtering". + +After each resolution, `UnmatchedPatterns` exposes any raw pattern that matched zero objects so the startup log can warn about operator typos without failing startup. + +### How the alarm loop applies the filter + +```csharp +// LmxNodeManager.BuildAddressSpace (and the subtree rebuild path) +if (_alarmTrackingEnabled) +{ + var includedIds = ResolveAlarmFilterIncludedIds(sorted); // null if no filter + foreach (var obj in sorted) + { + if (obj.IsArea) continue; + if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue; + // ... existing alarm-attribute collection + AlarmConditionState creation + } +} +``` + +`ResolveAlarmFilterIncludedIds` also emits a one-line summary (`Alarm filter: X of Y objects included (Z pattern(s))`) and per-pattern warnings for patterns that matched nothing. The included count is published to the dashboard via `AlarmFilterIncludedObjectCount`. + +### Runtime telemetry + +`LmxNodeManager` exposes three read-only properties populated by the filter: + +- `AlarmFilterEnabled` -- true when patterns are configured. +- `AlarmFilterPatternCount` -- number of compiled patterns. +- `AlarmFilterIncludedObjectCount` -- number of objects in the most recent included set. + +`StatusReportService` reads these into `AlarmStatusInfo.FilterEnabled`, `FilterPatternCount`, and `FilterIncludedObjectCount`. The Alarms panel on the dashboard renders `Filter: N pattern(s), M object(s) included` only when the filter is enabled. See [Status Dashboard](StatusDashboard.md#alarms). + +### Validator warning + +`ConfigurationValidator.ValidateAndLog()` logs the effective filter at startup and emits a `Warning` if `AlarmFilter.ObjectFilters` is non-empty while `AlarmTrackingEnabled` is `false`, because the filter would have no effect. + ## AlarmConditionState Creation Each detected alarm attribute produces an `AlarmConditionState` node: diff --git a/docs/Configuration.md b/docs/Configuration.md index 32b4ac9..f405728 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -55,6 +55,7 @@ Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfigu | `MaxSessions` | `int` | `100` | Maximum simultaneous OPC UA sessions | | `SessionTimeoutMinutes` | `int` | `30` | Idle session timeout in minutes | | `AlarmTrackingEnabled` | `bool` | `false` | Enables `AlarmConditionState` nodes for alarm attributes | +| `AlarmFilter.ObjectFilters` | `List` | `[]` | Wildcard template-name patterns (with `*`) that scope alarm tracking to matching objects and their descendants. Empty list disables filtering. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) | | `ApplicationUri` | `string?` | `null` | Explicit application URI for this server instance. Required when redundancy is enabled. Defaults to `urn:{GalaxyName}:LmxOpcUa` when null | ### MxAccess @@ -232,6 +233,7 @@ Example — two-instance redundant pair (Primary): Three boolean properties act as feature flags that control optional subsystems: - **`OpcUa.AlarmTrackingEnabled`** -- When `true`, the node manager creates `AlarmConditionState` nodes for alarm attributes and monitors `InAlarm` transitions. Disabled by default because alarm tracking adds per-attribute overhead. +- **`OpcUa.AlarmFilter.ObjectFilters`** -- List of wildcard template-name patterns that scope alarm tracking to matching objects and their descendants. An empty list preserves the current unfiltered behavior; a non-empty list includes an object only when any name in its template derivation chain matches any pattern, then propagates the inclusion to every descendant in the containment hierarchy. `*` is the only wildcard, matching is case-insensitive, and the Galaxy `$` prefix on template names is normalized so operators can write `TestMachine*` instead of `$TestMachine*`. Each list entry may itself contain comma-separated patterns (`"TestMachine*, Pump_*"`) for convenience. When the list is non-empty but `AlarmTrackingEnabled` is `false`, the validator emits a warning because the filter has no effect. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) for the full matching algorithm and telemetry. - **`Historian.Enabled`** -- When `true`, the service calls `HistorianPluginLoader.TryLoad(config)` to load the `ZB.MOM.WW.LmxOpcUa.Historian.Aveva` plugin from the `Historian/` subfolder next to the host exe and registers the resulting `IHistorianDataSource` with the OPC UA server host. Disabled by default because not all deployments have a Historian instance -- when disabled the plugin is not probed and the Wonderware SDK DLLs are not required on the host. If the flag is `true` but the plugin or its SDK dependencies cannot be loaded, the server still starts and every history read returns `BadHistoryOperationUnsupported` with a warning in the log. - **`GalaxyRepository.ExtendedAttributes`** -- When `true`, the repository loads additional Galaxy attribute metadata beyond the core set needed for the address space. Disabled by default to minimize startup query time. @@ -247,6 +249,7 @@ Three boolean properties act as feature flags that control optional subsystems: - Unknown security profile names are logged as warnings - `AutoAcceptClientCertificates = true` emits a warning - Only-`None` profile configuration emits a warning +- `OpcUa.AlarmFilter.ObjectFilters` is non-empty while `OpcUa.AlarmTrackingEnabled = false` emits a warning (filter has no effect) - `OpcUa.ApplicationUri` must be set when `Redundancy.Enabled = true` - `Redundancy.ServiceLevelBase` must be between 1 and 255 - `Redundancy.ServerUris` should contain at least 2 entries when enabled @@ -282,6 +285,9 @@ Integration tests use this constructor to inject substitute implementations of ` "MaxSessions": 100, "SessionTimeoutMinutes": 30, "AlarmTrackingEnabled": false, + "AlarmFilter": { + "ObjectFilters": [] + }, "ApplicationUri": null }, "MxAccess": { diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index 180b132..89bbf84 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -21,7 +21,7 @@ All queries are embedded as `const string` fields in `GalaxyRepositoryService`. ### Hierarchy query -Returns deployed Galaxy objects with their parent relationships and browse names: +Returns deployed Galaxy objects with their parent relationships, browse names, and template derivation chains: - Joins `gobject` to `template_definition` to filter by relevant `category_id` values (1, 3, 4, 10, 11, 13, 17, 24, 26) - Uses `contained_name` as the browse name, falling back to `tag_name` when `contained_name` is null or empty @@ -29,6 +29,7 @@ Returns deployed Galaxy objects with their parent relationships and browse names - Marks objects with `category_id = 13` as areas - Filters to `is_template = 0` (instances only, not templates) - Filters to `deployed_package_id <> 0` (deployed objects only) +- Returns a `template_chain` column built by a recursive CTE that walks `gobject.derived_from_gobject_id` from each instance through its immediate template and ancestor templates (depth guard `< 10`). Template names are ordered by depth and joined with `|` via `STUFF(... FOR XML PATH(''))`. Example: `TestMachine_001` returns `$TestMachine|$gMachine|$gUserDefined|$UserDefined`. The C# repository reader splits the column on `|`, trims, and populates `GalaxyObjectInfo.TemplateChain`, which is consumed by `AlarmObjectFilter` for template-based alarm filtering. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter). ### Attributes query (standard) diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index b760cd7..f1a2ec0 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -16,6 +16,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable | `MaxSessions` | `100` | Maximum concurrent client sessions | | `SessionTimeoutMinutes` | `30` | Idle session timeout | | `AlarmTrackingEnabled` | `false` | Enables `AlarmConditionState` nodes for alarm attributes | +| `AlarmFilter.ObjectFilters` | `[]` | Wildcard template-name patterns that scope alarm tracking to matching objects and their descendants (see [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter)) | The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`. diff --git a/docs/StatusDashboard.md b/docs/StatusDashboard.md index cf0da0a..a1661bd 100644 --- a/docs/StatusDashboard.md +++ b/docs/StatusDashboard.md @@ -127,6 +127,11 @@ New operation names are auto-registered on first use, so the `Operations` dictio | `TransitionCount` | `long` | Total `InAlarm` transitions observed in the dispatch loop since startup | | `AckEventCount` | `long` | Total alarm acknowledgement transitions observed since startup | | `AckWriteFailures` | `long` | Total MXAccess AckMsg writes that have failed while processing alarm acknowledges. Any non-zero value latches the service into Degraded (see Rule 2d). | +| `FilterEnabled` | `bool` | Whether `OpcUa.AlarmFilter.ObjectFilters` has any patterns configured | +| `FilterPatternCount` | `int` | Number of compiled filter patterns (after comma-splitting and trimming) | +| `FilterIncludedObjectCount` | `int` | Number of Galaxy objects included by the filter during the most recent address-space build. Zero when the filter is disabled. | + +When the filter is active, the operator dashboard's Alarms panel renders an extra line `Filter: N pattern(s), M object(s) included` so operators can verify scope at a glance. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) for the matching rules and resolution algorithm. ### Redundancy diff --git a/gr/queries/hierarchy.sql b/gr/queries/hierarchy.sql index 55ee35d..74fc08d 100644 --- a/gr/queries/hierarchy.sql +++ b/gr/queries/hierarchy.sql @@ -17,7 +17,41 @@ -- 17 = $DIObject (DI objects) -- 24 = $DDESuiteLinkClient -- 26 = $OPCClient +-- +-- template_chain (new): pipe-delimited list of template tag_names walking +-- gobject.derived_from_gobject_id from the instance upward. Index 0 is the +-- object's own immediate template, the last entry is the most ancestral +-- template before $Object. Mirrors the deployed_package_chain CTE pattern +-- in attributes.sql. Consumed by AlarmObjectFilter for template-based +-- alarm filtering with wildcard support. +;WITH template_chain AS ( + -- Start from each non-template deployed instance's own template + SELECT + g.gobject_id AS instance_gobject_id, + t.gobject_id AS template_gobject_id, + t.tag_name AS template_tag_name, + t.derived_from_gobject_id, + 0 AS depth + FROM gobject g + INNER JOIN gobject t + ON t.gobject_id = g.derived_from_gobject_id + WHERE g.is_template = 0 + AND g.deployed_package_id <> 0 + AND g.derived_from_gobject_id <> 0 + UNION ALL + -- Walk up the template derivation chain + SELECT + tc.instance_gobject_id, + t.gobject_id, + t.tag_name, + t.derived_from_gobject_id, + tc.depth + 1 + FROM template_chain tc + INNER JOIN gobject t + ON t.gobject_id = tc.derived_from_gobject_id + WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 +) SELECT DISTINCT g.gobject_id, g.tag_name, @@ -33,7 +67,17 @@ SELECT DISTINCT CASE WHEN td.category_id = 13 THEN 1 ELSE 0 - END AS is_area + END AS is_area, + ISNULL( + STUFF(( + SELECT '|' + tc.template_tag_name + FROM template_chain tc + WHERE tc.instance_gobject_id = g.gobject_id + ORDER BY tc.depth + FOR XML PATH('') + ), 1, 1, ''), + '' + ) AS template_chain FROM gobject g INNER JOIN template_definition td ON g.template_definition_id = td.template_definition_id diff --git a/service_info.md b/service_info.md index 5372a1c..0fda575 100644 --- a/service_info.md +++ b/service_info.md @@ -324,6 +324,145 @@ Code changes: No configuration changes required. All security gaps (1-10) resolved. +## Historian Plugin Runtime Load + Dashboard Health + +Updated: `2026-04-12 18:47-18:49 America/New_York` + +Both instances updated to the latest build. Brings in the runtime-loaded Historian plugin (`Historian/` subfolder next to the Host) and the status dashboard health surface for historian plugin + alarm-tracking misconfiguration. + +Backups created before deploy: +- `C:\publish\lmxopcua\backups\20260412-184713-instance1` +- `C:\publish\lmxopcua\backups\20260412-184713-instance2` + +Configuration preserved: +- `C:\publish\lmxopcua\instance1\appsettings.json` was not overwritten. +- `C:\publish\lmxopcua\instance2\appsettings.json` was not overwritten. + +Layout change: +- Flat historian interop DLLs removed from each instance root (`aahClient*.dll`, `ArchestrA.CloudHistorian.Contract.dll`, `Historian.CBE.dll`, `Historian.DPAPI.dll`). +- Historian plugin + interop DLLs now live under `\Historian\` (including `ZB.MOM.WW.LmxOpcUa.Historian.Aveva.dll`), loaded by `HistorianPluginLoader`. + +Deployed binary (both instances): +- `ZB.MOM.WW.LmxOpcUa.Host.exe` +- Last write time: `2026-04-12 18:46:22 -04:00` +- Size: `7938048` + +Windows services: +- `LmxOpcUa` — Running, PID `40176` +- `LmxOpcUa2` — Running, PID `34400` + +Restart evidence (instance1 `logs/lmxopcua-20260412.log`): +``` +2026-04-12 18:48:02.968 -04:00 [INF] Historian.Enabled=true, ServerName=localhost, IntegratedSecurity=true, Port=32568 +2026-04-12 18:48:02.971 -04:00 [INF] === Configuration Valid === +2026-04-12 18:48:09.658 -04:00 [INF] Historian plugin loaded from C:\publish\lmxopcua\instance1\Historian\ZB.MOM.WW.LmxOpcUa.Historian.Aveva.dll +2026-04-12 18:48:13.691 -04:00 [INF] LmxOpcUa service started successfully +``` + +Restart evidence (instance2 `logs/lmxopcua-20260412.log`): +``` +2026-04-12 18:49:08.152 -04:00 [INF] Historian.Enabled=true, ServerName=localhost, IntegratedSecurity=true, Port=32568 +2026-04-12 18:49:08.155 -04:00 [INF] === Configuration Valid === +2026-04-12 18:49:14.744 -04:00 [INF] Historian plugin loaded from C:\publish\lmxopcua\instance2\Historian\ZB.MOM.WW.LmxOpcUa.Historian.Aveva.dll +2026-04-12 18:49:18.777 -04:00 [INF] LmxOpcUa service started successfully +``` + +CLI verification (via `dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Client.CLI`): +``` +connect opc.tcp://localhost:4840/LmxOpcUa → Server: LmxOpcUa +connect opc.tcp://localhost:4841/LmxOpcUa → Server: LmxOpcUa2 +redundancy opc.tcp://localhost:4840/LmxOpcUa → Warm, ServiceLevel=200, urn:localhost:LmxOpcUa:instance1 +redundancy opc.tcp://localhost:4841/LmxOpcUa → Warm, ServiceLevel=150, urn:localhost:LmxOpcUa:instance2 +``` + +Both instances report the same `ServerUriArray` and the primary advertises the higher ServiceLevel, matching the prior redundancy baseline. + +## Endpoints Panel on Dashboard + +Updated: `2026-04-13 08:46-08:50 America/New_York` + +Both instances updated with a new `Endpoints` panel on the status dashboard surfacing the opc.tcp base addresses, active OPC UA security profiles (mode + policy name + full URI), and user token policies. + +Code changes: +- `StatusData.cs` — added `EndpointsInfo` / `SecurityProfileInfo` DTOs on `StatusData`. +- `OpcUaServerHost.cs` — added `BaseAddresses`, `SecurityPolicies`, `UserTokenPolicies` runtime accessors reading `ApplicationConfiguration.ServerConfiguration` live state. +- `StatusReportService.cs` — builds `EndpointsInfo` from the host and renders a new panel with a graceful empty state when the server is not started. + +No configuration changes required. + +Verification (instance1 @ `http://localhost:8085/`): +``` +Base Addresses: opc.tcp://localhost:4840/LmxOpcUa +Security Profiles: None / None / http://opcfoundation.org/UA/SecurityPolicy#None +User Token Policies: Anonymous, UserName +``` + +Verification (instance2 @ `http://localhost:8086/`): +``` +Base Addresses: opc.tcp://localhost:4841/LmxOpcUa +Security Profiles: None / None / http://opcfoundation.org/UA/SecurityPolicy#None +User Token Policies: Anonymous, UserName +``` + +## Template-Based Alarm Object Filter + +Updated: `2026-04-13 09:39-09:43 America/New_York` + +Both instances updated with a new configurable alarm object filter. When `OpcUa.AlarmFilter.ObjectFilters` is non-empty, only Galaxy objects whose template derivation chain matches a pattern (and their containment-tree descendants) contribute `AlarmConditionState` nodes. When the list is empty, the current unfiltered behavior is preserved (backward-compatible default). + +Backups created before deploy: +- `C:\publish\lmxopcua\backups\20260413-093900-instance1` +- `C:\publish\lmxopcua\backups\20260413-093900-instance2` + +Deployed binary (both instances): +- `ZB.MOM.WW.LmxOpcUa.Host.exe` +- Last write time: `2026-04-13 09:38:46 -04:00` +- Size: `7951360` + +Windows services: +- `LmxOpcUa` — Running, PID `40900` +- `LmxOpcUa2` — Running, PID `29936` + +Code changes: +- `gr/queries/hierarchy.sql` — added recursive CTE on `gobject.derived_from_gobject_id` and a new `template_chain` column (pipe-delimited, innermost template first). +- `Domain/GalaxyObjectInfo.cs` — added `TemplateChain: List` populated from the new SQL column. +- `GalaxyRepositoryService.cs` — reads the new column and splits into `TemplateChain`. +- `Configuration/AlarmFilterConfiguration.cs` (new) — `List ObjectFilters`; entries may themselves be comma-separated. Attached to `OpcUaConfiguration.AlarmFilter`. +- `Configuration/ConfigurationValidator.cs` — logs the effective filter and warns if patterns are configured while `AlarmTrackingEnabled == false`. +- `Domain/AlarmObjectFilter.cs` (new) — compiles wildcard patterns (`*` only) to case-insensitive regexes with Galaxy `$` prefix normalized on both sides; walks the hierarchy top-down with cycle defense; returns a `HashSet` of included gobject IDs plus `UnmatchedPatterns` for startup warnings. +- `OpcUa/LmxNodeManager.cs` — constructor accepts the filter; the two alarm-creation loops (`BuildAddressSpace` full build and the subtree rebuild path) both call `ResolveAlarmFilterIncludedIds(sorted)` and skip any object not in the resolved set. New public properties expose filter state to the dashboard: `AlarmFilterEnabled`, `AlarmFilterPatternCount`, `AlarmFilterIncludedObjectCount`. +- `OpcUa/OpcUaServerHost.cs`, `OpcUa/LmxOpcUaServer.cs`, `OpcUaService.cs`, `OpcUaServiceBuilder.cs` — plumbing to construct and thread the filter from `appsettings.json` down to the node manager. +- `Status/StatusData.cs` + `Status/StatusReportService.cs` — `AlarmStatusInfo` gains `FilterEnabled`, `FilterPatternCount`, `FilterIncludedObjectCount`; a filter summary line renders in the Alarms panel when the filter is active. + +Tests: +- 36 new unit tests in `tests/.../Domain/AlarmObjectFilterTests.cs` covering pattern parsing, wildcard semantics, regex escaping, Galaxy `$` normalization, template-chain matching, subtree propagation, set semantics, orphan/cycle defense, and `UnmatchedPatterns` tracking. +- 5 new integration tests in `tests/.../Integration/AlarmObjectFilterIntegrationTests.cs` spinning up a real `LmxNodeManager` via `OpcUaServerFixture` and asserting `AlarmConditionCount`/`AlarmFilterIncludedObjectCount` under various filters. +- 1 new Status test verifying JSON exposes the filter counters. +- Full suite: **446/446 tests passing** (no regressions). + +Configuration change: both instances have `OpcUa.AlarmFilter.ObjectFilters: []` (filter disabled, unfiltered alarm tracking preserved). + +Live verification against instance1 Galaxy (filter temporarily set to `"TestMachine"`): +``` +2026-04-13 09:41:31 [INF] OpcUa.AlarmTrackingEnabled=true, AlarmFilter.ObjectFilters=[TestMachine] +2026-04-13 09:41:42 [INF] Alarm filter: 42 of 49 objects included (1 pattern(s)) +Dashboard Alarms panel: Tracking: True | Conditions: 60 | Active: 4 + Filter: 1 pattern(s), 42 object(s) included +``` + +Final configuration restored to empty filter. Dashboard confirms unfiltered behavior on both endpoints: +``` +instance1 @ http://localhost:8085/ → Conditions: 60 | Active: 4 (no filter line) +instance2 @ http://localhost:8086/ → Conditions: 60 | Active: 4 (no filter line) +``` + +Filter syntax quick reference (documented in `AlarmFilterConfiguration.cs` XML-doc): +- `*` is the only wildcard (glob-style; zero or more characters). +- Matching is case-insensitive and ignores the Galaxy leading `$` template prefix on both the pattern and the stored chain entry, so operators write `TestMachine*` not `$TestMachine*`. +- Each entry may contain comma-separated patterns for convenience (e.g., `"TestMachine*, Pump_*"`). +- Empty list → filter disabled → current unfiltered behavior. +- Match semantics: an object is included when any template in its derivation chain matches any pattern, and the inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated once regardless of how many patterns or ancestors match. + ## Notes The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AlarmFilterConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AlarmFilterConfiguration.cs new file mode 100644 index 0000000..22e8a92 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AlarmFilterConfiguration.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration +{ + /// + /// Configures the template-based alarm object filter under OpcUa.AlarmFilter. + /// + /// + /// Each entry in is a wildcard pattern matched against the template + /// derivation chain of every Galaxy object. Supported wildcard: *. Matching is case-insensitive + /// and the leading $ used by Galaxy template tag_names is normalized away, so operators can + /// write TestMachine* instead of $TestMachine*. An entry may itself contain comma-separated + /// patterns for convenience (e.g., "TestMachine*, Pump_*"). An empty list disables the filter, + /// restoring current behavior: all alarm-bearing objects are monitored when + /// is . + /// + public class AlarmFilterConfiguration + { + /// + /// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions. + /// An object is included when any template in its derivation chain matches any pattern, and the + /// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated + /// once: overlapping matches never create duplicate alarm subscriptions. + /// + public List ObjectFilters { get; set; } = new(); + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index 04337f1..6b8ca35 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -52,6 +52,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration valid = false; } + // Alarm filter + var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0; + Log.Information( + "OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]", + config.OpcUa.AlarmTrackingEnabled, + alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters)); + if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled) + Log.Warning( + "OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect", + alarmFilterCount); + // MxAccess Log.Information( "MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}", diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs index 7a96e94..f72ff2f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs @@ -53,5 +53,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored. /// public bool AlarmTrackingEnabled { get; set; } = false; + + /// + /// Gets or sets the template-based alarm object filter. When + /// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only + /// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored. + /// + public AlarmFilterConfiguration AlarmFilter { get; set; } = new(); } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/AlarmObjectFilter.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/AlarmObjectFilter.cs new file mode 100644 index 0000000..680bbea --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/AlarmObjectFilter.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Serilog; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.LmxOpcUa.Host.Domain +{ + /// + /// Compiles and applies wildcard template patterns against Galaxy objects to decide which + /// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB — + /// so it is fully unit-testable with synthetic hierarchies. + /// + /// + /// Matching rules: + /// + /// An object is included when any template name in its derivation chain matches + /// any configured pattern. + /// Matching is case-insensitive and ignores the Galaxy leading $ prefix on + /// both the chain entry and the user pattern, so TestMachine* matches the stored + /// $TestMachine. + /// Inclusion propagates to every descendant of a matched object (containment subtree). + /// Each object is evaluated once — overlapping matches never produce duplicate + /// inclusions (set semantics). + /// + /// Pattern syntax: literal text plus * wildcards (zero or more characters). + /// Other regex metacharacters in the raw pattern are escaped and treated literally. + /// + public class AlarmObjectFilter + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly List _patterns; + private readonly List _rawPatterns; + private readonly HashSet _matchedRawPatterns; + + /// + /// Initializes a new alarm object filter from the supplied configuration section. + /// + /// The alarm filter configuration whose + /// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns. + public AlarmObjectFilter(AlarmFilterConfiguration? config) + { + _patterns = new List(); + _rawPatterns = new List(); + _matchedRawPatterns = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (config?.ObjectFilters == null) + return; + + foreach (var entry in config.ObjectFilters) + { + if (string.IsNullOrWhiteSpace(entry)) + continue; + + foreach (var piece in entry.Split(',')) + { + var trimmed = piece.Trim(); + if (trimmed.Length == 0) + continue; + + try + { + var normalized = Normalize(trimmed); + var regex = GlobToRegex(normalized); + _patterns.Add(regex); + _rawPatterns.Add(trimmed); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed); + } + } + } + } + + /// + /// Gets a value indicating whether the filter has any compiled patterns. When , + /// callers should treat alarm tracking as unfiltered (current behavior preserved). + /// + public bool Enabled => _patterns.Count > 0; + + /// + /// Gets the number of compiled patterns the filter will evaluate against each object. + /// + public int PatternCount => _patterns.Count; + + /// + /// Gets the raw pattern strings that did not match any object in the most recent call to + /// . Useful for startup warnings about operator typos. + /// + public IReadOnlyList UnmatchedPatterns => + _rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList(); + + /// + /// Returns when any template name in matches any + /// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern + /// equal to * (which collapses to an empty-matching regex after normalization). + /// + /// The template derivation chain to test (own template first, ancestors after). + public bool MatchesTemplateChain(IReadOnlyList? chain) + { + if (chain == null || chain.Count == 0 || _patterns.Count == 0) + return false; + + for (var i = 0; i < _patterns.Count; i++) + { + var regex = _patterns[i]; + for (var j = 0; j < chain.Count; j++) + { + var entry = chain[j]; + if (string.IsNullOrEmpty(entry)) + continue; + if (regex.IsMatch(Normalize(entry))) + { + _matchedRawPatterns.Add(_rawPatterns[i]); + return true; + } + } + } + + return false; + } + + /// + /// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms + /// should be monitored, honoring both template matching and descendant propagation. Returns + /// when the filter is disabled so callers can skip the containment check + /// entirely. + /// + /// The full deployed Galaxy hierarchy, as returned by the repository service. + /// The set of included gobject IDs, or when filtering is disabled. + public HashSet? ResolveIncludedObjects(IReadOnlyList? hierarchy) + { + if (!Enabled) + return null; + + _matchedRawPatterns.Clear(); + var included = new HashSet(); + if (hierarchy == null || hierarchy.Count == 0) + return included; + + var byId = new Dictionary(hierarchy.Count); + foreach (var obj in hierarchy) + byId[obj.GobjectId] = obj; + + var childrenByParent = new Dictionary>(); + foreach (var obj in hierarchy) + { + var parentId = obj.ParentGobjectId; + if (parentId != 0 && !byId.ContainsKey(parentId)) + parentId = 0; // orphan → treat as root + if (!childrenByParent.TryGetValue(parentId, out var list)) + { + list = new List(); + childrenByParent[parentId] = list; + } + list.Add(obj.GobjectId); + } + + var roots = childrenByParent.TryGetValue(0, out var rootList) + ? rootList + : new List(); + + var visited = new HashSet(); + var queue = new Queue<(int Id, bool ParentIncluded)>(); + foreach (var rootId in roots) + queue.Enqueue((rootId, false)); + + while (queue.Count > 0) + { + var (id, parentIncluded) = queue.Dequeue(); + if (!visited.Add(id)) + continue; // cycle defense + + if (!byId.TryGetValue(id, out var obj)) + continue; + + var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain); + if (nodeIncluded) + included.Add(id); + + if (childrenByParent.TryGetValue(id, out var children)) + foreach (var childId in children) + queue.Enqueue((childId, nodeIncluded)); + } + + return included; + } + + private static Regex GlobToRegex(string normalized) + { + var segments = normalized.Split('*'); + var parts = segments.Select(Regex.Escape); + var body = string.Join(".*", parts); + return new Regex("^" + body + "$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + } + + private static string Normalize(string value) + { + var trimmed = value.Trim(); + if (trimmed.StartsWith("$", StringComparison.Ordinal)) + return trimmed.Substring(1); + return trimmed; + } + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs index 169e291..60e6f77 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// @@ -34,5 +36,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain /// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object. /// public bool IsArea { get; set; } + + /// + /// Gets or sets the template derivation chain for this object. Index 0 is the object's own template; + /// subsequent entries walk up toward the most ancestral template before $Object. Populated by + /// the recursive CTE in hierarchy.sql on gobject.derived_from_gobject_id. Used by + /// to decide whether an object's alarms should be monitored. + /// + public List TemplateChain { get; set; } = new(); } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs index 4db7813..f45797a 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data.SqlClient; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Serilog; @@ -48,6 +49,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) + { + var templateChainRaw = reader.IsDBNull(6) ? "" : reader.GetString(6); + var templateChain = string.IsNullOrEmpty(templateChainRaw) + ? new List() + : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + results.Add(new GalaxyObjectInfo { GobjectId = Convert.ToInt32(reader.GetValue(0)), @@ -55,8 +65,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2), BrowseName = reader.GetString(3), ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), - IsArea = Convert.ToInt32(reader.GetValue(5)) == 1 + IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, + TemplateChain = templateChain }); + } if (results.Count == 0) Log.Warning("GetHierarchyAsync returned zero rows"); @@ -194,6 +206,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository #region SQL Queries (GR-006: const string, no dynamic SQL) private const string HierarchySql = @" +;WITH template_chain AS ( + SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, + t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth + FROM gobject g + INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 + UNION ALL + SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1 + FROM template_chain tc + INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id + WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 +) SELECT DISTINCT g.gobject_id, g.tag_name, @@ -209,7 +233,17 @@ SELECT DISTINCT CASE WHEN td.category_id = 13 THEN 1 ELSE 0 - END AS is_area + END AS is_area, + ISNULL( + STUFF(( + SELECT '|' + tc.template_tag_name + FROM template_chain tc + WHERE tc.instance_gobject_id = g.gobject_id + ORDER BY tc.depth + FOR XML PATH('') + ), 1, 1, ''), + '' + ) AS template_chain FROM gobject g INNER JOIN template_definition td ON g.template_definition_id = td.template_definition_id diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index bee777f..32c380d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -29,6 +29,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly Dictionary _alarmPriorityTags = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _alarmDescTags = new(StringComparer.OrdinalIgnoreCase); private readonly bool _alarmTrackingEnabled; + private readonly AlarmObjectFilter? _alarmObjectFilter; + private int _alarmFilterIncludedObjectCount; private readonly bool _anonymousCanWrite; private readonly AutoResetEvent _dataChangeSignal = new(false); private readonly Dictionary> _gobjectToTagRefs = new(); @@ -88,6 +90,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// The metrics collector used to track node manager activity. /// The optional historian adapter used to satisfy OPC UA history read requests. /// Enables alarm-condition state generation for Galaxy attributes modeled as alarms. + /// Optional template-based object filter. When supplied and enabled, only Galaxy + /// objects whose template derivation chain matches a pattern (and their descendants) contribute alarm conditions. + /// A or disabled filter preserves the current unfiltered behavior. public LmxNodeManager( IServerInternal server, ApplicationConfiguration configuration, @@ -100,7 +105,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa NodeId? writeOperateRoleId = null, NodeId? writeTuneRoleId = null, NodeId? writeConfigureRoleId = null, - NodeId? alarmAckRoleId = null) + NodeId? alarmAckRoleId = null, + AlarmObjectFilter? alarmObjectFilter = null) : base(server, configuration, namespaceUri) { _namespaceUri = namespaceUri; @@ -108,6 +114,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _metrics = metrics; _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; + _alarmObjectFilter = alarmObjectFilter; _anonymousCanWrite = anonymousCanWrite; _writeOperateRoleId = writeOperateRoleId; _writeTuneRoleId = writeTuneRoleId; @@ -161,6 +168,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// public bool AlarmTrackingEnabled => _alarmTrackingEnabled; + /// + /// Gets a value indicating whether the template-based alarm object filter is enabled. + /// + public bool AlarmFilterEnabled => _alarmObjectFilter?.Enabled ?? false; + + /// + /// Gets the number of compiled alarm filter patterns. + /// + public int AlarmFilterPatternCount => _alarmObjectFilter?.PatternCount ?? 0; + + /// + /// Gets the number of Galaxy objects included by the alarm filter during the most recent address-space build. + /// + public int AlarmFilterIncludedObjectCount => _alarmFilterIncludedObjectCount; + /// /// Gets the number of distinct alarm conditions currently tracked (one per alarm attribute). /// @@ -337,9 +359,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Build alarm tracking: create AlarmConditionState for each alarm attribute if (_alarmTrackingEnabled) + { + var includedIds = ResolveAlarmFilterIncludedIds(sorted); foreach (var obj in sorted) { if (obj.IsArea) continue; + if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue; if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; var hasAlarms = false; @@ -419,6 +444,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode)) EnableEventNotifierUpChain(objNode); } + } // Auto-subscribe to InAlarm tags so we detect alarm transitions if (_alarmTrackingEnabled) @@ -433,6 +459,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } + /// + /// Resolves the alarm object filter against the given hierarchy, updates the published include count, + /// emits a one-line summary log when the filter is active, and warns about patterns that matched nothing. + /// Returns when no filter is configured so the alarm loop continues unfiltered. + /// + private HashSet? ResolveAlarmFilterIncludedIds(IReadOnlyList sorted) + { + if (_alarmObjectFilter == null || !_alarmObjectFilter.Enabled) + { + _alarmFilterIncludedObjectCount = 0; + return null; + } + + var includedIds = _alarmObjectFilter.ResolveIncludedObjects(sorted); + _alarmFilterIncludedObjectCount = includedIds?.Count ?? 0; + + Log.Information( + "Alarm filter: {IncludedCount} of {TotalCount} objects included ({PatternCount} pattern(s))", + _alarmFilterIncludedObjectCount, sorted.Count, _alarmObjectFilter.PatternCount); + + foreach (var unmatched in _alarmObjectFilter.UnmatchedPatterns) + Log.Warning("Alarm filter pattern matched zero objects: {Pattern}", unmatched); + + return includedIds; + } + private void SubscribeAlarmTags() { foreach (var kvp in _alarmInAlarmTags) @@ -863,9 +915,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Alarm tracking for the new subtree if (_alarmTrackingEnabled) { + var includedIds = ResolveAlarmFilterIncludedIds(sorted); foreach (var obj in sorted) { if (obj.IsArea) continue; + if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue; if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; var hasAlarms = false; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs index e89f601..27508f7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs @@ -18,6 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly bool _alarmTrackingEnabled; + private readonly AlarmObjectFilter? _alarmObjectFilter; private readonly string? _applicationUri; private readonly AuthenticationConfiguration _authConfig; private readonly IUserAuthenticationProvider? _authProvider; @@ -39,13 +40,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null, - RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null) + RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null, + AlarmObjectFilter? alarmObjectFilter = null) { _galaxyName = galaxyName; _mxAccessClient = mxAccessClient; _metrics = metrics; _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; + _alarmObjectFilter = alarmObjectFilter; _authConfig = authConfig ?? new AuthenticationConfiguration(); _authProvider = authProvider; _redundancyConfig = redundancyConfig ?? new RedundancyConfiguration(); @@ -85,7 +88,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, - _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId); + _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId, + _alarmObjectFilter); var nodeManagers = new List { NodeManager }; return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index b638ee8..ad96a87 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -18,6 +19,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa public class OpcUaServerHost : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); + private readonly AlarmObjectFilter? _alarmObjectFilter; private readonly AuthenticationConfiguration _authConfig; private readonly IUserAuthenticationProvider? _authProvider; @@ -42,7 +44,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null, SecurityProfileConfiguration? securityConfig = null, - RedundancyConfiguration? redundancyConfig = null) + RedundancyConfiguration? redundancyConfig = null, + AlarmObjectFilter? alarmObjectFilter = null) { _config = config; _mxAccessClient = mxAccessClient; @@ -52,6 +55,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _authProvider = authProvider; _securityConfig = securityConfig ?? new SecurityProfileConfiguration(); _redundancyConfig = redundancyConfig ?? new RedundancyConfiguration(); + _alarmObjectFilter = alarmObjectFilter; } /// @@ -69,6 +73,45 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// public bool IsRunning => _server != null; + /// + /// Gets the list of opc.tcp base addresses the server is currently listening on. + /// Returns an empty list when the server has not started. + /// + public IReadOnlyList BaseAddresses + { + get + { + var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses; + return addrs != null ? addrs.ToList() : Array.Empty(); + } + } + + /// + /// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri). + /// Returns an empty list when the server has not started. + /// + public IReadOnlyList SecurityPolicies + { + get + { + var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies; + return policies != null ? policies.ToList() : Array.Empty(); + } + } + + /// + /// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate). + /// Returns an empty list when the server has not started. + /// + public IReadOnlyList UserTokenPolicies + { + get + { + var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies; + return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty(); + } + } + /// /// Stops the host and releases server resources. /// @@ -195,7 +238,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } _server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, - _config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri); + _config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri, + _alarmObjectFilter); await _application.Start(_server); Log.Information( diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index 2dbdffb..ec3ee19 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -237,8 +237,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host _config.Authentication.Ldap.BaseDN); } + var alarmObjectFilter = new AlarmObjectFilter(_config.OpcUa.AlarmFilter); + if (alarmObjectFilter.Enabled) + Log.Information( + "Alarm object filter compiled with {PatternCount} pattern(s): [{Patterns}]", + alarmObjectFilter.PatternCount, + string.Join(", ", _config.OpcUa.AlarmFilter.ObjectFilters)); + ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, _historianDataSource, - _config.Authentication, authProvider, _config.Security, _config.Redundancy); + _config.Authentication, authProvider, _config.Security, _config.Redundancy, alarmObjectFilter); // Step 9-10: Query hierarchy, start server, build address space DateTime? initialDeployTime = null; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs index 79f4f1d..29c4d14 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; @@ -173,6 +174,31 @@ namespace ZB.MOM.WW.LmxOpcUa.Host /// /// The security profile configuration to inject. /// The current builder so additional overrides can be chained. + /// + /// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path. + /// + /// Whether alarm tracking should be enabled. + /// The current builder so additional overrides can be chained. + public OpcUaServiceBuilder WithAlarmTracking(bool enabled) + { + _config.OpcUa.AlarmTrackingEnabled = enabled; + return this; + } + + /// + /// Configures the template-based alarm object filter for integration tests. + /// + /// Zero or more wildcard patterns. Empty → filter disabled. + /// The current builder so additional overrides can be chained. + public OpcUaServiceBuilder WithAlarmFilter(params string[] filters) + { + _config.OpcUa.AlarmFilter = new AlarmFilterConfiguration + { + ObjectFilters = filters.ToList() + }; + return this; + } + public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security) { _config.Security = security; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs index d7951ab..91add15 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs @@ -54,12 +54,59 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status /// public RedundancyInfo? Redundancy { get; set; } + /// + /// Gets or sets the listening OPC UA endpoints and active security profiles. + /// + public EndpointsInfo Endpoints { get; set; } = new(); + /// /// Gets or sets footer details such as the snapshot timestamp and service version. /// public FooterInfo Footer { get; set; } = new(); } + /// + /// Dashboard model describing the OPC UA server's listening endpoints and active security profiles. + /// + public class EndpointsInfo + { + /// + /// Gets or sets the list of opc.tcp base addresses the server is listening on. + /// + public List BaseAddresses { get; set; } = new(); + + /// + /// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate). + /// + public List UserTokenPolicies { get; set; } = new(); + + /// + /// Gets or sets the active security profiles reported to clients. + /// + public List SecurityProfiles { get; set; } = new(); + } + + /// + /// Dashboard model for a single configured OPC UA server security profile. + /// + public class SecurityProfileInfo + { + /// + /// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256). + /// + public string PolicyUri { get; set; } = ""; + + /// + /// Gets or sets the short policy name extracted from the policy URI. + /// + public string PolicyName { get; set; } = ""; + + /// + /// Gets or sets the message security mode (None, Sign, SignAndEncrypt). + /// + public string SecurityMode { get; set; } = ""; + } + /// /// Dashboard model for current runtime connection details. /// @@ -246,6 +293,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status /// Gets or sets the total number of alarm acknowledgement MXAccess writes that have failed since startup. /// public long AckWriteFailures { get; set; } + + /// + /// Gets or sets a value indicating whether the template-based alarm object filter is active. + /// + public bool FilterEnabled { get; set; } + + /// + /// Gets or sets the number of compiled alarm filter patterns. + /// + public int FilterPatternCount { get; set; } + + /// + /// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build. + /// + public int FilterIncludedObjectCount { get; set; } } /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs index 74db376..a3f34c7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Text; using System.Text.Json; @@ -112,6 +113,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status Historian = historianInfo, Alarms = alarmInfo, Redundancy = BuildRedundancyInfo(), + Endpoints = BuildEndpointsInfo(), Footer = new FooterInfo { Timestamp = DateTime.UtcNow, @@ -143,10 +145,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0, TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0, AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0, - AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0 + AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0, + FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false, + FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0, + FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0 }; } + private EndpointsInfo BuildEndpointsInfo() + { + var info = new EndpointsInfo(); + if (_serverHost == null) + return info; + + info.BaseAddresses = _serverHost.BaseAddresses.ToList(); + info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList(); + foreach (var policy in _serverHost.SecurityPolicies) + { + var uri = policy.SecurityPolicyUri ?? ""; + var hashIdx = uri.LastIndexOf('#'); + var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri; + info.SecurityProfiles.Add(new SecurityProfileInfo + { + PolicyUri = uri, + PolicyName = name, + SecurityMode = policy.SecurityMode.ToString() + }); + } + + return info; + } + private RedundancyInfo? BuildRedundancyInfo() { if (_redundancyConfig == null || !_redundancyConfig.Enabled) @@ -208,6 +237,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status sb.AppendLine($"

Status: {data.Health.Status} — {data.Health.Message}

"); sb.AppendLine(""); + // Endpoints panel (exposed URLs + security profiles) + var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray"; + sb.AppendLine($"

Endpoints

"); + if (data.Endpoints.BaseAddresses.Count == 0) + { + sb.AppendLine("

No endpoints — OPC UA server not started.

"); + } + else + { + sb.AppendLine("

Base Addresses:

    "); + foreach (var addr in data.Endpoints.BaseAddresses) + sb.AppendLine($"
  • {WebUtility.HtmlEncode(addr)}
  • "); + sb.AppendLine("
"); + + sb.AppendLine("

Security Profiles:

"); + sb.AppendLine(""); + foreach (var profile in data.Endpoints.SecurityProfiles) + { + sb.AppendLine( + $"" + + $"" + + $""); + } + sb.AppendLine("
ModePolicyPolicy URI
{WebUtility.HtmlEncode(profile.SecurityMode)}{WebUtility.HtmlEncode(profile.PolicyName)}{WebUtility.HtmlEncode(profile.PolicyUri)}
"); + + if (data.Endpoints.UserTokenPolicies.Count > 0) + sb.AppendLine( + $"

User Token Policies: {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}

"); + } + sb.AppendLine("
"); + // Redundancy panel (only when enabled) if (data.Redundancy != null) { @@ -258,6 +318,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status $"

Tracking: {data.Alarms.TrackingEnabled} | Conditions: {data.Alarms.ConditionCount} | Active: {data.Alarms.ActiveAlarmCount}

"); sb.AppendLine( $"

Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}

"); + if (data.Alarms.FilterEnabled) + sb.AppendLine( + $"

Filter: {data.Alarms.FilterPatternCount} pattern(s), {data.Alarms.FilterIncludedObjectCount} object(s) included

"); sb.AppendLine(""); // Operations table diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index 43c3a10..7e90d1e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -8,6 +8,9 @@ "MaxSessions": 100, "SessionTimeoutMinutes": 30, "AlarmTrackingEnabled": false, + "AlarmFilter": { + "ObjectFilters": [] + }, "ApplicationUri": null }, "MxAccess": { diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/AlarmObjectFilterTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/AlarmObjectFilterTests.cs new file mode 100644 index 0000000..4103b77 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/AlarmObjectFilterTests.cs @@ -0,0 +1,416 @@ +using System.Collections.Generic; +using System.Linq; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain +{ + /// + /// Exhaustive coverage of the template-based alarm object filter's pattern parsing, + /// chain matching, and hierarchy-subtree propagation logic. + /// + public class AlarmObjectFilterTests + { + // ---------- Pattern parsing & compilation ---------- + + [Fact] + public void EmptyConfig_DisablesFilter() + { + var sut = new AlarmObjectFilter(new AlarmFilterConfiguration()); + sut.Enabled.ShouldBeFalse(); + sut.PatternCount.ShouldBe(0); + sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull(); + } + + [Fact] + public void NullConfig_DisablesFilter() + { + var sut = new AlarmObjectFilter(null); + sut.Enabled.ShouldBeFalse(); + sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull(); + } + + [Fact] + public void WhitespaceEntries_AreSkipped() + { + var sut = new AlarmObjectFilter(Config("", " ", "\t")); + sut.Enabled.ShouldBeFalse(); + sut.PatternCount.ShouldBe(0); + } + + [Fact] + public void CommaSeparatedEntry_SplitsIntoMultiplePatterns() + { + var sut = new AlarmObjectFilter(Config("TestMachine*, Pump_*")); + sut.Enabled.ShouldBeTrue(); + sut.PatternCount.ShouldBe(2); + } + + [Fact] + public void CommaAndListForms_Combine() + { + var sut = new AlarmObjectFilter(Config("A*, B*", "C*")); + sut.PatternCount.ShouldBe(3); + } + + [Fact] + public void WhitespaceAroundCommas_IsTrimmed() + { + var sut = new AlarmObjectFilter(Config(" TestMachine* , Pump_* ")); + sut.PatternCount.ShouldBe(2); + sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "Pump_A" }).ShouldBeTrue(); + } + + [Fact] + public void LiteralPattern_MatchesExactTemplate() + { + var sut = new AlarmObjectFilter(Config("TestMachine")); + sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "TestMachine_001" }).ShouldBeFalse(); + sut.MatchesTemplateChain(new List { "OtherMachine" }).ShouldBeFalse(); + } + + [Fact] + public void StarAlonePattern_MatchesAnyNonEmptyChain() + { + var sut = new AlarmObjectFilter(Config("*")); + sut.MatchesTemplateChain(new List { "Foo" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "Bar", "Baz" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List()).ShouldBeFalse(); + } + + [Fact] + public void PrefixWildcard_MatchesSuffix() + { + var sut = new AlarmObjectFilter(Config("*Machine")); + sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "BigMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "MachineThing" }).ShouldBeFalse(); + } + + [Fact] + public void SuffixWildcard_MatchesPrefix() + { + var sut = new AlarmObjectFilter(Config("Test*")); + sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "TestFoo" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "Machine" }).ShouldBeFalse(); + } + + [Fact] + public void BothWildcards_MatchesContains() + { + var sut = new AlarmObjectFilter(Config("*Machine*")); + sut.MatchesTemplateChain(new List { "TestMachineWidget" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "Machine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "Pump" }).ShouldBeFalse(); + } + + [Fact] + public void MiddleWildcard_MatchesWithInnerAnything() + { + var sut = new AlarmObjectFilter(Config("Test*Machine")); + sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "TestCoolMachine" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "TestMachineX" }).ShouldBeFalse(); + } + + [Fact] + public void RegexMetacharacters_AreEscapedLiterally() + { + // The '.' in Pump.v2 is a regex metachar; it must be a literal dot. + var sut = new AlarmObjectFilter(Config("Pump.v2")); + sut.MatchesTemplateChain(new List { "Pump.v2" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "PumpXv2" }).ShouldBeFalse(); + } + + [Fact] + public void Matching_IsCaseInsensitive() + { + var sut = new AlarmObjectFilter(Config("testmachine*")); + sut.MatchesTemplateChain(new List { "TestMachine_001" }).ShouldBeTrue(); + sut.MatchesTemplateChain(new List { "TESTMACHINE_XYZ" }).ShouldBeTrue(); + } + + [Fact] + public void GalaxyDollarPrefix_IsNormalizedAway_OnBothSides() + { + var sut = new AlarmObjectFilter(Config("TestMachine*")); + sut.MatchesTemplateChain(new List { "$TestMachine" }).ShouldBeTrue(); + + var withDollarInPattern = new AlarmObjectFilter(Config("$TestMachine*")); + withDollarInPattern.MatchesTemplateChain(new List { "$TestMachine" }).ShouldBeTrue(); + withDollarInPattern.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); + } + + // ---------- Template-chain matching ---------- + + [Fact] + public void ChainMatch_AtAncestorPosition_StillMatches() + { + var sut = new AlarmObjectFilter(Config("TestMachine")); + var chain = new List { "TestCoolMachine", "TestMachine", "$UserDefined" }; + sut.MatchesTemplateChain(chain).ShouldBeTrue(); + } + + [Fact] + public void ChainNoMatch_ReturnsFalse() + { + var sut = new AlarmObjectFilter(Config("TestMachine*")); + var chain = new List { "FooBar", "$UserDefined" }; + sut.MatchesTemplateChain(chain).ShouldBeFalse(); + } + + [Fact] + public void EmptyChain_NeverMatchesNonWildcard() + { + var sut = new AlarmObjectFilter(Config("TestMachine*")); + sut.MatchesTemplateChain(new List()).ShouldBeFalse(); + } + + [Fact] + public void NullChain_NeverMatches() + { + var sut = new AlarmObjectFilter(Config("TestMachine*")); + sut.MatchesTemplateChain(null).ShouldBeFalse(); + } + + [Fact] + public void SystemTemplate_MatchesWhenOperatorOptsIn() + { + var sut = new AlarmObjectFilter(Config("Area*")); + sut.MatchesTemplateChain(new List { "$Area" }).ShouldBeTrue(); + } + + [Fact] + public void DuplicateChainEntries_StillMatch() + { + var sut = new AlarmObjectFilter(Config("TestMachine")); + var chain = new List { "TestMachine", "TestMachine", "$UserDefined" }; + sut.MatchesTemplateChain(chain).ShouldBeTrue(); + } + + // ---------- Hierarchy subtree propagation ---------- + + [Fact] + public void FlatHierarchy_OnlyMatchingIdsIncluded() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), + Obj(2, parent: 0, template: "Pump"), + Obj(3, parent: 0, template: "TestMachine") + }; + var sut = new AlarmObjectFilter(Config("TestMachine*")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldContain(1); + included.ShouldContain(3); + included.ShouldNotContain(2); + included.Count.ShouldBe(2); + } + + [Fact] + public void MatchOnGrandparent_PropagatesToGrandchildren() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), // root matches + Obj(2, parent: 1, template: "UnrelatedThing"), // child — inherited + Obj(3, parent: 2, template: "UnrelatedOtherThing") // grandchild — inherited + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldBe(new[] { 1, 2, 3 }, ignoreOrder: true); + } + + [Fact] + public void GrandchildMatch_DoesNotIncludeAncestors() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "Unrelated"), + Obj(2, parent: 1, template: "Unrelated"), + Obj(3, parent: 2, template: "TestMachine") + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldBe(new[] { 3 }); + } + + [Fact] + public void OverlappingMatches_StillSingleInclude() + { + // Grandparent matches AND grandchild matches independently — grandchild still counted once. + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), + Obj(2, parent: 1, template: "Widget"), + Obj(3, parent: 2, template: "TestMachine") + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.Count.ShouldBe(3); + included.ShouldContain(3); + } + + [Fact] + public void SiblingSubtrees_OnlyMatchedSideIncluded() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), // match — left subtree + Obj(2, parent: 1, template: "Child"), + Obj(10, parent: 0, template: "Pump"), // no match — right subtree + Obj(11, parent: 10, template: "PumpChild") + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldBe(new[] { 1, 2 }, ignoreOrder: true); + } + + // ---------- Defensive / edge cases ---------- + + [Fact] + public void OrphanObject_TreatedAsRoot() + { + // Object 2 claims parent 99 which isn't in the hierarchy — still reached as a root. + var hierarchy = new List + { + Obj(2, parent: 99, template: "TestMachine") + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldContain(2); + } + + [Fact] + public void SyntheticCycle_TerminatesWithoutStackOverflow() + { + // A→B→A cycle defended by the visited set. + var hierarchy = new List + { + Obj(1, parent: 2, template: "TestMachine"), + Obj(2, parent: 1, template: "Widget") + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + // No object has a ParentGobjectId of 0, and each references an id that exists — + // neither qualifies as a root under the orphan rule. Empty result is acceptable; + // the critical assertion is that the call returns without crashing. + var included = sut.ResolveIncludedObjects(hierarchy); + included.ShouldNotBeNull(); + } + + [Fact] + public void NullTemplateChain_TreatedAsEmpty() + { + var hierarchy = new List + { + new() { GobjectId = 1, ParentGobjectId = 0, TemplateChain = null! } + }; + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.ShouldBeEmpty(); + } + + [Fact] + public void EmptyHierarchy_ReturnsEmptySet() + { + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(new List())!; + included.ShouldBeEmpty(); + } + + [Fact] + public void NullHierarchy_ReturnsEmptySet() + { + var sut = new AlarmObjectFilter(Config("TestMachine")); + var included = sut.ResolveIncludedObjects(null)!; + included.ShouldBeEmpty(); + } + + [Fact] + public void MultipleRoots_AllProcessed() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), + Obj(2, parent: 0, template: "TestMachine"), + Obj(3, parent: 0, template: "Pump") + }; + var sut = new AlarmObjectFilter(Config("TestMachine*")); + var included = sut.ResolveIncludedObjects(hierarchy)!; + included.Count.ShouldBe(2); + } + + // ---------- UnmatchedPatterns ---------- + + [Fact] + public void UnmatchedPatterns_ListsPatternsWithZeroHits() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine") + }; + var sut = new AlarmObjectFilter(Config("TestMachine*", "NotThere*")); + sut.ResolveIncludedObjects(hierarchy); + sut.UnmatchedPatterns.ShouldContain("NotThere*"); + sut.UnmatchedPatterns.ShouldNotContain("TestMachine*"); + } + + [Fact] + public void UnmatchedPatterns_EmptyWhenAllMatch() + { + var hierarchy = new List + { + Obj(1, parent: 0, template: "TestMachine"), + Obj(2, parent: 0, template: "Pump") + }; + var sut = new AlarmObjectFilter(Config("TestMachine", "Pump")); + sut.ResolveIncludedObjects(hierarchy); + sut.UnmatchedPatterns.ShouldBeEmpty(); + } + + [Fact] + public void UnmatchedPatterns_EmptyWhenFilterDisabled() + { + var sut = new AlarmObjectFilter(new AlarmFilterConfiguration()); + sut.UnmatchedPatterns.ShouldBeEmpty(); + } + + [Fact] + public void UnmatchedPatterns_ResetBetweenResolutions() + { + var hierarchyA = new List { Obj(1, parent: 0, template: "TestMachine") }; + var hierarchyB = new List { Obj(1, parent: 0, template: "Pump") }; + var sut = new AlarmObjectFilter(Config("TestMachine*")); + + sut.ResolveIncludedObjects(hierarchyA); + sut.UnmatchedPatterns.ShouldBeEmpty(); + + sut.ResolveIncludedObjects(hierarchyB); + sut.UnmatchedPatterns.ShouldContain("TestMachine*"); + } + + // ---------- Helpers ---------- + + private static AlarmFilterConfiguration Config(params string[] filters) => + new() { ObjectFilters = filters.ToList() }; + + private static GalaxyObjectInfo Obj(int id, int parent, string template) => new() + { + GobjectId = id, + ParentGobjectId = parent, + TagName = $"Obj_{id}", + BrowseName = $"Obj_{id}", + TemplateChain = new List { template } + }; + + private static List SingleObject() => new() + { + Obj(1, parent: 0, template: "Anything") + }; + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs index a28d082..27ffa2f 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs @@ -150,7 +150,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers string? applicationUri = null, string? serverName = null, AuthenticationConfiguration? authConfig = null, - IUserAuthenticationProvider? authProvider = null) + IUserAuthenticationProvider? authProvider = null, + bool alarmTrackingEnabled = false, + string[]? alarmObjectFilters = null) { var client = mxClient ?? new FakeMxAccessClient(); var r = repo ?? new FakeGalaxyRepository @@ -176,8 +178,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers builder.WithAuthentication(authConfig); if (authProvider != null) builder.WithAuthProvider(authProvider); + if (alarmTrackingEnabled) + builder.WithAlarmTracking(true); + if (alarmObjectFilters != null) + builder.WithAlarmFilter(alarmObjectFilters); return new OpcUaServerFixture(builder, r, client); } + + /// + /// Exposes the node manager currently published by the running fixture so tests can assert + /// filter counters, alarm condition counts, and other runtime telemetry. + /// + public ZB.MOM.WW.LmxOpcUa.Host.OpcUa.LmxNodeManager? NodeManager => Service.NodeManagerInstance; } } \ No newline at end of file diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AlarmObjectFilterIntegrationTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AlarmObjectFilterIntegrationTests.cs new file mode 100644 index 0000000..071dfe8 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AlarmObjectFilterIntegrationTests.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; +using ZB.MOM.WW.LmxOpcUa.Tests.Helpers; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration +{ + /// + /// End-to-end integration tests that boot a real LmxNodeManager against fake Galaxy data and verify + /// the template-based alarm object filter actually suppresses alarm condition creation in both the + /// full build path and the subtree rebuild path after a simulated Galaxy redeploy. + /// + public class AlarmObjectFilterIntegrationTests + { + [Fact] + public async Task Filter_Empty_AllAlarmsTracked() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient( + repo: CreateRepoWithMixedTemplates(), + alarmTrackingEnabled: true); + await fixture.InitializeAsync(); + try + { + fixture.NodeManager.ShouldNotBeNull(); + // Two alarm attributes total (one per object), no filter → both tracked. + fixture.NodeManager!.AlarmConditionCount.ShouldBe(2); + fixture.NodeManager.AlarmFilterEnabled.ShouldBeFalse(); + fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Filter_MatchesOneTemplate_OnlyMatchingAlarmTracked() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient( + repo: CreateRepoWithMixedTemplates(), + alarmTrackingEnabled: true, + alarmObjectFilters: new[] { "TestMachine*" }); + await fixture.InitializeAsync(); + try + { + fixture.NodeManager.ShouldNotBeNull(); + fixture.NodeManager!.AlarmFilterEnabled.ShouldBeTrue(); + fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1); + fixture.NodeManager.AlarmConditionCount.ShouldBe(1); + fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(1); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Filter_MatchesParent_PropagatesToChild() + { + var attrs = new List(); + attrs.AddRange(AlarmWithInAlarm(1, "Parent_001", "AlarmA")); + attrs.AddRange(AlarmWithInAlarm(2, "Child_001", "AlarmB")); + + var repo = new FakeGalaxyRepository + { + Hierarchy = new List + { + Obj(1, parent: 0, tag: "Parent_001", template: "TestMachine"), + Obj(2, parent: 1, tag: "Child_001", template: "UnrelatedWidget") + }, + Attributes = attrs + }; + + var fixture = OpcUaServerFixture.WithFakeMxAccessClient( + repo: repo, + alarmTrackingEnabled: true, + alarmObjectFilters: new[] { "TestMachine*" }); + await fixture.InitializeAsync(); + try + { + fixture.NodeManager!.AlarmConditionCount.ShouldBe(2); + fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(2); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Filter_NoMatch_ZeroAlarmConditions() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient( + repo: CreateRepoWithMixedTemplates(), + alarmTrackingEnabled: true, + alarmObjectFilters: new[] { "NotInGalaxy*" }); + await fixture.InitializeAsync(); + try + { + fixture.NodeManager!.AlarmConditionCount.ShouldBe(0); + fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0); + fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Filter_GalaxyDollarPrefix_Normalized() + { + // Template chain stored as "$TestMachine" must match operator pattern "TestMachine*". + var repo = new FakeGalaxyRepository + { + Hierarchy = new List + { + Obj(1, parent: 0, tag: "Obj_1", template: "$TestMachine") + }, + Attributes = new List(AlarmWithInAlarm(1, "Obj_1", "AlarmX")) + }; + var fixture = OpcUaServerFixture.WithFakeMxAccessClient( + repo: repo, + alarmTrackingEnabled: true, + alarmObjectFilters: new[] { "TestMachine*" }); + await fixture.InitializeAsync(); + try + { + fixture.NodeManager!.AlarmConditionCount.ShouldBe(1); + } + finally + { + await fixture.DisposeAsync(); + } + } + + // ---------- Helpers ---------- + + private static FakeGalaxyRepository CreateRepoWithMixedTemplates() + { + var attrs = new List(); + attrs.AddRange(AlarmWithInAlarm(1, "TestMachine_001", "MachineAlarm")); + attrs.AddRange(AlarmWithInAlarm(2, "Pump_001", "PumpAlarm")); + return new FakeGalaxyRepository + { + Hierarchy = new List + { + Obj(1, parent: 0, tag: "TestMachine_001", template: "TestMachine"), + Obj(2, parent: 0, tag: "Pump_001", template: "Pump") + }, + Attributes = attrs + }; + } + + /// + /// Builds a Galaxy alarm attribute plus its companion .InAlarm sub-attribute. The alarm + /// creation path in LmxNodeManager skips objects whose alarm attribute has no InAlarm variable + /// in the tag→node map, so tests must include both rows for alarm conditions to materialize. + /// + private static IEnumerable AlarmWithInAlarm(int gobjectId, string tag, string attrName) + { + yield return new GalaxyAttributeInfo + { + GobjectId = gobjectId, + TagName = tag, + AttributeName = attrName, + FullTagReference = $"{tag}.{attrName}", + MxDataType = 1, + IsAlarm = true + }; + yield return new GalaxyAttributeInfo + { + GobjectId = gobjectId, + TagName = tag, + AttributeName = attrName + ".InAlarm", + FullTagReference = $"{tag}.{attrName}.InAlarm", + MxDataType = 1, + IsAlarm = false + }; + } + + private static GalaxyObjectInfo Obj(int id, int parent, string tag, string template) => new() + { + GobjectId = id, + ParentGobjectId = parent, + TagName = tag, + ContainedName = tag, + BrowseName = tag, + IsArea = false, + TemplateChain = new List { template } + }; + + private static GalaxyAttributeInfo AlarmAttr(int gobjectId, string tag, string attrName) => new() + { + GobjectId = gobjectId, + TagName = tag, + AttributeName = attrName, + FullTagReference = $"{tag}.{attrName}", + MxDataType = 1, + IsAlarm = true + }; + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs index e220599..e55ea59 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs @@ -154,6 +154,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status html.ShouldContain("

Alarms

"); } + /// + /// The Endpoints panel renders in the HTML dashboard even when no server host has been set, + /// so operators can tell the OPC UA server has not started. + /// + [Fact] + public void GenerateHtml_IncludesEndpointsPanel() + { + var sut = CreateService(); + var html = sut.GenerateHtml(); + + html.ShouldContain("

Endpoints

"); + html.ShouldContain("OPC UA server not started"); + } + + /// + /// The dashboard JSON surfaces the alarm filter counters so monitoring clients can verify scope. + /// + [Fact] + public void GenerateJson_Alarms_IncludesFilterCounters() + { + var sut = CreateService(); + var json = sut.GenerateJson(); + + json.ShouldContain("FilterEnabled"); + json.ShouldContain("FilterPatternCount"); + json.ShouldContain("FilterIncludedObjectCount"); + } + + /// + /// The dashboard JSON surfaces the Endpoints section with base-address and security-profile slots + /// so monitoring clients can read them programmatically. + /// + [Fact] + public void GenerateJson_Endpoints_IncludesBaseAddressesAndSecurityProfiles() + { + var sut = CreateService(); + var json = sut.GenerateJson(); + + json.ShouldContain("Endpoints"); + json.ShouldContain("BaseAddresses"); + json.ShouldContain("SecurityProfiles"); + json.ShouldContain("UserTokenPolicies"); + } + /// /// The /api/health payload exposes Historian and Alarms component status. ///