Files
lmxopcua/code-reviews/Client.UI/findings.md
Joseph Doherty 1b10194634 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>
2026-05-23 11:25:20 -04:00

16 KiB

Code Review - Client.UI

Field Value
Module src/Client/ZB.MOM.WW.OtOpcUa.Client.UI
Reviewer Claude Code
Review date 2026-05-22
Commit reviewed 76d35d1
Status Reviewed
Open findings 0

Checklist coverage

A comprehensive review completes every category, recording "No issues found" where a category produced nothing rather than leaving it blank.

# Category Result
1 Correctness & logic bugs Client.UI-001, Client.UI-002
2 OtOpcUa conventions Client.UI-003, Client.UI-004
3 Concurrency & thread safety Client.UI-005
4 Error handling & resilience Client.UI-006
5 Security Client.UI-007
6 Performance & resource management Client.UI-008
7 Design-document adherence Client.UI-009
8 Code organization & conventions Client.UI-010
9 Testing coverage No issues found
10 Documentation & comments Client.UI-011

Findings

Client.UI-001

Field Value
Severity Medium
Category Correctness & logic bugs
Location ViewModels/HistoryViewModel.cs:76, ViewModels/HistoryViewModel.cs:77
Status Resolved

Description: ReadHistoryAsync runs as a RelayCommand body, which is invoked on the UI thread, so the bare IsLoading = true at line 76 happens to land on the right thread today. But Results.Clear() on the very next line is wrapped in _dispatcher.Post(...), and the finally block also sets IsLoading through the dispatcher (_dispatcher.Post(() => IsLoading = false) at line 121). The two IsLoading writes use inconsistent dispatch paths. Because the Post in the finally is queued behind the result-population Post while the synchronous line-76 write is not, the loading-indicator updates are not guaranteed to be ordered relative to the grid population, and the pattern is fragile if the command is ever invoked off the UI thread (a future caller or test harness).

Recommendation: Route the line-76 IsLoading = true through _dispatcher.Post for consistency with the rest of the method, or set both IsLoading writes synchronously and only dispatch the ObservableCollection mutations.

Resolution: Resolved 2026-05-22 — Routed the IsLoading = true write through _dispatcher.Post to make both IsLoading assignments consistent with all other UI state mutations in the method.

Client.UI-002

Field Value
Severity Medium
Category Correctness & logic bugs
Location ViewModels/MainWindowViewModel.cs:255, ViewModels/MainWindowViewModel.cs:333
Status Resolved

Description: ConnectAsync calls await BrowseTree.LoadRootsAsync() and ViewHistoryForSelectedNode calls History.SelectedNodeId = ... by dereferencing the nullable child view-model properties (BrowseTreeViewModel?, HistoryViewModel?) without a null check or ! operator, while the surrounding code (lines 258-266) does guard Subscriptions and Alarms with != null. InitializeService() does assign all five child VMs before these lines run, so a real NRE is unlikely on the current call path, but the inconsistent guarding masks intent and the nullable-reference compiler flow analysis cannot prove InitializeService() set the field, so this either produces a CS8602 warning that is being ignored or relies on warnings being suppressed. A future refactor that makes InitializeService() conditionally skip a VM would introduce a silent crash.

Recommendation: Make the guarding consistent: either guard all five child VMs uniformly, or have InitializeService() return non-null references the caller uses directly so the compiler can prove non-nullness.

Resolution: Resolved 2026-05-22 — Added if (BrowseTree != null) and if (History != null) guards at both dereference sites to match the guarding style already used for Subscriptions and Alarms.

Client.UI-003

Field Value
Severity Low
Category OtOpcUa conventions
Location ZB.MOM.WW.OtOpcUa.Client.UI.csproj:20-21, Program.cs:14-20
Status Resolved

Description: The csproj references Serilog and Serilog.Sinks.Console, and docs/Client.UI.md lists Serilog as the logging technology, but no source file in the module uses Serilog. Program.BuildAvaloniaApp() uses Avalonia's LogToTrace() and there is no logger configuration, no log calls, and no rolling file sink. CLAUDE.md mandates "Serilog with rolling daily file sink" as the logging library preference. The references are dead weight and the documented logging behaviour does not exist.

Recommendation: Either wire up Serilog (a console sink at minimum, ideally the rolling daily file sink the project standard calls for) and route Avalonia logging through it, or drop the unused Serilog package references and correct docs/Client.UI.md.

Resolution: Resolved 2026-05-23 — Honoured the CLAUDE.md mandate by wiring up Serilog with a console sink + a rolling daily file sink ({LocalAppData}/OtOpcUaClient/logs/client-ui-*.log, retained 14 days). Added Serilog.Sinks.File to the csproj and a ConfigureLogging() initializer in Program.Main that creates Log.Logger before BuildAvaloniaApp() and calls Log.CloseAndFlush() on exit. Each VM that previously had silent swallow blocks now owns a static Log.ForContext<>() logger so failures (subscribe, alarm subscribe, redundancy probe, recursive browse) are written to the rolling file. Avalonia's own logging is still routed through LogToTrace — replacing that would require a custom ILogSink adapter outside the scope of this finding.

