Scope alarm tracking to selected templates and surface endpoint/security state on the dashboard so operators can deploy in large galaxies without drowning clients in irrelevant alarms or guessing what the server is advertising

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-13 09:48:57 -04:00
parent c5ed5312a9
commit 517d92c76f
25 changed files with 1511 additions and 12 deletions

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Configures the template-based alarm object filter under <c>OpcUa.AlarmFilter</c>.
/// </summary>
/// <remarks>
/// Each entry in <see cref="ObjectFilters"/> is a wildcard pattern matched against the template
/// derivation chain of every Galaxy object. Supported wildcard: <c>*</c>. Matching is case-insensitive
/// and the leading <c>$</c> used by Galaxy template tag_names is normalized away, so operators can
/// write <c>TestMachine*</c> instead of <c>$TestMachine*</c>. An entry may itself contain comma-separated
/// patterns for convenience (e.g., <c>"TestMachine*, Pump_*"</c>). An empty list disables the filter,
/// restoring current behavior: all alarm-bearing objects are monitored when
/// <see cref="OpcUaConfiguration.AlarmTrackingEnabled"/> is <see langword="true"/>.
/// </remarks>
public class AlarmFilterConfiguration
{
/// <summary>
/// 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.
/// </summary>
public List<string> ObjectFilters { get; set; } = new();
}
}

View File

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

View File

@@ -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.
/// </summary>
public bool AlarmTrackingEnabled { get; set; } = false;
/// <summary>
/// Gets or sets the template-based alarm object filter. When <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// 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.
/// </summary>
public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>Matching rules:</para>
/// <list type="bullet">
/// <item>An object is included when any template name in its derivation chain matches
/// any configured pattern.</item>
/// <item>Matching is case-insensitive and ignores the Galaxy leading <c>$</c> prefix on
/// both the chain entry and the user pattern, so <c>TestMachine*</c> matches the stored
/// <c>$TestMachine</c>.</item>
/// <item>Inclusion propagates to every descendant of a matched object (containment subtree).</item>
/// <item>Each object is evaluated once — overlapping matches never produce duplicate
/// inclusions (set semantics).</item>
/// </list>
/// <para>Pattern syntax: literal text plus <c>*</c> wildcards (zero or more characters).
/// Other regex metacharacters in the raw pattern are escaped and treated literally.</para>
/// </remarks>
public class AlarmObjectFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext<AlarmObjectFilter>();
private readonly List<Regex> _patterns;
private readonly List<string> _rawPatterns;
private readonly HashSet<string> _matchedRawPatterns;
/// <summary>
/// Initializes a new alarm object filter from the supplied configuration section.
/// </summary>
/// <param name="config">The alarm filter configuration whose <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.</param>
public AlarmObjectFilter(AlarmFilterConfiguration? config)
{
_patterns = new List<Regex>();
_rawPatterns = new List<string>();
_matchedRawPatterns = new HashSet<string>(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);
}
}
}
}
/// <summary>
/// Gets a value indicating whether the filter has any compiled patterns. When <see langword="false"/>,
/// callers should treat alarm tracking as unfiltered (current behavior preserved).
/// </summary>
public bool Enabled => _patterns.Count > 0;
/// <summary>
/// Gets the number of compiled patterns the filter will evaluate against each object.
/// </summary>
public int PatternCount => _patterns.Count;
/// <summary>
/// Gets the raw pattern strings that did not match any object in the most recent call to
/// <see cref="ResolveIncludedObjects"/>. Useful for startup warnings about operator typos.
/// </summary>
public IReadOnlyList<string> UnmatchedPatterns =>
_rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
/// <summary>
/// Returns <see langword="true"/> when any template name in <paramref name="chain"/> matches any
/// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
/// equal to <c>*</c> (which collapses to an empty-matching regex after normalization).
/// </summary>
/// <param name="chain">The template derivation chain to test (own template first, ancestors after).</param>
public bool MatchesTemplateChain(IReadOnlyList<string>? 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;
}
/// <summary>
/// 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
/// <see langword="null"/> when the filter is disabled so callers can skip the containment check
/// entirely.
/// </summary>
/// <param name="hierarchy">The full deployed Galaxy hierarchy, as returned by the repository service.</param>
/// <returns>The set of included gobject IDs, or <see langword="null"/> when filtering is disabled.</returns>
public HashSet<int>? ResolveIncludedObjects(IReadOnlyList<GalaxyObjectInfo>? hierarchy)
{
if (!Enabled)
return null;
_matchedRawPatterns.Clear();
var included = new HashSet<int>();
if (hierarchy == null || hierarchy.Count == 0)
return included;
var byId = new Dictionary<int, GalaxyObjectInfo>(hierarchy.Count);
foreach (var obj in hierarchy)
byId[obj.GobjectId] = obj;
var childrenByParent = new Dictionary<int, List<int>>();
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<int>();
childrenByParent[parentId] = list;
}
list.Add(obj.GobjectId);
}
var roots = childrenByParent.TryGetValue(0, out var rootList)
? rootList
: new List<int>();
var visited = new HashSet<int>();
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;
}
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
@@ -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.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// 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 <c>$Object</c>. Populated by
/// the recursive CTE in <c>hierarchy.sql</c> on <c>gobject.derived_from_gobject_id</c>. Used by
/// <see cref="AlarmObjectFilter"/> to decide whether an object's alarms should be monitored.
/// </summary>
public List<string> TemplateChain { get; set; } = new();
}
}

