docs(secured-writes): implementation plan + task persistence
This commit is contained in:
@@ -0,0 +1,553 @@
|
|||||||
|
# Secured Writes — Tag Selector + Data-Type Auto-Fill Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** On `/operations/secured-writes`, back the Tag path with the existing tag browser and auto-fill the Data type from the selected MxGateway tag.
|
||||||
|
|
||||||
|
**Architecture:** Plumb the MxGateway gateway's already-returned per-attribute `data_type_name` through the DCL browse seam into `BrowseNode.DataType` (currently dropped); make the shared `NodeBrowserDialog` emit the full selected node additively; wire a Browse button + a best-effort Galaxy-type→`DataType` mapper into the page. No new gRPC, DB/schema/migration, or message contracts.
|
||||||
|
|
||||||
|
**Tech Stack:** C#/.NET, Akka.NET (untouched here), Blazor Server (Bootstrap), bUnit + xUnit + NSubstitute for tests.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-06-27-secured-writes-tag-selector-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task ordering & parallelism
|
||||||
|
|
||||||
|
- **Tasks 1, 2, 3, 5** are mutually independent (different files) — dispatchable in parallel.
|
||||||
|
- **Task 4** depends on Tasks 1, 2, 3 (needs the dialog callback, the mapper, and the surfaced DataType).
|
||||||
|
- **Task 6** (verify) depends on everything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Surface `DataType` through the MxGateway browse seam (DCL)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 2, Task 3, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs:21`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs:263-270`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:269-271`
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs` (add one test after line 246)
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add after the existing `BrowseChildrenAsync_maps_children_and_truncated` test (after line 246) in `MxGatewayDataConnectionTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task BrowseChildrenAsync_surfaces_attribute_datatype()
|
||||||
|
{
|
||||||
|
var fake = new FakeMxGatewayClient
|
||||||
|
{
|
||||||
|
BrowseHandler = _ => (new List<MxBrowseChild>
|
||||||
|
{
|
||||||
|
new("Area1.Pump.Speed", "Speed", BrowseNodeClass.Variable, false, "Double"),
|
||||||
|
}, false)
|
||||||
|
};
|
||||||
|
var adapter = NewAdapter(fake);
|
||||||
|
await adapter.ConnectAsync(Details());
|
||||||
|
|
||||||
|
var result = await adapter.BrowseChildrenAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("Double", result.Children[0].DataType);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests --filter "FullyQualifiedName~BrowseChildrenAsync_surfaces_attribute_datatype"`
|
||||||
|
Expected: FAIL — compile error (the 5-arg `MxBrowseChild` ctor does not exist yet).
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
(a) `IMxGatewayClient.cs:21` — add the additive field:
|
||||||
|
```csharp
|
||||||
|
public record MxBrowseChild(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren, string? DataType = null);
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) `MxGatewayDataConnection.cs:269-271` — forward the new field:
|
||||||
|
```csharp
|
||||||
|
var nodes = children
|
||||||
|
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren, DataType: c.DataType))
|
||||||
|
.ToList();
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) `RealMxGatewayClient.cs:265-270` — pass the gateway's Galaxy type name (empty → null) when projecting attributes:
|
||||||
|
```csharp
|
||||||
|
children.Add(new MxBrowseChild(
|
||||||
|
attr.FullTagReference,
|
||||||
|
attr.AttributeName,
|
||||||
|
BrowseNodeClass.Variable,
|
||||||
|
false,
|
||||||
|
string.IsNullOrEmpty(attr.DataTypeName) ? null : attr.DataTypeName));
|
||||||
|
```
|
||||||
|
|
||||||
|
(The object loop at lines 252-256 is left unchanged — objects are not leaves and carry no type.)
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests --filter "FullyQualifiedName~BrowseChildrenAsync"`
|
||||||
|
Expected: PASS (both the new test and the existing `_maps_children_and_truncated`, whose 4-arg ctors stay valid because `DataType` defaults to null).
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs \
|
||||||
|
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs \
|
||||||
|
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs \
|
||||||
|
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
|
||||||
|
git commit -m "feat(dcl): surface MxGateway attribute DataType through browse seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Galaxy-type → `DataType` mapper (CentralUI)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 1, Task 3, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWriteDataTypeMapper.cs`
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs` (create)
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Operations;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||||
|
|
||||||
|
public class SecuredWriteDataTypeMapperTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Boolean", DataType.Boolean)]
|
||||||
|
[InlineData("bool", DataType.Boolean)]
|
||||||
|
[InlineData("Integer", DataType.Int32)]
|
||||||
|
[InlineData("int", DataType.Int32)]
|
||||||
|
[InlineData("Int32", DataType.Int32)]
|
||||||
|
[InlineData("Float", DataType.Float)]
|
||||||
|
[InlineData("Double", DataType.Double)]
|
||||||
|
[InlineData("String", DataType.String)]
|
||||||
|
[InlineData("Time", DataType.DateTime)]
|
||||||
|
[InlineData(" double ", DataType.Double)] // trimmed + case-insensitive
|
||||||
|
public void TryMap_known_galaxy_types_map(string galaxy, DataType expected)
|
||||||
|
{
|
||||||
|
Assert.True(SecuredWriteDataTypeMapper.TryMap(galaxy, out var dt));
|
||||||
|
Assert.Equal(expected, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("ElapsedTime")]
|
||||||
|
[InlineData("SomeVendorType")]
|
||||||
|
public void TryMap_unknown_or_blank_returns_false(string? galaxy)
|
||||||
|
{
|
||||||
|
Assert.False(SecuredWriteDataTypeMapper.TryMap(galaxy, out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SecuredWriteDataTypeMapperTests"`
|
||||||
|
Expected: FAIL — compile error (`SecuredWriteDataTypeMapper` does not exist).
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWriteDataTypeMapper.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Operations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort map from the MxGateway/Galaxy free-form attribute type name
|
||||||
|
/// (<c>GalaxyAttribute.data_type_name</c>, surfaced on <c>BrowseNode.DataType</c>)
|
||||||
|
/// to a ScadaBridge <see cref="DataType"/>. Used by the Secured Writes page to
|
||||||
|
/// pre-fill the data-type dropdown when a tag is picked. Unknown or blank names
|
||||||
|
/// return false so the operator's existing selection is left untouched. Galaxy
|
||||||
|
/// type names are free-form text, not a stable enum — keep this tolerant.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SecuredWriteDataTypeMapper
|
||||||
|
{
|
||||||
|
public static bool TryMap(string? galaxyTypeName, out DataType dataType)
|
||||||
|
{
|
||||||
|
dataType = default;
|
||||||
|
if (string.IsNullOrWhiteSpace(galaxyTypeName)) return false;
|
||||||
|
switch (galaxyTypeName.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "boolean": case "bool": dataType = DataType.Boolean; return true;
|
||||||
|
case "integer": case "int": case "int32": dataType = DataType.Int32; return true;
|
||||||
|
case "float": dataType = DataType.Float; return true;
|
||||||
|
case "double": dataType = DataType.Double; return true;
|
||||||
|
case "string": dataType = DataType.String; return true;
|
||||||
|
case "time": dataType = DataType.DateTime; return true;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SecuredWriteDataTypeMapperTests"`
|
||||||
|
Expected: PASS (14 cases).
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWriteDataTypeMapper.cs \
|
||||||
|
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWriteDataTypeMapperTests.cs
|
||||||
|
git commit -m "feat(central-ui): add Galaxy-type to DataType mapper for secured writes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `NodeBrowserDialog` — additive `OnNodeSelected` + `ShowSearch` (CentralUI)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 1, Task 2, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor` (params, search-box gate, selected-node tracking, Confirm emit)
|
||||||
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs` (create)
|
||||||
|
|
||||||
|
**Background (do not skip):** Selection is confirmed in `Confirm()` (the footer "Select" button), which fires `OnSelected(_selectedNodeId)`. `Select`/`SelectBrowseNode`/`UseManual` only set `_selectedNodeId`. So the new `OnNodeSelected` must fire from `Confirm()`, and the dialog must track the selected node (for its `DataType`).
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs` (mirrors the existing `NodeBrowserDialogSearchTests` harness; reuses the search path to select a typed node):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||||
|
|
||||||
|
public class NodeBrowserDialogSelectionTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
|
||||||
|
|
||||||
|
public NodeBrowserDialogSelectionTests()
|
||||||
|
{
|
||||||
|
Services.AddSingleton(_browse);
|
||||||
|
_browse.BrowseChildrenAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new BrowseNodeResult(Array.Empty<BrowseNode>(), Truncated: false, Failure: null));
|
||||||
|
_browse.SearchAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new SearchAddressSpaceResult(
|
||||||
|
Matches: new[]
|
||||||
|
{
|
||||||
|
new AddressSpaceMatch(
|
||||||
|
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
|
||||||
|
Path: "Devices/Pump1/Speed"),
|
||||||
|
},
|
||||||
|
CapReached: false, Failure: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_emits_full_node_with_datatype_on_OnNodeSelected()
|
||||||
|
{
|
||||||
|
BrowseNode? captured = null;
|
||||||
|
var cut = Render<NodeBrowserDialog>(p => p
|
||||||
|
.Add(c => c.SiteId, "plant-a")
|
||||||
|
.Add(c => c.ConnectionName, "PLC-OPC")
|
||||||
|
.Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(this, n => captured = n)));
|
||||||
|
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||||
|
cut.Find("[data-test=node-search-button]").Click();
|
||||||
|
cut.FindAll("[data-test=node-search-result] a")[0].Click();
|
||||||
|
cut.Find(".modal-footer .btn-primary").Click();
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal("ns=2;s=Pump1.Speed", captured!.NodeId);
|
||||||
|
Assert.Equal("Double", captured.DataType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowSearch_false_hides_search_box()
|
||||||
|
{
|
||||||
|
var cut = Render<NodeBrowserDialog>(p => p
|
||||||
|
.Add(c => c.SiteId, "plant-a")
|
||||||
|
.Add(c => c.ConnectionName, "PLC-MX")
|
||||||
|
.Add(c => c.ShowSearch, false));
|
||||||
|
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-MX", null));
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
Assert.Empty(cut.FindAll("[data-test=node-search-input]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~NodeBrowserDialogSelectionTests"`
|
||||||
|
Expected: FAIL — `OnNodeSelected` / `ShowSearch` parameters do not exist yet (compile error).
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation** — edit `NodeBrowserDialog.razor`:
|
||||||
|
|
||||||
|
(a) Gate the search input-group. Wrap lines 25-31 (the `<div class="input-group mb-3">…</div>` containing `node-search-input`/`node-search-button`) in:
|
||||||
|
```razor
|
||||||
|
@if (ShowSearch)
|
||||||
|
{
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input class="form-control" data-test="node-search-input"
|
||||||
|
@bind="_searchQuery" @bind:event="oninput"
|
||||||
|
@onkeydown="SearchOnEnter"
|
||||||
|
placeholder="Search the address space by name or path…" />
|
||||||
|
<button class="btn btn-outline-primary" data-test="node-search-button" @onclick="SearchAsync">Search</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Add parameters after line 114 (`[Parameter] public EventCallback OnCancelled …`):
|
||||||
|
```csharp
|
||||||
|
/// <summary>Additive: receives the full selected <see cref="BrowseNode"/> (incl.
|
||||||
|
/// DataType) on confirm, alongside the existing string-id <see cref="OnSelected"/>.
|
||||||
|
/// Optional — existing callers that only wire OnSelected are unaffected.</summary>
|
||||||
|
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When false, hides the address-space search box. Set false for protocols
|
||||||
|
/// that don't implement IAddressSpaceSearchable (e.g. MxGateway), where search would
|
||||||
|
/// always fail. Default true preserves OPC UA behaviour.</summary>
|
||||||
|
[Parameter] public bool ShowSearch { get; set; } = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Add a tracking field next to `_selectedNodeId` (after line 117):
|
||||||
|
```csharp
|
||||||
|
private BrowseNode? _selectedNode;
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) `TreeNode` — add a source reference (after the `DataType` property, ~line 149):
|
||||||
|
```csharp
|
||||||
|
/// <summary>The originating BrowseNode, retained so selection can emit the
|
||||||
|
/// full node (incl. DataType) via OnNodeSelected.</summary>
|
||||||
|
public BrowseNode? Source { get; init; }
|
||||||
|
```
|
||||||
|
|
||||||
|
(e) `ToTreeNode` (lines 197-198) — capture the source:
|
||||||
|
```csharp
|
||||||
|
private static TreeNode ToTreeNode(BrowseNode c) =>
|
||||||
|
new(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren) { DataType = c.DataType, Source = c };
|
||||||
|
```
|
||||||
|
|
||||||
|
(f) `ShowAsync` — reset the tracker. After `_selectedNodeId = initialNodeId;` (line 176) add:
|
||||||
|
```csharp
|
||||||
|
_selectedNode = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
(g) `SelectBrowseNode` (line 309-314) — track the node. After `_manualNodeId = node.NodeId;` add:
|
||||||
|
```csharp
|
||||||
|
_selectedNode = node;
|
||||||
|
```
|
||||||
|
|
||||||
|
(h) `Select` (line 316-321) — track via the tree node's source. After `_manualNodeId = node.NodeId;` add:
|
||||||
|
```csharp
|
||||||
|
_selectedNode = node.Source;
|
||||||
|
```
|
||||||
|
|
||||||
|
(i) `UseManual` (line 349-352) — manual entry has no known type. After `_selectedNodeId = _manualNodeId.Trim();` add:
|
||||||
|
```csharp
|
||||||
|
_selectedNode = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
(j) `Confirm` (lines 354-359) — also emit the full node:
|
||||||
|
```csharp
|
||||||
|
private async Task Confirm()
|
||||||
|
{
|
||||||
|
_isVisible = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
|
||||||
|
{
|
||||||
|
await OnSelected.InvokeAsync(_selectedNodeId!);
|
||||||
|
if (OnNodeSelected.HasDelegate)
|
||||||
|
await OnNodeSelected.InvokeAsync(
|
||||||
|
_selectedNode ?? new BrowseNode(_selectedNodeId!, _selectedNodeId!, BrowseNodeClass.Variable, HasChildren: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~NodeBrowserDialog"`
|
||||||
|
Expected: PASS — both the new `NodeBrowserDialogSelectionTests` and the existing `NodeBrowserDialogSearchTests` (search box still present by default; OnSelected still fires).
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor \
|
||||||
|
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
|
||||||
|
git commit -m "feat(central-ui): NodeBrowserDialog emits full node + optional ShowSearch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Wire the Browse button + data-type auto-fill into the page (CentralUI)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Depends on:** Task 1, Task 2, Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor`
|
||||||
|
|
||||||
|
No new automated test: the auto-fill logic is unit-tested in Task 2 and the dialog emit in Task 3; the page itself is gated behind `AuthorizeView`/policies with several injected services, so it is verified by build (this task) + live-smoke (Task 6). Do NOT add a heavyweight bUnit page harness.
|
||||||
|
|
||||||
|
**Step 1: Add usings** — after line 7 (`@using …Commons.Types.Enums`) add:
|
||||||
|
```razor
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Replace the Tag path field (lines 59-63)** with an input-group + Browse button:
|
||||||
|
```razor
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label form-label-sm">Tag path</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input class="form-control form-control-sm" @bind="_formTagPath"
|
||||||
|
placeholder="e.g. PLC1.Pump.Setpoint" data-test="secured-write-tagpath" />
|
||||||
|
<button class="btn btn-outline-secondary" type="button"
|
||||||
|
disabled="@(!CanBrowse)"
|
||||||
|
title="@(CanBrowse ? "Browse tags" : "Pick a site and MxGateway connection first")"
|
||||||
|
@onclick="OpenBrowser" data-test="secured-write-browse">Browse…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Render the dialog once** — insert between line 91 (`</div>` closing the `mt-3` submit-button div) and line 92 (`</div>` closing `card-body`), inside the operator `Authorized` region:
|
||||||
|
```razor
|
||||||
|
<NodeBrowserDialog @ref="_browserRef"
|
||||||
|
SiteId="@_formSiteIdentifier"
|
||||||
|
ConnectionName="@_formConnectionName"
|
||||||
|
ShowSearch="false"
|
||||||
|
OnNodeSelected="OnTagPicked" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add the `@code` members** (place with the other submit-form fields, e.g. just after `_formComment` around line 227):
|
||||||
|
```csharp
|
||||||
|
private NodeBrowserDialog? _browserRef;
|
||||||
|
|
||||||
|
private bool CanBrowse =>
|
||||||
|
!string.IsNullOrWhiteSpace(_formSiteIdentifier)
|
||||||
|
&& !string.IsNullOrWhiteSpace(_formConnectionName);
|
||||||
|
|
||||||
|
private async Task OpenBrowser()
|
||||||
|
{
|
||||||
|
if (!CanBrowse || _browserRef is null) return;
|
||||||
|
await _browserRef.ShowAsync(_formSiteIdentifier, _formConnectionName, _formTagPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTagPicked(BrowseNode node)
|
||||||
|
{
|
||||||
|
_formTagPath = node.NodeId;
|
||||||
|
if (SecuredWriteDataTypeMapper.TryMap(node.DataType, out var dt))
|
||||||
|
_formValueType = dt.ToString();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`SecuredWriteDataTypeMapper` is in the same `…Pages.Operations` namespace — no using needed.)
|
||||||
|
|
||||||
|
**Step 5: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor
|
||||||
|
git commit -m "feat(central-ui): tag selector + data-type auto-fill on Secured Writes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update component design docs
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 1, Task 2, Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/requirements/Component-Security.md` (Secured Writes section)
|
||||||
|
- Modify: `docs/requirements/Component-DataConnectionLayer.md` (MxGateway browse type-info)
|
||||||
|
|
||||||
|
**Step 1: Security doc.** Find the Secured Writes section: `grep -n "Secured Write" docs/requirements/Component-Security.md`. Add a concise sentence to the operator-submit description: the Tag path is backed by the shared tag browser (`NodeBrowserDialog`, search hidden for MxGateway), and the Data type is auto-filled (best-effort, still editable) from the selected tag's Galaxy type via `SecuredWriteDataTypeMapper`.
|
||||||
|
|
||||||
|
**Step 2: DCL doc.** Find the browse type-info note: `grep -n "DataType\|type-info\|BrowseNode\|MxGateway" docs/requirements/Component-DataConnectionLayer.md`. Update the type-info note so it states that `BrowseNode.DataType` is now surfaced for **MxGateway** too (from `GalaxyAttribute.data_type_name`), not OPC-UA-only. Note `ValueRank`/`Writable` remain OPC-UA-only.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/requirements/Component-Security.md docs/requirements/Component-DataConnectionLayer.md
|
||||||
|
git commit -m "docs: secured-writes tag selector + MxGateway browse DataType"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Build, targeted tests, and live-smoke
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~6 min (+ image rebuild)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Depends on:** Task 1, Task 2, Task 3, Task 4, Task 5
|
||||||
|
|
||||||
|
**Step 1: Build the touched projects**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
|
||||||
|
dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj
|
||||||
|
```
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
**Step 2: Run targeted tests** (the touched test projects only — per the targeted-tests convention, not the full suite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests --filter "FullyQualifiedName~MxGatewayDataConnectionTests"
|
||||||
|
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SecuredWriteDataTypeMapperTests|FullyQualifiedName~NodeBrowserDialog"
|
||||||
|
```
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
**Step 3: Rebuild the cluster image and live-smoke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker/deploy.sh
|
||||||
|
```
|
||||||
|
Then in the browser (Traefik LB at `http://localhost:9000`), as a user holding the Operator role, open `/operations/secured-writes`:
|
||||||
|
- Pick a site + MxGateway connection → the **Browse…** button enables.
|
||||||
|
- Click Browse → the dialog opens with **no search box**; expand an object, pick an attribute → the Tag path fills with the tag reference and the Data type pre-selects the mapped type (still editable).
|
||||||
|
- Tag path remains hand-editable; Submit still works.
|
||||||
|
|
||||||
|
**Step 4: Final commit (if any smoke-fix needed)** — otherwise nothing to commit. Report results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
- DCL test surfaces `BrowseNode.DataType` for MxGateway; existing browse test still green.
|
||||||
|
- Mapper unit tests green (known types map; unknown/blank → false).
|
||||||
|
- Dialog test: `OnNodeSelected` emits the typed node; `ShowSearch=false` hides search; existing dialog tests green.
|
||||||
|
- CentralUI + DCL build clean.
|
||||||
|
- Live-smoke: Browse populates tag path and auto-fills data type on an MxGateway connection.
|
||||||
|
- Design docs updated.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-27-secured-writes-tag-selector.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Surface DataType through MxGateway browse seam (DCL)", "status": "pending", "classification": "standard", "parallelizableWith": [2, 3, 5]},
|
||||||
|
{"id": 2, "subject": "Task 2: Galaxy-type to DataType mapper (CentralUI)", "status": "pending", "classification": "small", "parallelizableWith": [1, 3, 5]},
|
||||||
|
{"id": 3, "subject": "Task 3: NodeBrowserDialog additive OnNodeSelected + ShowSearch (CentralUI)", "status": "pending", "classification": "standard", "parallelizableWith": [1, 2, 5]},
|
||||||
|
{"id": 4, "subject": "Task 4: Wire Browse button + data-type auto-fill into Secured Writes page", "status": "pending", "classification": "standard", "blockedBy": [1, 2, 3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Update component design docs", "status": "pending", "classification": "trivial", "parallelizableWith": [1, 2, 3]},
|
||||||
|
{"id": 6, "subject": "Task 6: Build, targeted tests, live-smoke", "status": "pending", "classification": "standard", "blockedBy": [1, 2, 3, 4, 5]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-27"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user