Skip to content

UaLens — multi-tab Avalonia desktop client for OPC UA#3766

Draft
marcschier wants to merge 57 commits into
OPCFoundation:masterfrom
marcschier:pisrapp
Draft

UaLens — multi-tab Avalonia desktop client for OPC UA#3766
marcschier wants to merge 57 commits into
OPCFoundation:masterfrom
marcschier:pisrapp

Conversation

@marcschier
Copy link
Copy Markdown
Collaborator

@marcschier marcschier commented May 14, 2026

UaLens — multi-tab Avalonia desktop client for OPC UA

UaLens is a new Avalonia desktop client added under Applications/Opc.Ua.Lens/ as a reference UI on top of OPCFoundation.NetStandard.Opc.Ua.Client. Every workflow lives in its own tab; users keep N tabs open against a single connected session.

What ships in this PR

Tab applications (8 plug-ins)

Plug-in Highlights
Subscription Live monitored items with chart modes (dots / bars / lines / signal / histogram / heatmap). Per-monitored-item status sub-pane (queue size, sampling, mode, samples received, last status, last value). Right-click Set monitoring mode (Disabled / Sampling / Reporting). Optional DataChangeFilter (Trigger + Deadband) on Add.
GDS Push 🛡 Push-management: secondary ServerPushConfigurationClient, trust-list management with TrustListMasks filter and Rejected refresh, server-status poll (BuildInfo / StartTime / CurrentTime / State / SecondsTillShutdown), Apply Changes flow.
GDS Management 🏛 Pull-management: register / unregister applications, per-app certificate groups, combined Issue + Deliver flow (Pull writes to local store; Push spawns ephemeral push client + UpdateCertificate + ApplyChanges). HTTPS-cert variant. Pull-Trust-List → Save Locally / Push to Server. Three-mode RegisterApplicationDialog (ClientPull / ServerPull / ServerPush) with XML Load-from-config / Save-as.
GDS Discovery 🔍 4-root tree: Local Machine (LDS FindServers), Local Network (FindServersOnNetwork), Global Discovery (GDS QueryServers with filter), Custom Discovery (persisted favourites via FavoritesStore). Per-server endpoints list via GetEndpoints. Context menu: Connect (drops onto Connection pane), Open as Push, Open as Management.
Event View 🔔 Per-tab event subscription with severity threshold filter, field-list editor, event-fields detail tree. Address-space context menu seeds the source.
Historian 📈 History read (Raw / Modified / Processed / AtTime) + history update (edit row); per-row clipboard / CSV export.
Performance 📊 Synthetic write / call benchmarks, latency histogram (log buckets), run history (64-entry cap) with CSV save/load and compare-last-3 visual highlight.
File System 📁 Browses FileType / FileDirectoryType like Windows Explorer using the new Opc.Ua.Client.FileSystem.FileSystemClient SDK. Auto-attaches Server.FileSystem; Pick-root opens a type-filtered BrowsePickerDialog. OS-Explorer drop-target for uploads; per-row context menu for Add File / Export / Rename / Delete / Refresh.

Cross-cutting utilities

  • Connection panel — Engine toggle (ChannelV2 / Classic), endpoint URL, secure / anonymous / user identity / X.509, certificate-store + trust-list dialogs.
  • Address space — Live TreeView with search (F3), context menu (Add Item / Add Recursively / Call Method / Write Value / Read history / Show Events / Perf / Export value / Find by path / View NodeState), address-space view kinds (Objects / ObjectTypes / VariableTypes / DataTypes / ReferenceTypes / Views), attribute + reference side panels.
  • Find Node by pathTranslateBrowsePathsToNodeIds UI.
  • View NodeState — Recursive attribute + reference dump dialog from the address-space context menu; one batched Read, lazy browse for references, Copy as text to clipboard.
  • Preferred Locales — Edit + apply via ChangePreferredLocales.
  • Write Value Dialog — Now with optional Status + SourceTimestamp + ServerTimestamp overrides.
  • NodeSet export — Multi-namespace NodeSet2.xml export.
  • Diagnostics — App log + resource monitor + publish-message log (sequence# / publish-time / notif-count for every publish on every adapter).

Packaging

  • Builds against net8.0 / net9.0 / net10.0.
  • net10.0 target ships as a global dotnet tooldotnet tool install -g OPCFoundation.NetStandard.Opc.Ua.Lens && ualens. Tool-specific metadata gated to net10.0 only (multi-TFM packs need -p:TargetFramework=net10.0).
  • AOT-compatible: dotnet publish -c Release -f net10.0 -r win-x64 produces a 38 MB native UaLens.exe, 0 warnings, 0 errors.
  • NugetREADME.md ships as the package landing page.

Sample parity reference

The implementation draws from UA-.NETStandard-Samples/Samples/{Client.Net4, ClientControls.Net4, Controls.Net4, ReferenceClient, GDS/Client*}. Coverage matrix lives in the per-feature commit messages.

Verification

  • dotnet build -c Release -f net10.0 — 0 warnings, 0 errors.
  • dotnet format --verify-no-changes — exit 0.
  • dotnet pack -c Release -p:TargetFramework=net10.0 — produces OPCFoundation.NetStandard.Opc.Ua.Lens.<version>.nupkg.
  • dotnet tool install --tool-path … --add-source … — round-trip install + launch + uninstall — all exit 0.
  • dotnet publish -c Release -f net10.0 -r win-x64 (AOT) — 0 warnings, 0 errors.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 14, 2026

CLA assistant check
All committers have signed the CLA.

@marcschier marcschier changed the title UaLens: Avalonia desktop client for OPC UA UaLens May 14, 2026
@marcschier marcschier marked this pull request as draft May 14, 2026 04:44
@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.91%. Comparing base (239a40b) to head (70f7429).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3766      +/-   ##
==========================================
- Coverage   72.02%   71.91%   -0.12%     
==========================================
  Files         679      681       +2     
  Lines      130956   131119     +163     
  Branches    22306    22326      +20     
==========================================
- Hits        94321    94291      -30     
- Misses      29963    30154     +191     
- Partials     6672     6674       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Adds a new desktop application UaLens (Applications/Opc.Ua.Lens) — an
Avalonia 11 workbench for browsing, subscribing to and operating an OPC
UA server.  The app is plug-in based, with a shared Connection panel and
address-space tree driving five built-in plug-in tab types:

* Subscription — multi-tab live notification scope with six rendering
  modes (Dots / Bars / Lines / Signal / Histogram / Heatmap) on a
  custom AnimationCanvas, plus the diagnostic header (seq, missing /
  republish / dropped, value counters).
* Historian — Raw/Modified, Processed-Aggregate, At-Time read modes
  with UtcDateTimePicker composites, a RangeDialog, inline At-Time
  edit/save sentinel rows, SharedSizeGroup-resizable result columns, a
  ScottPlot line chart and CSV export.
* Event View — per-tab classic subscription, severity-threshold filter,
  per-event-type field-selection driven by an in-dialog
  BrowsePickerDialog event-type chooser, pause / clear / details tree.
* Performance — benchmark runner with rate slider, TimeSpan-style
  [N] [Unit] duration composite, throughput chart, latency histogram
  with percentile statistics and CSV export.
* GDS Push / GDS Management — secondary-session piggyback on the main
  connection when it is SignAndEncrypt; auto-prompt via a shared picker
  when the outer session is unsuitable; AdminCredentialsRequired
  reactive prompt kept.

Shared building blocks:

* Connection/EndpointCredentialsPicker unifies Discovery →
  EndpointPickerDialog → CredentialsDialog and is consumed by the
  Connection panel and both GDS plug-ins.
* Connection/DataValueCodec + Views/EncodingPickerDialog +
  Views/EncodedValueIO provide Binary / XML / JSON encode + decode for
  DataValue and Variant via the SDK encoders, powering Write Value's
  Import-from-file, Method Call's per-argument file import, and the
  address-space Export-value-to-file context menu.
* Views/BrowsePickerDialog (lazy tree, NodeClass / ReferenceTypeId
  filter, async predicate) + Views/FlattenedBrowseDialog (live
  recursive flat browse with progress) deliver the node-pick fallback
  used by Historian, Performance and Event-source flows.
* Address-space context menu with class-aware entries: Add Item,
  Add Recursively, Call Method, Write Value, Read history…,
  Show Events…, Perf…, Export value to file….

Connection plumbing:

* Connection/ConnectionService owns the ManagedSession, the certificate
  validator hook-up (currently auto-accepting untrusted certs while
  the new ICertificateValidatorEx surface stabilises) and the per-tab
  subscription adapters.
* Connected state surfaces a "Change ▾" flyout with Disconnect, Change
  User (credentials-only Session.UpdateSessionAsync) and Reconnect
  (Session.ReconnectAsync).
* MainViewModel.IsAddressSpaceVisible mirrors the View menu toggle so
  plug-ins can short-circuit when the live tree is visible and a
  suitable node is already selected.
* MainViewModel.AddPluginAsync(kind, seedEventSource?, seedPickTarget?)
  lets the address-space context-menu shortcuts create a new tab
  pre-bound to the right-clicked node (Historian target, EventView
  source via EventViewPlugin.SeedSourceAsync, or Performance
  PickTarget invocation).

Build hygiene:

* Nullable reference types enforced project-wide
  (<WarningsAsErrors>nullable</WarningsAsErrors>).
* Every analyzer bucket cleared; CA2007 swept (await-using sites
  rewritten to the MS-doc-recommended block form).
* UTF-8 mojibake cleaned across files that had been round-tripped
  through CP-1252.
* dotnet build -c Release -f net10.0 clean (0 / 0).
* dotnet format --verify-no-changes exit 0.

Also moves the upstream "Applications/McpServer" naming to the
"Applications/Opc.Ua.Mcp" folder UaLens already uses, so the project
file, sign lists, build glob, agent-instruction note, the McpServer
docs and the README all reference a single canonical path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread Applications/Opc.Ua.Lens/Connection/CertificateStoreService.cs Dismissed
Comment thread Applications/Opc.Ua.Lens/Connection/CertificateStoreService.cs Dismissed
Comment thread Applications/Opc.Ua.Lens/Connection/CertificateStoreService.cs Dismissed
marcschier and others added 9 commits May 15, 2026 10:50
* Adds the Windows-Explorer-style FileSystem plug-in (Plugins/FileSystem/)
  built on Opc.Ua.Client.FileSystem.FileSystemClient. Auto-attaches
  Server.FileSystem on connect; Pick Root browses for FileType /
  FileDirectoryType / FileSystem instances filtered by a tri-state
  dialog; toolbar + per-row context menu cover Add File, Export File,
  New Folder, Rename, Delete, Refresh; OS Explorer drop-target uploads
  via UaFileInfo.OpenWriteAsync. Replaces the StubPlugin registration
  in ViewModels/PluginRegistry.cs.

* Replaces the short 4-line license stub in every .cs file under
  Applications/Opc.Ua.Lens/ with the canonical 28-line OPC Foundation
  MIT License 1.00 block used elsewhere in the repo, and adds the
  XML-comment equivalent at the top of every .axaml file that had no
  header (98 .cs files + 36 .axaml files; copyright year 2005-2025 to
  match the prevailing repo convention).

Verification:
* dotnet build -c Release -f net10.0 -- 0 warnings, 0 errors.
* dotnet format --verify-no-changes -- exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Integrates the nullable-reference-types enablement from OPCFoundation#3732 and the
ServerPushConfigurationClient cert-group push support from OPCFoundation#3585.

Resolved post-merge nullable annotation gaps in UaLens:
* BrowserViewModel: ReferenceDescription.DisplayName.Text /
  BrowseName.Name / NodeId.ToString() are nullable; coalesce to
  string.Empty so children collections stay typed string.
* NodeSetExportDialog: NamespaceTable.GetString returns string?;
  declare the local accordingly and keep the IsNullOrEmpty guard.
* HistoryReader: ExtensionObject.TryGetValue<T>(out T?) — declare the
  HistoryData / HistoryModifiedData outs as nullable.
* NodeAttributesViewModel: same TryGetValue pattern for
  RolePermissionType.
* EventViewPlugin: AmbientMessageContext.Telemetry is now nullable;
  annotate the fallback with ! plus a comment explaining why the
  dispose-time null path is unreachable.
* DataValueCodec: BinaryEncoder/XmlEncoder.CloseAndReturnBuffer /
  CloseAndReturnText are nullable but never null when the encoder owns
  its destination — annotate with !. ReadDataValue is now nullable
  too; coalesce to
ew DataValue() to match the existing JSON branch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… plug-in)

