docs(code-reviews): comprehensive per-module review pass at 76d35d1
Reviewed all 31 src/ production projects against the 10-category checklist in REVIEW-PROCESS.md. Each module gets its own findings.md; code-reviews/README.md is regenerated from them. 334 findings: 6 Critical, 46 High, 126 Medium, 156 Low. Critical findings: - Server-001: WriteNodeIdUnknown recurses unconditionally — a HistoryRead on an unresolvable node crashes the process (remote DoS). - Admin-001/002: app-wide auth bypass (RouteView not AuthorizeRouteView) plus unauthenticated mutating routes. - Core.Scripting-001: System.Environment reachable from operator scripts; Environment.Exit() terminates the server. - Core.AlarmHistorian-001: rowIds/events parallel-list desync on a corrupt payload misapplies outcomes — silent alarm-event data loss. - Driver.Galaxy-001: ReconnectSupervisor is built but never triggered, so a transient gateway drop permanently kills the event stream. All findings are Status=Open; resolution is tracked per REVIEW-PROCESS.md section 4. Review only — no source code changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
296
code-reviews/Client.UI/findings.md
Normal file
296
code-reviews/Client.UI/findings.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 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 | 11 |
|
||||
|
||||
## 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### Client.UI-002
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `ViewModels/MainWindowViewModel.cs:255`, `ViewModels/MainWindowViewModel.cs:333` |
|
||||
| Status | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### Client.UI-004
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | OtOpcUa conventions |
|
||||
| Location | `Views/MainWindow.axaml.cs:125-138` |
|
||||
| Status | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### Client.UI-005
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `ViewModels/MainWindowViewModel.cs:286-304`, `ViewModels/MainWindowViewModel.cs:155-189` |
|
||||
| Status | Open |
|
||||
|
||||
**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
|
||||
`ObservableCollection`s, 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:** _(open)_
|
||||
|
||||
### 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### Client.UI-009
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `ViewModels/HistoryViewModel.cs:44-54` |
|
||||
| Status | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### 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 | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
|
||||
### Client.UI-011
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `Views/MainWindow.axaml:81`, `Services/JsonSettingsService.cs:11-15` |
|
||||
| Status | Open |
|
||||
|
||||
**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:** _(open)_
|
||||
Reference in New Issue
Block a user