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