fix(client-ui): resolve Low code-review findings (Client.UI-003,004,006,009,010,011)

- Client.UI-003: wire Serilog properly per CLAUDE.md — console sink +
  rolling daily file sink in Program.Main, Log.CloseAndFlush in finally,
  per-VM Log.ForContext<> loggers.
- Client.UI-004: migrate the cert-store folder picker from the obsolete
  OpenFolderDialog to StorageProvider.OpenFolderPickerAsync (with
  TryGetFolderFromPathAsync seed + TryGetLocalPath extraction).
- Client.UI-006: surface formerly silent catch blocks via an observable
  StatusMessage on the Subscriptions / Alarms VMs that bubbles up into
  the shell's status bar; soft fallbacks log at Information level so
  hard failures stay distinguishable.
- Client.UI-009: docs/Client.UI.md now lists Standard Deviation in the
  Aggregate row of the Query Options table.
- Client.UI-010: removed the unused MinDateTimeProperty /
  MaxDateTimeProperty styled properties from DateTimeRangePicker.
- Client.UI-011: updated the cert-store TextBox watermark from the
  legacy AppData/LmxOpcUaClient/pki to the canonical
  AppData/OtOpcUaClient/pki.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 11:25:20 -04:00
parent 59ecd18169
commit 1b10194634
14 changed files with 246 additions and 64 deletions
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -13,9 +14,18 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
/// </summary>
public partial class AlarmsViewModel : ObservableObject
{
private static readonly ILogger Logger = Log.ForContext<AlarmsViewModel>();
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
/// <summary>
/// Last user-visible status message — set when an alarm subscribe / unsubscribe / refresh
/// operation fails so the shell can surface the diagnostic instead of silently dropping it.
/// Genuine failures are distinguished from "feature not supported" (condition refresh).
/// </summary>
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private int _interval = 1000;
[ObservableProperty]
@@ -95,19 +105,25 @@ public partial class AlarmsViewModel : ObservableObject
await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
IsSubscribed = true;
StatusMessage = null;
try
{
await _service.RequestConditionRefreshAsync();
}
catch
catch (Exception refreshEx)
{
// Refresh not supported
// Condition refresh is optional on the server side — log at info level and surface
// a soft notice rather than a hard failure so the operator can tell apart "server
// does not advertise refresh" from a genuine subscribe failure.
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server");
StatusMessage = "Condition refresh not supported by server (subscribed).";
}
}
catch
catch (Exception ex)
{
// Subscribe failed
Logger.Warning(ex, "SubscribeAlarms failed for {Source}", MonitoredNodeIdText ?? "(all)");
StatusMessage = $"Subscribe to alarms failed: {ex.Message}";
}
}
@@ -123,10 +139,12 @@ public partial class AlarmsViewModel : ObservableObject
{
await _service.UnsubscribeAlarmsAsync();
IsSubscribed = false;
StatusMessage = null;
}
catch
catch (Exception ex)
{
// Unsubscribe failed
Logger.Warning(ex, "UnsubscribeAlarms failed");
StatusMessage = $"Unsubscribe alarms failed: {ex.Message}";
}
}
@@ -136,10 +154,14 @@ public partial class AlarmsViewModel : ObservableObject
try
{
await _service.RequestConditionRefreshAsync();
StatusMessage = null;
}
catch
catch (Exception ex)
{
// Refresh failed
// Same as the subscribe-time fallback: refresh is server-side optional. Information-
// level log + soft status so the operator sees why an explicit refresh did nothing.
Logger.Information(ex, "RequestConditionRefresh not supported by server");
StatusMessage = "Condition refresh not supported by server.";
}
}
@@ -189,19 +211,22 @@ public partial class AlarmsViewModel : ObservableObject
await _service.SubscribeAlarmsAsync(nodeId, Interval);
IsSubscribed = true;
StatusMessage = null;
try
{
await _service.RequestConditionRefreshAsync();
}
catch
catch (Exception refreshEx)
{
// Refresh not supported
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server (restore path)");
StatusMessage = "Condition refresh not supported by server (restored subscription).";
}
}
catch
catch (Exception ex)
{
// Subscribe failed
Logger.Warning(ex, "RestoreAlarmSubscription failed for {Source}", sourceNodeId);
StatusMessage = $"Restore alarm subscription failed: {ex.Message}";
}
}
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -12,6 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
/// </summary>
public partial class MainWindowViewModel : ObservableObject, IDisposable
{
private static readonly ILogger Logger = Log.ForContext<MainWindowViewModel>();
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientServiceFactory _factory;
private readonly ISettingsService _settingsService;
@@ -137,6 +140,15 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
{
if (args.PropertyName == nameof(AlarmsViewModel.ActiveAlarmCount))
_dispatcher.Post(() => ActiveAlarmCount = Alarms.ActiveAlarmCount);
else if (args.PropertyName == nameof(AlarmsViewModel.StatusMessage)
&& !string.IsNullOrEmpty(Alarms.StatusMessage))
_dispatcher.Post(() => StatusMessage = Alarms.StatusMessage!);
};
Subscriptions.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(SubscriptionsViewModel.StatusMessage)
&& !string.IsNullOrEmpty(Subscriptions.StatusMessage))
_dispatcher.Post(() => StatusMessage = Subscriptions.StatusMessage!);
};
History = new HistoryViewModel(_service, _dispatcher);
@@ -244,15 +256,17 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
SessionLabel = $"{info.ServerName} | Session: {info.SessionName} ({info.SessionId})";
});
// Load redundancy info
// Load redundancy info — the server may not implement the redundancy facet, in which
// case we leave RedundancyInfo null but log so a field diagnosis can tell the difference
// between "facet not advertised" and "facet errored". The connection itself stays up.
try
{
var redundancy = await _service!.GetRedundancyInfoAsync();
_dispatcher.Post(() => RedundancyInfo = redundancy);
}
catch
catch (Exception redundancyEx)
{
// Redundancy info not available
Logger.Information(redundancyEx, "GetRedundancyInfo unavailable on this server");
}
// Load root nodes
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -13,9 +14,17 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
/// </summary>
public partial class SubscriptionsViewModel : ObservableObject
{
private static readonly ILogger Logger = Log.ForContext<SubscriptionsViewModel>();
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
/// <summary>
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
/// </summary>
[ObservableProperty] private string? _statusMessage;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
@@ -85,11 +94,13 @@ public partial class SubscriptionsViewModel : ObservableObject
{
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, interval));
SubscriptionCount = ActiveSubscriptions.Count;
StatusMessage = null;
});
}
catch
catch (Exception ex)
{
// Subscription failed; no item added
Logger.Warning(ex, "AddSubscription failed for {NodeId}", nodeIdStr);
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
}
}
@@ -116,9 +127,11 @@ public partial class SubscriptionsViewModel : ObservableObject
_dispatcher.Post(() => ActiveSubscriptions.Remove(item));
}
catch
catch (Exception ex)
{
// Unsubscribe failed for this item; continue with others
Logger.Warning(ex, "Unsubscribe failed for {NodeId}", item.NodeId);
_dispatcher.Post(() => StatusMessage = $"Unsubscribe failed for {item.NodeId}: {ex.Message}");
// Continue with the other items in the batch.
}
}
@@ -146,11 +159,13 @@ public partial class SubscriptionsViewModel : ObservableObject
{
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, intervalMs));
SubscriptionCount = ActiveSubscriptions.Count;
StatusMessage = null;
});
}
catch
catch (Exception ex)
{
// Subscription failed
Logger.Warning(ex, "AddSubscriptionForNode failed for {NodeId}", nodeIdStr);
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
}
}
@@ -186,9 +201,10 @@ public partial class SubscriptionsViewModel : ObservableObject
foreach (var child in children)
await AddSubscriptionRecursiveAsync(child.NodeId, child.NodeClass, intervalMs, maxDepth, currentDepth + 1);
}
catch
catch (Exception ex)
{
// Browse failed for this node; skip it
Logger.Warning(ex, "Recursive browse failed for {NodeId}; skipping subtree", nodeIdStr);
_dispatcher.Post(() => StatusMessage = $"Browse failed for {nodeIdStr}: {ex.Message}");
}
}