The reference GDS sample client (UA-.NETStandard-Samples/Samples/GDS/Client*)
exposes three GDS workflows we don't yet cover end-to-end in UaLens:
LDS/GDS-backed discovery, push/pull cert delivery, and trust-list pull/push
bridging. This commit starts closing the gap.

Phase 1 - shared scaffolding:
* Plugins/Gds/Shared/RegisteredApplicationContext.cs - immutable record
  mirroring the sample's RegisteredApplication POCO (app identity +
  cert/trust-list paths for pull mode + push endpoint).
* MainViewModel.CurrentRegisteredApp [ObservableProperty] - top-level
  state cooperating GDS tabs all see.
* MainViewModel.AddPluginAsync(...) gains seedRegisteredApp /
  seedDiscoveryEndpoint parameters so spawned tabs land with context.

Phase 2 - GDS Discovery plug-in (new):
* Plugins/GdsDiscovery/GdsDiscoveryPlugin.cs - IPlugin VM with a 4-root
  TreeView (Local Machine / Local Network / Global Discovery /
  Custom Discovery) that lazily calls FindServersAsync,
  FindServersOnNetworkAsync, QueryServersAsync (filtered) and renders
  per-server endpoint lists via GetEndpointsAsync.
* Plugins/GdsDiscovery/GdsDiscoveryView.axaml(.cs) - toolbar + split
  TreeView/ListBox.
* Plugins/GdsDiscovery/QueryServersFilterDialog.axaml(.cs) - GDS
  QueryServers filter editor (App name/URI/Product URI/Capabilities).
* PluginKind.GdsDiscovery + PluginRegistry entry (glyph 🔍, header
  'GDS _Discovery').
* Context-menu actions: 'Connect to selection' drops the URL onto the
  Connection pane, 'Open as Push…' / 'Open as Management…' spawn the
  respective GDS plug-ins seeded with the picked endpoint.

Verification:
* dotnet build -c Release -f net10.0 -- 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes -- exit 0.

Follow-up phases (planned, not yet implemented): combined issue+deliver
flow in GdsManagement, trust-list pull/push bridge, ServerStatus poll
panel on GdsPush, three-mode RegisterApplicationDialog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two small, self-contained additions ported from the OPC Foundation
reference WinForms client (UA-.NETStandard-Samples/Samples) so the UaLens
Avalonia front-end gains parity with the most-asked-for utility dialogs:

* A1 - Find by path...:
  Views/FindNodeDialog.axaml(.cs) resolves one or more RelativePath
  strings against a starting NodeId via
  TranslateBrowsePathsToNodeIdsAsync. Reachable from the address-space
  context menu (works with no selection too - defaults to ObjectsFolder).
  Mirrors Samples/Controls.Net4/Common/FindNodeDlg.cs.

  BrowserViewModel.ResolveBrowsePathsAsync() does the service-call work
  (parses each RelativePath against the session TypeTree, batches the
  TranslateBrowsePaths call, returns per-row status + matches).

* A6 - Preferred locales...:
  Views/LocalePickerDialog.axaml(.cs) edits the session's preferred-locales
  list (Add / Remove / Up / Down) and calls
  ISession.ChangePreferredLocalesAsync on Apply. Mirrors
  Samples/ClientControls.Net4/Common/SelectLocaleDlg.cs.

* MainWindow menu adjustments:
  * Renamed "_Certificates" to "_Session" with "Manage Certificates..."
    + new "Preferred Locales..." entries (Ctrl+K shortcut preserved).
  * Added "GDS _Discovery" entry to Tabs -> Add so the new plug-in is
    reachable from the menu (it was already in PluginRegistry).

Verification:
* dotnet build -c Release -f net10.0 -- 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes -- exit 0.

Remaining Phase A items (A2 browse-modes, A3 DataChangeFilter, A4 saved-
endpoints favourites, A5 perf CSV export) plus phases B-E are tracked in
plan.md for follow-up sessions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Six parallel sub-agent deliveries landed together in this commit
(~2.5kLOC additions across 18 modified + 5 new files):

* samples-A2 - Address-space view modes: BrowserViewModel gains a
  CurrentViewKind ObservableProperty + SetViewKindAsync that re-roots
  the tree under Objects / ObjectTypes / VariableTypes / DataTypes /
  ReferenceTypes / Views and switches the hierarchical reference type.
  AddressSpaceView toolbar gets a "View:" ComboBox.

* samples-A3 - DataChangeFilter on AddItem: MonitoredItemConfig gains
  an optional DataChangeFilter; AddItemDialog grows a "Data change
  filter (optional)" section (Trigger + Deadband type + Deadband value;
  hidden in event mode); both ClassicEngineAdapter and
  ChannelV2EngineAdapter propagate the filter to the underlying
  monitored item.

* samples-A4 - Saved-endpoints favourites: new Connection/FavoritesStore
  persists a versioned favourites.json under %LocalAppData%\UaLens;
  GdsDiscoveryPlugin seeds the Custom Discovery root with the saved
  entries (glyph star), exposes Add/Remove favourites commands +
  toolbar buttons + context-menu entries.

* samples-A5 - Performance CSV save/load + compare-last-3:
  new BenchmarkRun record + CSV serializer; PerformancePlugin captures
  each finished run into a 64-entry RunHistory, exposes
  SaveResultsAsync / LoadResultsAsync commands, and a CompareLast3
  toggle that visually highlights the last three runs in a new history
  list in PerformanceView.

* gds-server-status (GDS phase 5): periodic ServerStatusDataType read
  (1 Hz) on GdsPushPlugin once a secondary push session is live; new
  Expander panel in GdsPushView shows BuildInfo / StartTime /
  CurrentTime / State / SecondsTillShutdown / ShutdownReason with a
  manual Refresh button (PollOnceCommand).

* gds-register-dialog (GDS phase 6): RegisterApplicationDialog rebuilt
  as a three-mode form (ClientPull / ServerPull / ServerPush). Push
  mode shows endpoint + Pick... reusing EndpointCredentialsPicker;
  Pull modes show cert / trust-list store paths plus an HTTPS expander.
  Load from config... / Save... round-trip via a new public
  RegisteredApplicationContextDto (mediator for XmlSerializer over the
  internal record). On Register, the resulting context is promoted to
  MainViewModel.CurrentRegisteredApp so cooperating tabs see it.

* tool-csproj + tool-readme: Opc.Ua.Lens.csproj now packs as a global
  .NET tool (PackageId OPCFoundation.NetStandard.Opc.Ua.Lens, command
  name ualens) on the net10.0 target; non-net10 TFMs build but don't
  pack. Ships a tool-specific NugetREADME.md as the package landing
  page. Verified: dotnet pack produces
  OPCFoundation.NetStandard.Opc.Ua.Lens.<version>.nupkg containing
  tools/net10.0/any/UaLens.dll.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format Opc.Ua.Lens.csproj --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two more GDS phases land on top of phases 1+2 (shared scaffolding +
Discovery), 5 (server-status), 6 (three-mode register dialog):

