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:
@@ -30,12 +30,6 @@ public partial class DateTimeRangePicker : UserControl
|
||||
public static readonly StyledProperty<string> EndTextProperty =
|
||||
AvaloniaProperty.Register<DateTimeRangePicker, string>(nameof(EndText), defaultValue: "");
|
||||
|
||||
public static readonly StyledProperty<DateTimeOffset?> MinDateTimeProperty =
|
||||
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MinDateTime));
|
||||
|
||||
public static readonly StyledProperty<DateTimeOffset?> MaxDateTimeProperty =
|
||||
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MaxDateTime));
|
||||
|
||||
private bool _isUpdating;
|
||||
|
||||
public DateTimeRangePicker()
|
||||
@@ -67,18 +61,6 @@ public partial class DateTimeRangePicker : UserControl
|
||||
set => SetValue(EndTextProperty, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset? MinDateTime
|
||||
{
|
||||
get => GetValue(MinDateTimeProperty);
|
||||
set => SetValue(MinDateTimeProperty, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset? MaxDateTime
|
||||
{
|
||||
get => GetValue(MaxDateTimeProperty);
|
||||
set => SetValue(MaxDateTimeProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Avalonia;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI;
|
||||
|
||||
@@ -7,8 +9,16 @@ public class Program
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
ConfigureLogging();
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
@@ -18,4 +28,35 @@ public class Program
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the Serilog root logger with a console sink + a rolling daily file sink
|
||||
/// under <c>{LocalAppData}/OtOpcUaClient/logs/</c>. CLAUDE.md mandates Serilog with a
|
||||
/// rolling daily file sink as the project standard; this is also the only way the swallow
|
||||
/// blocks in the alarms / subscriptions / redundancy view-models surface a diagnosable
|
||||
/// trace when an operator hits a problem in the field.
|
||||
/// </summary>
|
||||
private static void ConfigureLogging()
|
||||
{
|
||||
var logsDir = Path.Combine(ClientStoragePaths.GetRoot(), "logs");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logsDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; file sink will gracefully fall back if the dir can't be created.
|
||||
}
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
path: Path.Combine(logsDir, "client-ui-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 14,
|
||||
shared: true)
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<TextBox Text="{Binding CertificateStorePath}"
|
||||
Width="370"
|
||||
IsReadOnly="True"
|
||||
Watermark="(default: AppData/LmxOpcUaClient/pki)" />
|
||||
Watermark="(default: AppData/OtOpcUaClient/pki)" />
|
||||
<Button Name="BrowseCertPathButton"
|
||||
Content="..."
|
||||
Width="30"
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using SkiaSharp;
|
||||
using Svg.Skia;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
||||
@@ -126,15 +127,34 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
var dialog = new OpenFolderDialog
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel == null) return;
|
||||
|
||||
IStorageFolder? startLocation = null;
|
||||
if (!string.IsNullOrEmpty(vm.CertificateStorePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(vm.CertificateStorePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: if the existing path can't be resolved (missing/permission), open the dialog without it.
|
||||
}
|
||||
}
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select Certificate Store Folder",
|
||||
Directory = vm.CertificateStorePath
|
||||
};
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = startLocation
|
||||
});
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
vm.CertificateStorePath = result;
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
var picked = folders[0].TryGetLocalPath();
|
||||
if (!string.IsNullOrEmpty(picked))
|
||||
vm.CertificateStorePath = picked;
|
||||
}
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user