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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
209
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/AlarmObjectFilter.cs
Normal file
209
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/AlarmObjectFilter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30,
|
||||
"AlarmTrackingEnabled": false,
|
||||
"AlarmFilter": {
|
||||
"ObjectFilters": []
|
||||
},
|
||||
"ApplicationUri": null
|
||||
},
|
||||
"MxAccess": {
|
||||
|
||||
Reference in New Issue
Block a user