Client.UI-004

Field Value
Severity Low
Category OtOpcUa conventions
Location Views/MainWindow.axaml.cs:125-138
Status Resolved

Description: OnBrowseCertPathClicked uses OpenFolderDialog, which is obsolete in Avalonia 11.x (the version pinned in the csproj). The supported replacement is the StorageProvider API (StorageProvider.OpenFolderPickerAsync). Using the obsolete type produces a compiler obsoletion warning and the API is scheduled for removal in a future Avalonia major version.

Recommendation: Migrate the folder chooser to TopLevel.GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(...).

Resolution: Resolved 2026-05-23 — Replaced OpenFolderDialog with TopLevel.GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(...), using TryGetFolderFromPathAsync(vm.CertificateStorePath) as the suggested start location and TryGetLocalPath() to extract the chosen path. The CS0618 obsoletion warning no longer appears in the build output.

Client.UI-005

Field Value
Severity Medium
Category Concurrency & thread safety
Location ViewModels/MainWindowViewModel.cs:286-304, ViewModels/MainWindowViewModel.cs:155-189
Status Resolved

Description: SubscriptionsViewModel and AlarmsViewModel attach handlers to the long-lived _service events (DataChanged, AlarmEvent) in their constructors and detach them only via Teardown(). Teardown() is called from DisconnectAsync (operator-initiated disconnect), but it is NOT called from the OnConnectionStateChanged partial method that handles the Disconnected state; that path only calls Clear(). When the connection drops server-side (session lost, network failure) the service raises ConnectionStateChanged(Disconnected) without DisconnectAsync ever running, so the alarm/data event handlers remain attached to a dead service. They are not re-attached on the next connect because InitializeService() early-returns when _service != null and the same VM instances are reused, so there is no handler leak per reconnect, but a late or buffered DataChanged/AlarmEvent callback fired during teardown will still mutate ObservableCollections, and the asymmetry between the two disconnect paths is a latent correctness hazard.

Recommendation: Make the disconnect handling symmetric: call Subscriptions?.Teardown() / Alarms?.Teardown() (or otherwise quiesce the event handlers) from the Disconnected branch of the OnConnectionStateChanged partial method, not only from DisconnectAsync.

Resolution: Resolved 2026-05-22 — Added Teardown() calls to the Disconnected branch and added Reattach() methods (idempotent remove+add) called from the Connected branch to restore handlers after a server-side drop + reconnect.

Client.UI-006

Field Value
Severity Low
Category Error handling & resilience
Location ViewModels/MainWindowViewModel.cs:244-252, ViewModels/AlarmsViewModel.cs:88-112, ViewModels/SubscriptionsViewModel.cs:79-94
Status Resolved