View File

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

View File

@@ -29,6 +29,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly Dictionary<string, AlarmInfo> _alarmPriorityTags = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AlarmInfo> _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<int, List<string>> _gobjectToTagRefs = new();
@@ -88,6 +90,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <param name="metrics">The metrics collector used to track node manager activity.</param>
/// <param name="historianDataSource">The optional historian adapter used to satisfy OPC UA history read requests.</param>
/// <param name="alarmTrackingEnabled">Enables alarm-condition state generation for Galaxy attributes modeled as alarms.</param>
/// <param name="alarmObjectFilter">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 <see langword="null"/> or disabled filter preserves the current unfiltered behavior.</param>
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
/// </summary>
public bool AlarmTrackingEnabled => _alarmTrackingEnabled;
/// <summary>
/// Gets a value indicating whether the template-based alarm object filter is enabled.
/// </summary>
public bool AlarmFilterEnabled => _alarmObjectFilter?.Enabled ?? false;
/// <summary>
/// Gets the number of compiled alarm filter patterns.
/// </summary>
public int AlarmFilterPatternCount => _alarmObjectFilter?.PatternCount ?? 0;
/// <summary>
/// Gets the number of Galaxy objects included by the alarm filter during the most recent address-space build.
/// </summary>
public int AlarmFilterIncludedObjectCount => _alarmFilterIncludedObjectCount;
/// <summary>
/// Gets the number of distinct alarm conditions currently tracked (one per alarm attribute).
/// </summary>
@@ -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
}
}
/// <summary>
/// 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 <see langword="null"/> when no filter is configured so the alarm loop continues unfiltered.
/// </summary>
private HashSet<int>? ResolveAlarmFilterIncludedIds(IReadOnlyList<GalaxyObjectInfo> 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;

View File

@@ -18,6 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
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<INodeManager> { NodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());

View File

@@ -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<OpcUaServerHost>();
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;
}
/// <summary>
@@ -69,6 +73,45 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Gets the list of opc.tcp base addresses the server is currently listening on.
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> BaseAddresses
{
get
{
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
return addrs != null ? addrs.ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
}
}
/// <summary>
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> UserTokenPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>
@@ -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(

View File

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

View File

@@ -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
/// </summary>
/// <param name="security">The security profile configuration to inject.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
/// <summary>
/// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path.
/// </summary>
/// <param name="enabled">Whether alarm tracking should be enabled.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithAlarmTracking(bool enabled)
{
_config.OpcUa.AlarmTrackingEnabled = enabled;
return this;
}
/// <summary>
/// Configures the template-based alarm object filter for integration tests.
/// </summary>
/// <param name="filters">Zero or more wildcard patterns. Empty → filter disabled.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithAlarmFilter(params string[] filters)
{
_config.OpcUa.AlarmFilter = new AlarmFilterConfiguration
{
ObjectFilters = filters.ToList()
};
return this;
}
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
{
_config.Security = security;

View File

@@ -54,12 +54,59 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// </summary>
public RedundancyInfo? Redundancy { get; set; }
/// <summary>
/// Gets or sets the listening OPC UA endpoints and active security profiles.
/// </summary>
public EndpointsInfo Endpoints { get; set; } = new();
/// <summary>
/// Gets or sets footer details such as the snapshot timestamp and service version.
/// </summary>
public FooterInfo Footer { get; set; } = new();
}
/// <summary>
/// Dashboard model describing the OPC UA server's listening endpoints and active security profiles.
/// </summary>
public class EndpointsInfo
{
/// <summary>
/// Gets or sets the list of opc.tcp base addresses the server is listening on.
/// </summary>
public List<string> BaseAddresses { get; set; } = new();
/// <summary>
/// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate).
/// </summary>
public List<string> UserTokenPolicies { get; set; } = new();
/// <summary>
/// Gets or sets the active security profiles reported to clients.
/// </summary>
public List<SecurityProfileInfo> SecurityProfiles { get; set; } = new();
}
/// <summary>
/// Dashboard model for a single configured OPC UA server security profile.
/// </summary>
public class SecurityProfileInfo
{
/// <summary>
/// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256).
/// </summary>
public string PolicyUri { get; set; } = "";
/// <summary>
/// Gets or sets the short policy name extracted from the policy URI.
/// </summary>
public string PolicyName { get; set; } = "";
/// <summary>
/// Gets or sets the message security mode (None, Sign, SignAndEncrypt).
/// </summary>
public string SecurityMode { get; set; } = "";
}
/// <summary>
/// Dashboard model for current runtime connection details.
/// </summary>
@@ -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.
/// </summary>
public long AckWriteFailures { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the template-based alarm object filter is active.
/// </summary>
public bool FilterEnabled { get; set; }
/// <summary>
/// Gets or sets the number of compiled alarm filter patterns.
/// </summary>
public int FilterPatternCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build.
/// </summary>
public int FilterIncludedObjectCount { get; set; }
}
/// <summary>

View File

@@ -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($"<p>Status: <b>{data.Health.Status}</b> — {data.Health.Message}</p>");
sb.AppendLine("</div>");
// Endpoints panel (exposed URLs + security profiles)
var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray";
sb.AppendLine($"<div class='panel {endpointsColor}'><h2>Endpoints</h2>");
if (data.Endpoints.BaseAddresses.Count == 0)
{
sb.AppendLine("<p>No endpoints — OPC UA server not started.</p>");
}
else
{
sb.AppendLine("<p><b>Base Addresses:</b></p><ul>");
foreach (var addr in data.Endpoints.BaseAddresses)
sb.AppendLine($"<li>{WebUtility.HtmlEncode(addr)}</li>");
sb.AppendLine("</ul>");
sb.AppendLine("<p><b>Security Profiles:</b></p>");
sb.AppendLine("<table><tr><th>Mode</th><th>Policy</th><th>Policy URI</th></tr>");
foreach (var profile in data.Endpoints.SecurityProfiles)
{
sb.AppendLine(
$"<tr><td>{WebUtility.HtmlEncode(profile.SecurityMode)}</td>" +
$"<td>{WebUtility.HtmlEncode(profile.PolicyName)}</td>" +
$"<td>{WebUtility.HtmlEncode(profile.PolicyUri)}</td></tr>");
}
sb.AppendLine("</table>");
if (data.Endpoints.UserTokenPolicies.Count > 0)
sb.AppendLine(
$"<p><b>User Token Policies:</b> {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}</p>");
}
sb.AppendLine("</div>");
// Redundancy panel (only when enabled)
if (data.Redundancy != null)
{
@@ -258,6 +318,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
$"<p>Tracking: <b>{data.Alarms.TrackingEnabled}</b> | Conditions: {data.Alarms.ConditionCount} | Active: <b>{data.Alarms.ActiveAlarmCount}</b></p>");
sb.AppendLine(
$"<p>Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}</p>");
if (data.Alarms.FilterEnabled)
sb.AppendLine(
$"<p>Filter: <b>{data.Alarms.FilterPatternCount}</b> pattern(s), <b>{data.Alarms.FilterIncludedObjectCount}</b> object(s) included</p>");
sb.AppendLine("</div>");
// Operations table

View File

@@ -8,6 +8,9 @@
"MaxSessions": 100,
"SessionTimeoutMinutes": 30,
"AlarmTrackingEnabled": false,
"AlarmFilter": {
"ObjectFilters": []
},
"ApplicationUri": null
},
"MxAccess": {