* gds-mgmt-issue-deliver: GdsManagementPlugin.IssueNewCertificateAsync
  is now delivery-aware via a shared IssueAndDeliverAsync core.
  ClientPull/ServerPull deliver into the registered application's
  CertificateStorePath + IssuerListStorePath via
  CertificateStoreIdentifier.OpenStore + AddAsync/AddCRLAsync.
  ServerPush spins up an ephemeral ServerPushConfigurationClient
  against the registered PushEndpoint, calls UpdateCertificateAsync +
  ApplyChangesAsync, then disposes. New IssueNewHttpsCertificateAsync
  wraps the same flow with ObjectTypeIds.HttpsCertificateType and the
  HTTPS-prefixed path properties.

* gds-trust-bridge:
  - GdsManagement: PullTrustListSaveLocallyAsync and
    PullTrustListPushToServerAsync commands branch on
    RegistrationType. Both share a ReadGdsTrustListAsync helper that
    calls GetTrustListAsync + ReadTrustListAsync; local-save honours
    TrustListStorePath/IssuerListStorePath gated on SpecifiedLists,
    push-to-server reuses the ephemeral-push pattern from the
    issue+deliver flow.
  - GdsPush: new TrustListMasks ObservableProperty (default All) and a
    mask ComboBox in the toolbar; RefreshAsync threads the mask into
    ReadTrustListAsync. New RefreshRejectedListAsync command + ".
    Rejected" button calls GetRejectedListAsync and replaces just the
    Rejected ObservableCollection.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Six parallel sub-agent deliveries across UaLens polish, subscription
debugging, write extensions, and address-space inspection
(~900 LOC additions across 19 modified + 3 new files):

* P3 (polish-p3-session-helper): new
  Plugins/Gds/Shared/GdsSessionHelper.cs with 3 partially-extracted
  static helpers (IsOuterSuitable, IsOuterInsecure,
  SafeDisconnectAndDisposeAsync). GdsPushPlugin / GdsManagementPlugin
  shed ~39 LOC of duplicated session plumbing. The fuller
  EstablishOrSwitchSessionAsync / EnsureSessionAsync extraction was
  punted (interlocks 5+ pieces of plug-in state) - documented in the
  helper's class summary.

* P4 (polish-p1-p4 part): BrowserViewModel default Objects-view root
  restored to ObjectIds.RootFolder (i=84) so Types/Views siblings are
  visible by default; View combo still re-roots to the per-mode folder.
  (P1 - un-conditioning IsPackable - was investigated and reverted:
  multi-TFM tool packs can't have IsPackable unconditional because
  PackAsTool can only live on net10.0. The csproj now documents the
  required -p:TargetFramework=net10.0 pack invocation in a comment.)

* B1 (samples-b1-b2 part): per-monitored-item status sub-pane
  (Id/BrowseName/NodeId/Attr/Mode/Sampling/Q/Samples/Status/Value)
  toggled by a new chart-toolbar Items checkbox bound to
  SelectedSubscriptionTab.ShowItemStatusGrid. ConcurrentDictionary of
  MonitoredItemLiveStats on both engine adapters captures live values
  via existing FastDataChange/OnDataChangeNotificationAsync hot paths
  (no new observer); 250 ms DispatcherTimer pulls snapshots into the
  bound ObservableCollection<MonitoredItemStatusRow>.

* B2 (samples-b1-b2 part): right-click "Set monitoring mode ->
  Disabled / Sampling / Reporting" on status-grid rows. New
  ISubscriptionAdapter.SetMonitoringModeAsync; Classic adapter routes
  through Subscription.SetMonitoringModeAsync; V2 adapter mutates the
  per-item OptionsMonitor (V2 change-tracking propagates a server-side
  SetMonitoringMode). Confirmed mode mirrored back into cached
  MonitoredItemConfig.

* B4 (samples-b4-publish-log): new Diagnostics/PublishLogObserver
  (singleton on MainViewModel, threaded into both adapters via
  ConnectionService) records (Time, SubId, SequenceNumber, PublishTime,
  NotifCount, Type) for every publish callback. New "Publishes" tab
  under DiagnosticsView; 500-entry FIFO cap; consecutive callbacks
  sharing (SubId, SequenceNumber) merge into a single row with Type
  Mixed. EventView's tab-local subscription is logged automatically
  because all adapters flow through CreateAdapter.

* C2 (samples-c2-write-datavalue): WriteValueDialog gains an Advanced
  expander with three optional fields (StatusCode TextBox accepting
  hex / decimal / symbolic name like "BadOutOfService"; SourceTimestamp
  and ServerTimestamp via the existing UtcDateTimePicker). Each is
  gated by an Override checkbox; unrecognised status names surface as
  an explicit error rather than silently writing Good.

* E1 (samples-e1-view-nodestate): new Views/ViewNodeStateDialog hosts
  a recursive TreeView dump of the right-clicked node - all attributes
  read in one batched session.ReadAsync (per-attribute bad codes
  rendered inline), References sub-branch lazily browsed and grouped
  by ReferenceTypeId, Variables get a dedicated Value sub-node.
  "Copy as text" writes a depth-indented dump to the clipboard.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.
* dotnet pack -c Release -p:TargetFramework=net10.0 -
  OPCFoundation.NetStandard.Opc.Ua.Lens.<version>.nupkg produced.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@marcschier marcschier changed the title UaLens UaLens — multi-tab Avalonia desktop client for OPC UA May 18, 2026
@marcschier marcschier marked this pull request as ready for review May 18, 2026 07:13
marcschier and others added 5 commits May 18, 2026 11:49
~6.3kLOC across 21 new files + 24 modified, single fleet batch.

* samples-c1-complex-edit: new ComplexValueEditor + EditArrayDialog +
  PrimitiveValuePromptDialog + ComplexValueElementDialog +
  ComplexValueIO. Recursive Structure / Union / Enum / array editor
  driven by DataTypeDefinition (cached per session via
  ConditionalWeakTable). Reusable UserControl (Value StyledProperty +
  Initialize/TryCommit) consumed by WriteValueDialog (inline) and
  MethodCallDialog (per-argument complex-edit button). Opaque-type
  fallback: existing JSON/XML/binary file-import path.

* samples-d1-where-clause: full ContentFilter editor.
  Views/ContentFilterEditor (left ListBox of elements + right
  per-element editor + bottom preview) + FilterOperandEditDialog
  (single composite dialog switching on FilterOperandKind: Literal /
  Element / Attribute / SimpleAttribute). EventFilterConfig gains
  WhereClause; EventViewPlugin BuildEventFilter writes it through.

* samples-c3-c4-history: HistoryRow gains Annotation + DisplayAnnotation
  (resolved via TranslateBrowsePaths + ReadRawModifiedDetails on the
  Annotations HasProperty); HistorianView gains a 4th annotation column
  and a HistorianUpdateOp combo (Insert / InsertReplace / Replace /
  Remove / DeleteRaw / DeleteModified / DeleteAtTime) with per-op input
  panels. HistoryUpdater returns HistoryUpdateOutcome (overall status +
  per-row results), surfaced in the result label without throwing. New
  AnnotationEditDialog edits Message; UserName + AnnotationTime stamped
  at Save. Per-row context menu (Edit annotation / Edit row / Delete
  row) on the rows list.

* samples-f2-cert-manager: new CertificateManager plug-in
  (PluginKind.CertificateManager, glyph key). Left TreeView of the 4
  well-known stores (Application / TrustedPeer / TrustedIssuer /
  Rejected) plus right ListBox of certificates. Toolbar (Add store,
  Refresh, Open trust dialog) + per-row context menu (View details,
  Trust, Reject, Delete, Export PEM/DER, Import). Works without an
  active OPC UA session via ConnectionService.GetConfigAsync.

* samples-f3-mdns-hosts: GdsDiscovery Local Network root now appends a
  mDNS hosts (this machine) sub-folder enumerating NetworkInterface
  unicast addresses (hostname / family / IP / interface / MAC / up)
  with an inline disclaimer that cross-host mDNS requires an
  additional library (e.g. Makaretu.Mdns) which UaLens does not
  reference. Task.Run wraps enumeration so the UI thread never blocks.

* polish-p5-stj-dto: RegisteredApplicationContextDto migrated from
  XmlSerializer to System.Text.Json with a [JsonSerializable] source-
  generated context (trim/AOT safe). DTO is now internal sealed. Load
  retains legacy XML compat via XDocument (no XmlSerializer dep so
  internal/AOT stays clean). File-picker filter switches to .json on
  Save; Open accepts .json + .xml.

* ui-polish-bundle:
  - Toggle button (🔽) next to the address-space Refresh, plus
    View -> Address Space -> Filter / View Combo menu entry, plus
    Ctrl+Shift+F shortcut. Default: filters hidden.
  - References panel hidden by default (SidePanelMode default
    AttrsAndRefs -> AttrsOnly; cycle order unchanged).
  - Node-class icons replaced with consistent emoji set (Object 🟦
    / ObjectType 🧩 / Variable 🟢 / VariableType 🟣 / Method ⚙️ /
    ReferenceType 🔗 / DataType 🧮 / View 👁️).
  - New AboutDialog under Help -> About... (480x420, app icon +
    product header + runtime assembly version + 2025 OPC Foundation
    copyright + verbatim MIT 1.00 license body + Visit website +
    Close).

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0 (one IMPORTS fix
  auto-applied in ComplexValueEditor).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The B1 status sub-pane (Subscription tab) was fixed-width (560 px) with