Description: Many catch blocks swallow exceptions silently with an empty body and only a comment (// Redundancy info not available, // Subscribe failed, // Subscription failed; no item added, and others). When a subscribe, alarm-subscribe, or redundancy read fails, the operator gets no feedback at all: no status message, no log entry (compounded by Client.UI-003: there is no logger). A failed AddSubscriptionAsync simply leaves the node un-subscribed with no indication why. This makes field diagnosis of a misconfigured server or a permission denial effectively impossible from the UI.

Recommendation: Surface failures to the operator: at minimum set a status message or write the exception to a log. Distinguish "feature not supported" (condition refresh) from "operation failed" so genuine errors are not hidden.

Resolution: Resolved 2026-05-23 — Added an observable StatusMessage property on SubscriptionsViewModel and AlarmsViewModel; each former silent catch now logs through Serilog (via Client.UI-003's logger) and writes a user-visible message. MainWindowViewModel.InitializeService subscribes to both child VMs' StatusMessage changes and bubbles them up into the shell's StatusMessage (which is already bound to the status bar). Soft conditions are distinguished from hard failures: RequestConditionRefreshAsync failures log at Information level and surface as "Condition refresh not supported by server" rather than a generic error, matching the recommendation. Redundancy probe failure still leaves RedundancyInfo null but now logs at Information level instead of dropping the exception. Regression tests AddSubscription_OnFailure_SurfacesStatusMessage, AddSubscriptionForNodeAsync_OnFailure_SurfacesStatusMessage, Subscribe_OnFailure_SurfacesStatusMessage, and ConnectCommand_RedundancyFailure_DoesNotBreakConnection cover the four affected swallow sites.

Client.UI-007

Field Value
Severity Medium
Category Security
Location Services/UserSettings.cs:22-23, Services/JsonSettingsService.cs:38-50, ViewModels/MainWindowViewModel.cs:393-408
Status Resolved

Description: The OPC UA UserName-token password is persisted in cleartext. UserSettings.Password is a plain string, JsonSettingsService.Save serializes the whole settings object to settings.json under LocalApplicationData, and SaveSettings() is invoked after every successful connect and on window close. Any process or user able to read the current user's profile directory can recover the server credentials. docs/Client.UI.md documents that "All connection parameters" are persisted but does not flag the password among them.

Recommendation: Do not persist the password in cleartext. Options: omit it from the persisted model entirely (re-prompt each launch); encrypt it at rest with ProtectedData (DPAPI) on Windows or an equivalent OS keystore on other platforms; or store only a non-reversible reference. At minimum, document the cleartext storage as a known limitation.

Resolution: Resolved 2026-05-22 — Removed Password from UserSettings and stopped writing/reading it in SaveSettings/LoadSettings; the operator is re-prompted each launch.

Client.UI-008

Field Value
Severity Medium
Category Performance & resource management
Location ViewModels/MainWindowViewModel.cs:18, ViewModels/MainWindowViewModel.cs:125-148, App.axaml.cs:18-32
Status Resolved

Description: IOpcUaClientService is declared IDisposable (IOpcUaClientService.cs:10), and the concrete service owns an OPC UA session plus SDK resources. MainWindowViewModel holds _service for the lifetime of the app but never calls _service.Dispose(): not on window close, not on disconnect, not anywhere. DisconnectAsync calls DisconnectAsync() on the service but leaves the object undisposed, and there is no IDisposable implementation on MainWindowViewModel itself. The OPC UA SDK session, certificate validator, and any background reconnect timers are leaked until process exit. The ConnectionStateChanged handler attached at line 130 is also never detached.

Recommendation: Make MainWindowViewModel implement IDisposable, detach the ConnectionStateChanged handler, and dispose _service from MainWindow.OnClosing (alongside the existing SaveSettings() call).

Resolution: Resolved 2026-05-22 — Added IDisposable to MainWindowViewModel with a Dispose() that detaches ConnectionStateChanged, calls Teardown() on child VMs, and calls _service.Dispose(); wired Dispose() into MainWindow.OnClosing.

Client.UI-009

Field Value
Severity Low
Category Design-document adherence
Location ViewModels/HistoryViewModel.cs:44-54
Status Resolved

Description: HistoryViewModel.AggregateTypes exposes eight entries: null (Raw) plus Average, Minimum, Maximum, Count, Start, End, and StandardDeviation. docs/Client.UI.md ("Query Options" table) lists only "Raw (default), Average, Minimum, Maximum, Count, Start, End" and omits StandardDeviation. The doc is stale relative to the code.

Recommendation: Update the "Aggregate" row in docs/Client.UI.md to include Standard Deviation.

Resolution: Resolved 2026-05-23 — Added "Standard Deviation" to the Aggregate row of the Query Options table in docs/Client.UI.md so it matches the eighth entry already exposed by HistoryViewModel.AggregateTypes.

Client.UI-010

Field Value
Severity Low
Category Code organization & conventions
Location Controls/DateTimeRangePicker.axaml.cs:33-37, Controls/DateTimeRangePicker.axaml.cs:70-80
Status Resolved

Description: DateTimeRangePicker declares MinDateTimeProperty / MaxDateTimeProperty styled properties with public CLR accessors, but neither is read anywhere in the control. TryParseDateTime, OnStartLostFocus, and OnEndLostFocus never clamp or reject input against the min/max bounds, and no XAML binds them. The properties are dead API surface that implies a range constraint the control does not enforce.

Recommendation: Either implement min/max validation in the LostFocus parse path (turn out-of-range input red, as invalid input already is) or remove the two unused styled properties.

Resolution: Resolved 2026-05-23 — Removed MinDateTimeProperty / MaxDateTimeProperty and their CLR accessors from DateTimeRangePicker.axaml.cs. No XAML or external caller binds the properties (grep across the repo confirmed only the control file referenced them), so removing the dead API surface is the correct fix; adding min/max clamping would have been speculative behaviour without a calling site.

Client.UI-011

Field Value
Severity Low
Category Documentation & comments
Location Views/MainWindow.axaml:81, Services/JsonSettingsService.cs:11-15
Status Resolved

Description: The certificate-store-path TextBox watermark reads (default: AppData/LmxOpcUaClient/pki), referencing the legacy pre-task-#208 folder name. Per CLAUDE.md / docs/Client.UI.md the canonical path is now {LocalAppData}/OtOpcUaClient/, and ClientStoragePaths migrates the old LmxOpcUaClient/ folder forward. The watermark shows operators an obsolete path that no longer matches where settings and the PKI store actually live.

Recommendation: Update the watermark to reference OtOpcUaClient/pki, or bind it to ClientStoragePaths.GetPkiPath() so it cannot drift again.

Resolution: Resolved 2026-05-23 — Updated the watermark text in Views/MainWindow.axaml from (default: AppData/LmxOpcUaClient/pki) to (default: AppData/OtOpcUaClient/pki) so it matches the canonical folder name resolved by ClientStoragePaths (the binding-to-helper alternative was considered but a static string keeps the watermark cheap; the path is also already documented in docs/Client.UI.md).