hardcoded column widths.  Two ergonomic fixes:

* The chart / flyout split is now a Grid with three columns
  (chart *, splitter Auto, flyout 400 px / MinWidth 240) and a
  GridSplitter at column 1.  The splitter is gated on the same
  ShowItemStatusGrid flag as the flyout itself so dragging only
  shows when the sub-pane is visible.
* The 10-column header + per-row template share a SharedSizeGroup
  scope (Grid.IsSharedSizeScope=True on the parent Grid containing the
  header row + ListBox).  Each column lives in its own ColumnDefinition
  with a SharedSizeGroup; intervening Auto-width splitter columns
  carry a <GridSplitter Width=3 ResizeBehavior=PreviousAndNext> only
  in the header — the per-row template just mirrors the layout via the
  same SharedSizeGroups so columns stay aligned when the user drags.
  Pattern lifted from the existing HistorianView grid.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three localised polish items:

* Subscription toolbar: 📋 Items checkbox label changed to "Monitored
  Items" (no icon), per user request. Added three sibling chart-element
  visibility checkboxes (Legend, X axis, Y axis) bound to new per-tab
  SubscriptionViewModel.ShowLegend / ShowXAxis / ShowYAxis observable
  properties (default true).

* ScottPlotView gains SetChartElementsVisible(legend, xAxis, yAxis)
  which mutates Plot.Axes.Bottom.IsVisible / Left.IsVisible and
  Show/HideLegend, then refreshes. The values are remembered locally
  so each pump's Bind() re-applies them (every pump previously
  unconditionally called ShowLegend on bind). MainWindow wires the 3
  checkbox PropertyChanged callbacks into ScottPlotView and pushes
  the per-tab state on tab switch via SyncTabUiState.

* Address-space tree node icons: replaced the emoji glyphs baked into
  NodeViewModel.Text (🟦/🧩/🟢/🟣/⚙️/🔗/🧮/👁️) with per-NodeClass
  vector Paths. New Views/NodeClassIcons.cs holds the 8 StreamGeometry
  resources + a SolidColorBrush palette + three IValueConverter
  helpers (Geometry / Fill / Stroke). AddressSpaceView template now
  renders <Path Width=14 Height=14> + <TextBlock> side-by-side; the
  TextBlock binds to just the name (the glyph prefix is no longer
  injected by BrowserViewModel.LoadChildrenAsync). Concrete kinds
  (Object/Variable/Method/View) render filled; type kinds
  (ObjectType/VariableType/DataType/ReferenceType) render stroked-only
  in lighter tints to read as templates. Vector chosen over
  raster .ico/.png so the icons stay crisp at any DPI without asset
  management - functionally equivalent to PNGs for the user's intent.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two distinct UX fixes bundled together:

* Node-class icons now use arc-free M/L/Z StreamGeometries so each
  NodeClass renders with a visibly different silhouette: solid square
  for Object, gridded square for ObjectType, solid hexagon for
  Variable, hexagon-ring for VariableType, triangle for Method,
  bidirectional arrow for ReferenceType, three-row spreadsheet for
  DataType, diamond for View. StrokeThickness bumped to 1.6 and
  StrokeJoin set to Round so the outline-only type kinds still read
  clearly at 14x14. Root NodeViewModel (BrowserViewModel.Reload) no
  longer prefixes the emoji glyph - the icon set takes over fully.

* Plug-in toolbars now flow children onto the next line when the
  window narrows (no horizontal scroll bar). Six top-level toolbars
  switched from <DockPanel LastChildFill=False>+inner StackPanel /
  <ScrollViewer>+StackPanel layouts to <WrapPanel Orientation=Horizontal
  ItemHeight=32>:
  - MainWindow Subscription toolbar
  - Plugins/CertificateManager/CertificateManagerView
  - Plugins/EventView/EventViewView
  - Plugins/GdsDiscovery/GdsDiscoveryView
  - Plugins/FileSystem/FileSystemView
  - Plugins/GdsManagement/GdsManagementView (action bar)
  - Plugins/GdsPush/GdsPushView (action bar)
  - Plugins/Historian/HistorianView (action buttons row)
  Logically-grouped sub-units (e.g. ComboBox + label, timescale
  +/-/value cluster, Add/Remove/Settings cluster) are wrapped in
  small inline StackPanels inside the WrapPanel so the groups stay
  together when individual rows reflow.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Repro: trust a rejected certificate in the Certificate store dialog ->
Avalonia 11+ enforces UI-thread access for logical-tree queries and
throws "Call from invalid thread" out of FindControl<TextBlock>
inside SetStatus(), tearing the app down.

Root cause: the rejected-Trust button handler awaits ReloadAsync(...)
with ConfigureAwait(false), so by the time control reaches the inner
SetStatus(...) call we're on a thread-pool thread.  The old
implementation walked the logical tree (FindControl) from that worker.

Fix:
* Cache the StatusLabel TextBlock once at WireUp() (UI thread) into a
  new m_statusLabel field.
* SetStatus(...) no longer calls FindControl; it just reads the cached
  reference.  Marshals the Text assignment via
  Dispatcher.UIThread.CheckAccess() / Post so it stays thread-safe
  whether the caller is on the UI thread or a worker.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
catch (Exception ex)
{
Status = $"● Move to {dst.DisplayName} failed: {ex.Message}";
m_log.LogWarning(ex, "Certificate Manager tab {Title} Move({Target}) failed.", Title, target);
marcschier and others added 6 commits May 18, 2026 14:47
Replaces the inline 🔌 Connection panel (Endpoint TextBox + Connect /
Change ▾ flyout + Engine button + status banner) with a modal
ConnectDialog reachable from a restructured Session menu.

File menu (after):
* Export NodeSet2 · Export Plugin Data · Manage Certificates (moved
  from Session, Ctrl+K still works) · Quit.

Session menu (after, in order):
* Create...                  (Ctrl+N — opens ConnectDialog; disabled
                              when connected)
* Close (Disconnect)          (enabled when connected)
* Reconnect                   (enabled when connected)
* Change User...              (enabled when connected)
* Use ChannelV2 Engine        (checkbox bound TwoWay to
                              MainViewModel.UseChannelV2Engine -
                              toggling while connected recreates the
                              session; the older C4 guard that refused
                              the toggle is removed per user request)
* Load Session... (Ctrl+O)    (moved from File, enabled when
                              connected)
* Save Session... (Ctrl+S)    (moved from File, enabled when
                              connected)
* Preferred Locales...        (enabled when connected)

ConnectDialog (new Views/ConnectDialog.axaml(.cs)):
* Modal 460x260, single TabControl containing one URL tab today.
* URL tab content is a single TextBox pre-populated from
  MainViewModel.EndpointUrl.
* Connect (IsDefault) + Cancel (IsCancel) button bar.
* Extensibility scaffold for future MDNS / LDS / reverse-connect
  sources: an IConnectionSourceTab interface + a XAML comment marker
  immediately under the URL tab.  Each future tab's content control
  implements GetEndpointUrl() and the existing Connect handler picks
  the active tab automatically — no further dialog refactor needed.

MainWindow code-behind:
* OnSessionCreateAsync() opens the dialog, writes the picked URL into
  MainViewModel.EndpointUrl, and reuses the existing OnConnect() flow
  (which itself still goes through EndpointCredentialsPicker for
  security mode + identity).
* Removes all references to the old inline panel controls
  (ConnectButton / ChangeButton / ToggleEngineButton /
  MenuConnectionDisconnect / MenuConnectionChangeUser /
  MenuConnectionReconnect / EndpointBox).
* Ctrl+U keyboard shortcut (previously focused the inline EndpointBox)
  now opens the ConnectDialog as a convenience alias for Ctrl+N.

MainViewModel:
* New UseChannelV2Engine get/set wrapper for the menu checkbox
  TwoWay binding.  The setter routes through the existing
  ToggleEngineCommand so the disconnect/reconnect dance stays in one
  place.
* ToggleEngineAsync no longer short-circuits when connected with
  open tabs - the menu binding makes the toggle deliberate so the
  recreate-session behaviour is the desired outcome.

Verification:
* dotnet build -c Release -f net10.0 - 0 Warning(s), 0 Error(s).
* dotnet format --verify-no-changes - exit 0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… fix

* BrowserViewModel: relabel the Objects-view root from 'Objects' to

  'Root' since it actually starts at i=84 (RootFolder); the inner

  'Objects' node is now distinct and unambiguous.

* SubscriptionViewModel: default ShowLegend / ShowXAxis / ShowYAxis

  to false; the chart is busy enough without legend / axis chrome.

  MainWindow.axaml binding FallbackValue switched to False.

* MainWindow: move 'Manage Certificates...' (Ctrl+K) back to the

  Session menu (under 'Preferred Locales...').

* CertificateManagerPlugin: per-cert actions (ViewDetails / Trust /

  Reject / Delete / Export) are now [RelayCommand]s gated on

  HasSelectedCertificate, so menu items and toolbar buttons grey

  out automatically when no row is selected. SelectedCertificate

  fires NotifyCanExecuteChangedFor on each command. ContributeMenuItems

  rebuilt around Command binding (CreateMenuItem helper) so the

  plug-in's contributed menu reflects CanExecute too.

* CertificateManagerView: add a 'Show details' / 'Import' / 'Export'

  group to the toolbar (commands honour CanExecute), declare a XAML

  ContextMenu on the cert ListBox bound to the same commands, and

  enable double-tap to open details. Column header now uses

  SharedSizeGroup + interleaved GridSplitters so the user can drag-

  resize each column; the per-row template mirrors the grouping.

  Removed the in-tab gray Status panel — duplicated the green

  SelectedTab.Status footer in MainWindow.

* Bug fix: Certificate.From(cert) takes ownership of the inner

  X509Certificate2 and disposes it via the wrapper's refcount, so

  the second wrap-and-dispose in BuildDetailsWindow/ExportAsync/

  AddToStoreAsync hit a freed handle ('m_safeCertContext is an

  invalid handle'). Switched all three call sites to

  Certificate.FromRawData(cert.RawData) which clones internally and

  leaves the caller's X509Certificate2 intact.

Build: 0/0 across net8.0/net9.0/net10.0; dotnet format clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every entry under Tabs -> Add... now has an InputGesture that's both

shown next to the menu entry and wired through the MainWindow KeyDown

handler:

  Subscription          Ctrl+T          (already shipped)

  GDS Push              Ctrl+Shift+P

  GDS Management        Ctrl+Shift+M

  GDS Discovery         Ctrl+Shift+D

  Performance           Ctrl+Shift+B  (Benchmark)

  Event View            Ctrl+Shift+V

  Historian             Ctrl+Shift+H

  File System           Ctrl+Shift+L  (fiLe)

  Certificate Manager   Ctrl+Shift+C

All new bindings use Ctrl+Shift+<letter>. The shift-modified branches

are checked before the existing plain Ctrl+V (cycle view-mode) and

Ctrl+R (toggle References) handlers so they take precedence when

Shift is held.

Build: 0/0 (net10.0 Release); dotnet format clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…heet

* MenuNewSubscription InputGesture: Ctrl+T -> Ctrl+Shift+S so the

  whole 'Tabs -> Add ...' group uses a single Ctrl+Shift+<letter>

  family.

* MainWindow KeyDown: replaced the Ctrl+T branch with Ctrl+Shift+S,

  and added a !shift guard on the plain Ctrl+S (Save Session) branch

  so Ctrl+Shift+S doesn't accidentally trigger Save first.

* Cheat sheet (F1): rewrote the layout to mirror the new menus —

  added Session group (Ctrl+N/U/O/S/K), added a full 'Tabs (add)'

  table for the Ctrl+Shift+<letter> family, added 'Tabs (manage)'

  for F2 rename / Ctrl+W close, dropped the stale 'Ctrl+U focus the

  Endpoint URL field' line (Ctrl+U now opens the Connect dialog),

  and wrapped the content in a ScrollViewer so the longer text

  still fits in the dialog (640 x 640).

Build: 0/0 (net10.0 Release).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…efaults

* Tabs -> Add ... menu: emojis moved to MenuItem.Icon with a fixed-

  width (18 px) centred TextBlock so the icon column lines up across

  all entries regardless of the individual emoji width. Header text

  no longer carries the icon, so the entry text also aligns.

* Subscription toolbar (MainWindow): WrapPanel HorizontalAlignment

  Right -> Left so the chart-controls bar fills from the left edge

  instead of leaving empty space when narrow.

* GDS Management toolbar: WrapPanel HorizontalAlignment Right ->

  Left so the action buttons left-align consistently with the

  other plug-in toolbars.

* Removed the in-toolbar emoji + plug-in name label from

  CertificateManagerView, GdsDiscoveryView, EventViewView and

  FileSystemView -- redundant with the tab strip label and forced

  awkward left padding before the action buttons.

* Replaced the Margin='4,0' (8 px on both sides) pattern with

  Margin='0,0,4,0' (4 px right only) so the first action button

  in each toolbar is flush with the panel's inner padding.

* Event View severity row: slider Width 160 -> 180, MinHeight 20

  and value MinWidth 32 -> 36 with right text-alignment so the

  thumb / value-label no longer crowd the adjacent Filter button.

* Performance Duration row: StackPanel Spacing 6 -> 8 and added

  Margin='2,0,0,0' to the Seconds ComboBox so the NumericUpDown

  spinner edge no longer touches the Unit selector.

* GDS Push Server status Expander: IsExpanded True -> False so the

  panel is collapsed by default; it was forcing users to scroll

  past 12 placeholder rows just to reach the trust-list tabs.

Build: 0/0 (net10.0 Release).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ileSystem

UaLens:

* FileSystemView details list switched to the SharedSizeGroup +

  interleaved-GridSplitter pattern (FsCol0/Sep0/Col1/Sep1/Col2)

  so the user can drag-resize NAME / SIZE / MODIFIED, matching

  CertificateManagerView and the Monitored items flyout.

ConsoleReferenceServer:

* Factory in Program.cs now constructs ReferenceServer with

  EnableFileSystemNodeManager = true so the canonical interactive

  host exposes Server.FileSystem (Part 20) by default. Unit-test

  fixtures still get the small browse-friendly address space

  because they construct ReferenceServer directly and the property

  defaults to false.

Build: 0/0 (UaLens net10.0 Release); ConsoleReferenceServer 2 pre-

existing CA1859 warnings unrelated to this change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marcschier and others added 9 commits May 20, 2026 08:53
Brand-new users opening a tab without a session previously had to
hunt through the Session menu to reach the Connect dialog. The
placeholder body now shows the existing hint plus a `Connect...`
button that invokes the same `OnSessionCreateAsync` handler wired
to `Session -> Create...` (Ctrl+N).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The disconnected-tab placeholder was nested inside the
`<Grid IsEnabled=IsConnected>` wrapper, so its `Connect...` button
was greyed out exactly when the user needed it.

Wrap the body Grid + placeholder StackPanel in an outer Grid (no
IsEnabled binding) so the placeholder sits as a sibling to the
disabled body, not a child of it. `IsVisible=!IsConnected` continues
to hide the placeholder once a session is active.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nt picker

The previous `Trigger event` toolbar action wrote a fixed value to
`NodeIds_Events_TriggerNode01` resolved through a hardcoded namespace
URI -- silent on every server except the OPC Foundation reference
server.

Replace with a flat-browse picker over the address space filtered to
nodes whose forward GeneratesEvent reference set is non-empty:

* Open `FlattenedBrowseDialog` rooted at `ObjectsFolder`, walking
  hierarchical references, accepting Method/Variable/Object nodes
  whose `HasGeneratesEventAsync` predicate returns true (one
  Browse with `ReferenceTypeId=GeneratesEvent, IncludeSubtypes,
  Forward, ResultMask=None` per candidate).
* On selection, open `MethodCallDialog` for Methods,
  `WriteValueDialog` for Variables (Object class is accepted in the
  picker but only diagnoses if the user picks one).
* Drop the obsolete `ResolveTriggerNodeAsync` /
  `TryTriggerNodeAsync` helpers and the per-tab
  `m_triggerCounter` / `m_autoTriggerFired` fields.
* Drop the auto-trigger that fired after the first AddSource -- the
  new picker is interactive, so auto-opening it would be intrusive.

The headless `--probe-events` runner still exercises TriggerNode01
directly against ConsoleReferenceServer for diagnostics; the UI no
longer depends on it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two reliability fixes for `+ Add Source` so the picked node always
appears in the Event sources panel:

1. **Race fix** - AddSourceAsync now awaits `m_subscriptionReady`
   before dispatching to AddSourceCoreAsync. Previously, a quick
   click right after opening a tab found `m_subscription == null`,
   AddSourceCoreAsync bailed out with a log-only warning, and the
   user saw an empty Event sources list with no UI feedback.
   SeedSourceAsync already had this gate; AddSourceAsync was missing
   it.

2. **Picker swap** - replace the tree-style BrowsePickerDialog
   fallback with the flat FlattenedBrowseDialog (same dialog used
   by the Trigger event button). The flat picker lists every
   reachable Object/View whose EventNotifier carries
   SubscribeToEvents in one scrollable view, which is easier to
   navigate than the lazy-loading tree on large address spaces
   and gives consistent UX with Trigger event.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two changes:

1. Revert the BrowsePickerDialog -> FlattenedBrowseDialog swap from
   the previous commit per user feedback. The tree picker is back
   for `+ Add Source`; only the Trigger event flow keeps using
   the flat picker.

2. **Display fix** - AddSourceCoreAsync now adds the EventSourceVm
   to the EventSources collection BEFORE the
   `sub.AddItem` / `ApplyChangesAsync` round-trip. Previously,
   if ApplyChanges threw (e.g. bad NodeId, server-side filter
   rejection, transport hiccup), control jumped to the catch block,
   the Dispatcher.Post that adds to EventSources was never queued,
   and the user saw an empty Event sources panel with no UI feedback
   despite the click being acknowledged. Now the source appears
   immediately on click; if ApplyChanges fails, we log the error
   but keep the source visible so the user knows the click was
   accepted (and so they can Remove it). Also dropped the
   Dispatcher.Post indirection - everything in AddSourceCoreAsync
   already runs on the UI thread via ConfigureAwait(true).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds live observability of the per-tab event subscription so users
can diagnose why events aren''t flowing without diving into the SDK
log buffer:

* New `SubscriptionStatus` line in the toolbar (right-aligned,
  monospaced) shows:
  `Sub <id> ┬À PI <ms> ┬À MI <created>/<total>[ ┬À <n> bad] ┬À publishing|paused`
  Updated when the subscription is created, after every AddSource,
  on every event publish, and on every keep-alive tick (via a new
  `FastKeepAliveCallback` on the classic subscription).
* Each `EventSourceVm` in the left panel now carries a live
  `State` row underneath the name: `pending` → `√ created`
  → `BAD: <statusCode>` based on the monitored item''s
  `Status.Created` / `Status.Error`. Refreshed alongside the
  toolbar indicator.
* AddSource log now includes `mi.Status.Id` so the server-assigned
  monitored-item id is visible in the SDK log too.

Diagnostic intent: when a user calls Condition.Enable / .Disable and
sees nothing in the event log, the toolbar shows whether the sub is
publishing, how many monitored items the server actually accepted,
and which (if any) returned a bad status. The reference server''s
Alarms node manager provides the conditions; auditing is enabled
in Quickstarts.ReferenceServer.Config.xml so AuditUpdateMethod /
AuditCondition events should reach any subscription rooted on the
Server node.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every plug-in / tab toolbar strip now uses ``Classes="toolbar"`` on
its WrapPanel.  App.axaml defines descendant styles that pin every
interactive control inside such a strip to:

* ``Height = 28``, ``MinHeight = 28`` so Button / ToggleButton /
  ComboBox / TextBox / CheckBox all render at the same height
  regardless of FluentTheme defaults.
* ``VerticalAlignment = Center`` + ``VerticalContentAlignment =
  Center`` so the label / icon sits on the same baseline as every
  neighbour.
* ``Padding = 10,0`` for Buttons / ToggleButtons, ``8,0`` for
  ComboBoxes -- keeps the horizontal whitespace per the previous
  look while flattening the vertical padding.
* ``FontSize = 12`` so icon glyphs render at the same metric size.

Toolbars updated: EventView, FileSystem, CertificateManager,
GdsDiscovery, GdsManagement, GdsPush, Historian, Performance, and
the Subscription tab toolbar in MainWindow.  StackPanel selectors
are also defined so future StackPanel-based toolbars inherit the
same treatment without further markup changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
User-reported exception:

  System.InvalidOperationException: Call from invalid thread
     at UaLens.Views.MainWindow.OnSelectedTabPropertyChanged(...)
     at UaLens.ViewModels.SubscriptionViewModel.set_Subscription(...)
     at UaLens.ViewModels.SubscriptionViewModel.ApplySubscriptionAsync(...)
     at UaLens.Views.MainWindow.OnSettings()

Root cause: ``OnSettings`` awaited the Subscription Settings dialog with
``.ConfigureAwait(false)`` and then invoked ``ApplySubscriptionCommand
.ExecuteAsync(r).ConfigureAwait(false)``. After the dialog closed the
continuation could resume on a thread-pool thread; the ``Subscription =
newConfig`` assignment in ``ApplySubscriptionAsync`` ran on that thread,
fired ``PropertyChanged`` off-thread, and the
``MainWindow.OnSelectedTabPropertyChanged`` handler then crashed when it
tried to ``RequiredControl<T>(...)``.

Fixes:
1. ``MainWindow.axaml.cs`` -- bulk-replace ``.ConfigureAwait(false)`` with
   ``.ConfigureAwait(true)`` so every dialog / command continuation
   resumes on the UI thread.
2. ``MainViewModel.ConnectAsync`` / ``ToggleEngineAsync`` -- same change,
   so ``ConnectionStatus`` and other ObservableProperty mutations after
   the await stay on the UI thread.
3. ``SubscriptionViewModel.ApplySubscriptionAsync`` -- defensive marshal
   of the ``Subscription = newConfig`` setter via
   ``Dispatcher.UIThread.InvokeAsync`` when not already on the UI thread,
   and ``Dispatcher.UIThread.Post(RefreshStatus)`` after the adapter
   call. This survives ANY future caller that happens to invoke it
   off-thread.
4. ``Views/*Dialog.axaml.cs`` (Certificate store, Method call, Write
   value) -- same ``.ConfigureAwait(false)`` -> ``.ConfigureAwait(true)``
   change so post-await ``RequiredControl`` lookups and ObservableProperty
   mutations stay on the UI thread.
5. ``Views/AddressSpaceView.axaml.cs`` and ``Views/DiagnosticsView``
   -- same change for the same reason (UI code-behind).

Files NOT touched: background-only call sites that intentionally yield
the UI thread:
* ``Views/ScottPlotView.axaml.cs`` ChannelReader loop inside Task.Run
* ``Views/EncodedValueIO.cs`` pure stream I/O
* ``Plugins/Performance/BenchmarkRunner.cs`` benchmark hot loop
* ``Plugins/Historian/Historian{Reader,Updater}.cs`` history SDK calls
* ``Plugins/Gds/Shared/GdsSessionHelper.cs`` disconnect on dispose
* ``ViewModels/BrowserViewModel.cs`` already uses ``PostToUiAsync`` to
  marshal every UI mutation explicitly, so the SDK browses can stay on
  the thread pool.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marcschier and others added 2 commits May 20, 2026 16:13
The Subscription Settings + several other dialogs used a smaller outer
`Grid Margin` (12 or 14) than the rest, so the visible whitespace
below the OK / Cancel button strip was noticeably tighter than the
gap from the dialog title bar to the first control.

Standardize every dialog''s root `Grid Margin` to 16 so the space
below the buttons matches the space above the first control.  Dialogs
already using 16 are unchanged.

Files updated (Margin=14 -> 16 or Margin=12 -> 16):
* WhereClauseDialog, FilterDialog, NameInputDialog,
  QueryServersFilterDialog
* BrowsePickerDialog, CertificateStoreDialog, ConnectDialog,
  EndpointPickerDialog, FindNodeDialog, FlattenedBrowseDialog,
  LocalePickerDialog, NodeSetExportDialog, RemoveItemDialog,
  ViewNodeStateDialog

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add runtime theme switching with three presets — Dark Navy (current
palette, default), Dark Standard (neutral grays), and Light (white
backgrounds). Persists the user''s choice across restarts.

Architecture:
* Themes/ folder with DarkNavy.axaml, DarkStandard.axaml, Light.axaml
  — each defines ~20 semantic brush keys (AppBg, PanelBg, SurfaceBg,
  PanelBorder, TextPrimary, TextSecondary, TextDim, AccentBlue,
  AccentGreen, AccentYellow, AccentRed, etc.).
* ThemeManager.cs — static helper that swaps the active
  ResourceDictionary at runtime and toggles Avalonia''s
  RequestedThemeVariant between Dark and Light so FluentTheme
  control chrome follows suit. Saves/loads the preference from
  %LOCALAPPDATA%/UaLens/theme.json.
* App.axaml loads the DarkNavy dictionary as the default
  MergedDictionary. App.axaml.cs calls ThemeManager.Initialize()
  on startup to restore the persisted preference.

Migration (69 files, 638 insertions / 555 deletions):
* All 52 .axaml files: replaced ~500 hardcoded hex color values
  (19 unique colors) with {DynamicResource KEY} references. Removed
  per-view local SolidColorBrush definitions that duplicated the
  app-level palette. Converted StaticResource -> DynamicResource.
* 12 .axaml.cs code-behind files: replaced Color.Parse("#..."),
  new SolidColorBrush(...), Brushes.Black/White with
  Application.Current.FindResource("KEY") lookups.
* Chart/plot data-driven colors (ScottPlot, AnimationCanvas) left
  hardcoded as they are data-series colors, not theme chrome.

View menu:
* Three radio-style MenuItems under View: Dark Navy, Dark Standard,
  Light. Initial IsChecked matches ThemeManager.Current; click
  calls ThemeManager.SetTheme(...).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
marcschier and others added 16 commits May 21, 2026 06:53
The FileSystemNodeManager''s NodeIdFactory (New()) reassigns each
method child''s NodeId from the well-known ns=0 MethodId to a custom
string NodeId (e.g. ns=X;s=D/path?DeleteFileSystemObject). When the
client sends a Call request with the well-known MethodId, FindMethod
checks method.NodeId == methodId || method.MethodDeclarationId ==
methodId — both fail because NodeId was reassigned and
MethodDeclarationId was never set, producing BadMethodInvalid /
BadNotImplemented.

Fix: set MethodDeclarationId = MethodIds.FileDirectoryType_* on
each DirectoryObjectState method (4: DeleteFileSystemObject,
CreateFile, CreateDirectory, MoveOrCopy) and MethodDeclarationId =
MethodIds.FileType_* on each FileObjectState method (6: Open,
Close, Read, Write, GetPosition, SetPosition) after Create().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…leanup

Addresses code-review findings on PR OPCFoundation#3766:

1. EventViewPlugin.AddSourceCoreAsync - when ApplyChangesAsync throws
   (transport error, session closed, ...), the source previously
   stayed in the panel with state ``pending`` forever because the
   exception bypasses mi.Status.SetCreateResult. Now we:
   - Add a sticky ``CreationFailure`` field on EventSourceVm that
     ``RefreshState()`` consults before the mi.Status checks, so the
     ``FAILED: <message>`` text survives subsequent publish /
     keep-alive refreshes.
   - Set ``source.CreationFailure`` in the catch block and call
     ``RefreshStatus()`` so the toolbar + source-row indicators
     update immediately.
   This lets users see WHY no events arrive and Remove the bad row
   instead of staring at a stuck "pending" state.

2. CertificateRequestDialog.OnBackAsync - remove the spurious
   ``await Task.CompletedTask.ConfigureAwait(false)`` no-op left
   over from when the method was async. Convert to a synchronous
   ``Task`` returner returning ``Task.CompletedTask``. Behaviour
   unchanged; one fewer async machine generated.

Two other review findings were investigated and confirmed as false
positives:
- ``RefreshSubscriptionStatus`` reading ``sub.MonitoredItems`` is
  already thread-safe; the SDK''s ``MonitoredItems`` getter returns
  a fresh snapshot under its internal cache lock.
- ``DiscoverEventTypeFieldsAsync`` ``ConfigureAwait(false)`` calls
  are safe because the method body is pure data manipulation; the
  caller awaits with ``ConfigureAwait(true)`` to marshal back to
  the UI thread.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New client SDK
==============

Libraries/Opc.Ua.Client/UserManagement/ -- full-fledged user-management
client mirroring the existing Role client shape, wrapping the source-
generated `Opc.Ua.UserManagementTypeClient` proxy emitted by
`Opc.Ua.SourceGeneration` for every standard ObjectType:

* `IUserManagementClient` interface
* `UserManagementClient` implementation rooted at
  `Objects.UserManagement` (NodeId `i=24290`); discovers the standard
  `ServerConfiguration.UserManagement` object and delegates to the
  generated proxy''s typed `AddUserAsync` / `ModifyUserAsync` /
  `RemoveUserAsync` / `ChangePasswordAsync` methods plus property
  reads for `PasswordLength` / `PasswordOptions` /
  `PasswordRestrictions`
* `UserManagementUser` DTO -- wraps `UserManagementDataType` with
  convenience accessors (IsActive, MustChangePassword, NoDelete,
  NoChangeByUser) decomposed from the raw `UserConfigurationMask`

This is the first client-side typed wrapper for Part 18 §5.2 user
management surface in the .NET Standard SDK.

UaLens plugins
==============

Two new tabs in `Applications/Opc.Ua.Lens/Plugins/`:

* `RoleManagement/` -- two-pane (list + 4 detail tabs) plugin over the
  existing `RoleManagementClient`. Add/Remove roles, identities,
  applications, endpoints; toggle ApplicationsExclude / EndpointsExclude
  / CustomConfiguration. Four create-dialogs (AddRole / AddIdentity /
  AddEndpoint / AddApplicationUri) + ConfirmDialog for destructive
  remove.
* `UserManagement/` -- single-pane DataGrid (UserName / State / Flags /
  Description) over the new `UserManagementClient`. Add / Modify /
  Remove / Change Password dialogs. Surfaces the server''s
  `PasswordRestrictions` text below the grid so users see length /
  options before entering credentials.

New PluginKind enum values + PluginRegistry entries with `🛂`
(Role Management) and `👤` (User Management) glyphs, both
theme-compliant (all colours via `{DynamicResource ...}`).

Both plugins auto-refresh when the connection state changes and grey
out when disconnected. UI awaits use `ConfigureAwait(true)`; SDK
awaits use `ConfigureAwait(false)` then marshal to UI thread via
`Dispatcher.UIThread.Post(...)` when mutating ObservableCollections.

Build verified: `dotnet build Opc.Ua.Client.csproj -c Release -f
net10.0` and `dotnet build Opc.Ua.Lens.csproj -c Release -f net10.0`
both clean (0 errors, 0 warnings on UaLens; 4 pre-existing warnings
on Opc.Ua.Client from master code).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The new RoleManagement and UserManagement plugin kinds were
registered in PluginRegistry but the Tabs -> Add+ menu (which is
declared statically in MainWindow.axaml rather than driven by the
registry) had no entries for them, so users could not reach the new
tabs.

Added:
* MenuItem `MenuNewRoleManagement` (Ctrl+Shift+R, glyph 🛂)
* MenuItem `MenuNewUserManagement` (Ctrl+Shift+U, glyph 👤)
Plus the matching Click handlers in MainWindow.axaml.cs and the new
shortcuts in the F1 Cheat sheet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…istry

The Tabs -> Add+ submenu was 11 hardcoded `<MenuItem>` entries in
MainWindow.axaml plus 11 matching Click handlers in code-behind.
The F1 cheat sheet duplicated the same 11 shortcut strings as a
multiline `string.Concat`. Adding a new plug-in required edits in
three places (registry + XAML + cheat sheet) and they routinely
drifted out of sync.

PluginRegistration now carries an optional `InputGesture` (e.g.
``"Ctrl+Shift+S"``). Three call sites consume it:

1. **Tabs -> Add+ submenu** -- MainWindow.axaml shrinks to a single
   placeholder ``<MenuItem x:Name="MenuTabsAdd" Header="_Add..." />``.
   ``PopulateTabsAddMenu`` (new helper) iterates ``PluginRegistry.All``
   and creates a `MenuItem` per entry with the correct Glyph icon,
   MenuHeader, InputGesture, Description tooltip, and Click handler.
   The Subscription kind keeps its special routing through
   ``MainViewModel.AddTabCommand``; everything else dispatches to
   ``AddPluginAsync(kind)``.

2. **Cheat sheet** -- the per-plug-in shortcut block under
   "Tabs (add)" is now built dynamically by iterating
   ``PluginRegistry.All`` and skipping entries without an
   `InputGesture`. New plug-ins appear automatically.

3. **Keyboard shortcuts** -- the manual ``OnKeyDown`` branch that
   matched 9 ``Ctrl+Shift+*`` combinations to specific menu fields
   is replaced by ``TryDispatchPluginShortcut(e)`` which parses each
   plug-in's ``InputGesture`` once per keypress and dispatches the
   matching kind through the same Subscription-or-AddPluginAsync
   logic.

Side-effect cleanups while in the area:
* ``PluginKind.EventView`` `MenuHeader` changed from ``"E_vents"``
  to ``"_Event View"`` and ``Performance`` from ``"_Performance"``
  to ``"Per_formance"`` to match the previous static MenuItem
  headers verbatim (the XAML diff would otherwise be visible to the
  user).
* Removed the dead ``menuAddTab`` / ``menuNew*`` local variables.

Build verified clean (`dotnet build -c Release -f net10.0` -> 0
errors, 0 warnings).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New "Subscription Bench" tab (📐, Ctrl+Shift+T) lets users stress-test
how many monitored items a server's subscription pipeline can sustain
with live throughput + resource + engine metrics.

New plug-in files (Applications/Opc.Ua.Lens/Plugins/SubscriptionBench/):

* SubscriptionBenchPlugin.cs - IPlugin view-model owning a per-tab
  classic Subscription (PublishingInterval=1000, KeepAliveCount=10,
  LifetimeCount=1000). Maintains a variable pool plus a 60-bucket
  1-second ring buffer for 1/10/30/60s rolling throughput averages.
  Slider changes diff against the current item count and dispatch
  a single batched ApplyChangesAsync (Subscription.AddItems +
  RemoveItem cycling pool[i % pool.Count]). Error counting reads
  mi.Status.Error after each apply + bumps a counter on bad
  status-change notifications.

* SubscriptionBenchView.axaml(.cs) - DockPanel toolbar + slider row
  + body grid with chart left, stats right (GridSplitter). AvaPlot
  hosts six DataLogger series (1s/10s/30s/60s throughput on the
  primary axis, CPU% / Mem MB on a secondary right axis via
  AddRightAxis()). 1 Hz DispatcherTimer rotates the bucket ring,
  computes the four rolling averages, samples
  ResourceMonitorHost.SampleNumeric(), polls engine metrics, and
  pushes one sample per DataLogger.

* VariablePoolPickerDialog.axaml(.cs) - BFS multi-pick variant of
  FlattenedBrowseDialog filtered to NodeClass.Variable, with a
  checkbox per row and a tri-state "Select all" header checkbox.

Registry wiring (single-line append now that the
Tabs->Add+ menu / cheat sheet / hot-key are registry-driven):

* IPlugin.cs - SubscriptionBench added to PluginKind enum
* PluginRegistry.cs - new PluginRegistration entry with
  InputGesture = "Ctrl+Shift+T"

Engine metrics surfaced:
* V2 (ManagedSession.SubscriptionManager): Count, CreatedCount,
  PublishWorkerCount, Good/BadPublishRequestCount,
  MissingMessageCount, RepublishMessageCount,
  Min/MaxPublishWorkerCount
* Classic: GoodPublishRequestCount (only public scalar exposed)

Theme compliant (all colours via {DynamicResource ...}); UI awaits
use ConfigureAwait(true), SDK awaits use ConfigureAwait(false) +
Dispatcher.UIThread.Post for ObservableCollection mutations.

Build: dotnet build -c Release -f net10.0 -> 0 errors, 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… not overlaid

Adding a tab in disconnected (start-up) mode rendered the plug-in
view in the disabled body BEHIND the Connect... placeholder, so the
placeholder text + button visibly overlapped the plug-in chrome.
Add IsVisible=IsConnected to the body grid alongside the existing
IsEnabled binding so the body is hidden (not just disabled) while
disconnected.  The placeholder StackPanel keeps its
IsVisible=!IsConnected so the two are mutually exclusive and there
is no overlap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an "Add to Subscription Bench..." entry to the address-space
tree's right-click menu so users can seed the Subscription Bench
plug-in directly from a node in the live tree.

- Variable nodes are appended as a single pool entry.
- Object / View / ObjectType / VariableType nodes trigger a recursive
  subtree walk that collects every variable descendant (mirroring the
  existing in-plugin "Pick Subtree..." command).

To avoid spawning a fresh bench tab on every right-click, the host
reuses the most recently created SubscriptionBench tab when one is
already open and just appends to its pool (focusing it).  A new tab
is only created when none exists or when the user opens one
explicitly via the Add+ menu.

The seeding never auto-starts the run -- the user keeps full control
of Run/Stop via the toolbar so the pool can grow across multiple
context-menu invocations before the bench starts streaming.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a uniform connection-state notification hook so every IPlugin can
refresh its own command CanExecute predicates and cached state when
the host's ConnectionService transitions between connected and
disconnected -- without each plug-in having to wire its own
StateChanged subscribe / unsubscribe pair.

Fixes the regression where opening a Subscription Bench tab while
disconnected, then connecting, leaves the Run button stuck in the
disabled state because CanRun's m_host.Connection.Session is not null
check returns true on the next evaluation but RelayCommand never gets
NotifyCanExecuteChanged() invoked.

Infrastructure
- IPlugin.OnConnectionStateChanged(): new default-no-op method,
  documented to always fire on the UI thread.
- MainViewModel: adds a second subscriber on Connection.StateChanged
  alongside the existing SyncFromConnection, fans out to every tab in
  a snapshot of Tabs, swallows per-tab exceptions with a Warning log
  so one misbehaving plug-in cannot break the others.

New overrides (plug-ins that previously had no reaction)
- SubscriptionBench: notifies Run / Stop / PickVariables / PickSubtree
  CanExecuteChanged. Direct fix for the user-reported bug.
- Performance: notifies Run / Stop CanExecuteChanged.
- Historian: notifies Read CanExecuteChanged. Preserves cached read
  results across disconnect so the user can still inspect the last
  successful read.
- EventView: if the tab was opened while disconnected, lazily creates
  the event subscription on first connect so subsequent AddSource calls
  no longer stall on m_subscriptionReady. Mid-session reconnect with a
  stale TCS is a pre-existing limitation tracked separately.
- FileSystem: lazily attaches Server.FileSystem on first connect when
  opened disconnected; clears Roots on disconnect because the
  FileSystemClient handles hang off the dead session.

Migrations (move existing bespoke subscribers onto the hook)
- UserManagement, RoleManagement, GdsManagement, GdsPush all migrated
  from Connection.StateChanged += in ctor + -= in DisposeAsync to
  overriding OnConnectionStateChanged. Inner Dispatcher.UIThread.Post
  wrappers removed (the central fan-out already marshals to UI thread).

Build clean (0/0); the Lens project has no separate test suite, so
verification is the compile-time wiring + the unchanged behaviour of
the four migrated plug-ins.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The bench chart already plots six colour-coded series (1s/10s/30s/60s
value rates plus CPU % and Memory MB) but with no legend the
colour-to-meaning mapping was buried in source comments.

Extend AddLogger with a label argument, set DataLogger.LegendText for
each series, style the legend to match ScottPlotPump.ApplyDarkTheme
(background = figBg, font = Cascadia Mono, text = s_text, outline =
s_dim), and call plot.ShowLegend() so the legend is on by default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a Subscriptions slider on top of the existing items-per-sub
slider so the bench can stress *both* dimensions of the subscription
pipeline. Both sliders are live without needing a Run press, and
moving either one drives a single idempotent ConvergeAsync() that
brings the live topology (m_subscriptions + per-sub item lists) in
line with the current targets. Shrinking either slider triggers a
clean tear-down of the trailing subs / items so server-side
state stays in sync.

Toolbar / lifecycle
- Run button removed: sliders auto-create on movement; the
  aggregation timer starts on connect so the chart shows a live
  0 val/s line before the user touches anything.
- Stop kept as an emergency reset that zeros both sliders, waits
  for the resulting converge to tear every sub down, then clears
  counters.
- OnConnectionStateChanged: mirrors session state onto IsConnected
  (drives CanResize + Stop CanExecute), refreshes server-cap
  limits on first connect (MaxMonitoredItemsPerSubscription +
  MaxSubscriptionsPerSession), starts the aggregation timer.
  Disconnect zeros both sliders and drops every local sub
  reference because the server-side handles die with the session.

Convergence model
- One SemaphoreSlim m_resizeLock serialises every ConvergeAsync;
  the targets are re-read inside the lock so the latest slider
  values are always honoured (fixes the previous
  m_applyInFlight-swallow that could drop mid-drag updates).
- ConvergeSubscriptionsAsync: grows by CreateAsync + AddSubscription
  per new sub, shrinks by tail DeleteAsync + Dispose.
- ConvergeItemsAsync: per sub, adds / removes monitored items to
  match the target, ApplyChangesAsync per affected sub.
- Each sub independently fills slots 0..N-1 from pool[i % pool.Count]
  so subs with the same target end up with the same item set --
  the simplest and most predictable layout for a scale test.

XAML
- Subscriptions slider row added above the existing Items row.
- Both sliders IsEnabled bound to CanResize
  (connected && PoolCount > 0).
- Run button removed; Stop tooltip updated.

EngineMetricsText now shows
  Subscriptions     : N (xM items)
  Sub ids           : 1, 2, 3, ... (truncated past 4)
followed by the existing session / SubscriptionManager counters.

DisposeAsync iterates every live sub and deletes + disposes,
matching the new state model.

Build clean (0 errors); manual smoke requires a GUI session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Plumb the existing SubscriptionSettingsDialog into the bench and add
a new generic MonitoredItemSettingsDialog so the user can tune both
the per-subscription parameters (publishing interval, keep-alive,
lifetime, max notifications, priority, publishing enabled, pipeline
depth) and the per-item defaults (sampling interval, queue size,
discard oldest, monitoring mode, optional DataChangeFilter) without
having to rebuild from scratch.

Plug-in
- Two new fields hold the current configs: m_subConfig and
  m_itemSettings. ConvergeSubscriptionsAsync /
  ConvergeItemsAsync read them when creating new subs / items so
  subsequent slider grows pick them up automatically.
- EditSubscriptionCommand opens SubscriptionSettingsDialog with
  the current m_subConfig and engineHasWorkerPool flag derived
  from the host's Connection.Engine; on OK, sets the props on
  every live ClassicSubscription and awaits ModifyAsync (mirrors
  ClassicEngineAdapter.ApplySubscriptionAsync).
- EditItemSettingsCommand opens the new MonitoredItemSettingsDialog
  with the current m_itemSettings; on OK, walks every live item,
  assigns SamplingInterval / QueueSize / DiscardOldest / Filter,
  then awaits ApplyChangesAsync per sub (the AttributesModified
  flag drives a ModifyMonitoredItems wire request). Monitoring
  mode changes flow through the dedicated SetMonitoringModeAsync
  per sub when (and only when) the mode actually changed.
- Both apply paths share m_resizeLock with Converge so they
  never race a slider-driven grow / shrink.

Dialog
- MonitoredItemSettingsDialog mirrors AddItemDialog's layout but
  drops the node-specific bits (NodeId, AttributeId, mode/value
  vs event split) since this is a defaults editor.  Returns a
  small new record MonitoredItemSettings defined in the same file.

XAML
- Two new toolbar buttons next to Stop: "⚙ Subscription…" and
  "⚙ Item settings…". Both gated on IsConnected via
  NotifyCanExecuteChangedFor on the IsConnected property.
- Same entries added to the per-kind menu contribution.

Build clean (0 errors); manual smoke needs a GUI session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a Subscription Bench tab is opened on an already-connected
session, no Connection.StateChanged event fires (because the state
didn't change), so the central IPlugin.OnConnectionStateChanged
fan-out from MainViewModel never wakes the plug-in.  The bench's
IsConnected field stayed at its default false, leaving CanResize
false and both sliders permanently disabled -- with the Run button
gone, the bench was inoperable for users who opened the tab while
already connected.

Fix: call OnConnectionStateChanged() once at the end of the bench
ctor so we mirror the current host state immediately.  The hook
body is already idempotent (m_serverLimitsRefreshed guards the
ServerCapabilities read; StartAggregationTimer disposes the old
timer before creating a fresh one) so a subsequent fan-out call
on the next real transition is harmless.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause for "Cumulative values stays at 0" reported via the metrics
panel: the bench was creating classic Opc.Ua.Client.Subscription
instances and registering them via session.AddSubscription(sub).
On the V2 (channel) subscription engine -- UaLens's default --
publish responses are dispatched only to V2 ISubscriptions added via
session.SubscriptionManager.Add(handler, options).  Classic-style
subscriptions on a V2 session are created server-side but never
delivered notifications client-side, so the bench's
FastDataChangeCallback never fired and the totals stayed at zero.

Rewrite the bench to use the V2 API throughout:

- m_subscriptions  : List<ISubscription>            (V2)
- m_liveItems      : List<List<BenchItem>>          BenchItem = IMonitoredItem + per-item OptionsMonitor
- m_sharedSubOpts  : one OptionsMonitor<V2SubscriptionOptions>
                     shared across every live sub -- editing the
                     subscription parameters is now a single
                     CurrentValue assignment and every sub re-applies
                     on the next dispatch cycle.
- BenchHandler     : ISubscriptionNotificationHandler that increments
                     m_totalValues / m_bucketsPerSec / m_totalErrors on
                     every data-change notification.  Events + keep-
                     alives are no-ops (bench is value-only).
- ConvergeSubscriptionsAsync now does
  session.SubscriptionManager.Add(handler, opts) on grow, and
  ISubscription.DisposeAsync on shrink (V2 is IAsyncDisposable).
  Surfaces a clear status message when the user is on the classic
  engine ("requires V2 engine -- switch via Connection > Engine").
- ConvergeItemsAsync uses Subscription.MonitoredItems.TryAdd /
  TryRemove(clientHandle) and never calls ApplyChangesAsync itself
  (the V2 dispatcher handles modify-monitored-items batching).
- EditSubscriptionAsync flips m_sharedSubOpts.CurrentValue.
- EditItemSettingsAsync walks every BenchItem and updates each
  item's per-item OptionsMonitor (StartNodeId preserved per item).
- DisposeAsync iterates m_subscriptions and awaits DisposeAsync on
  each (deletes server-side state).
- Drops the classic FastDataChangeCallback / FastKeepAliveCallback
  paths and the "Sub ids" line from the engine metrics text (user
  reported the list was noise and not actionable).

Cumulative values now increments on every data-change notification.
Build clean (0 warnings / 0 errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@marcschier marcschier marked this pull request as draft May 22, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants