Merge pull request 'v3 Batch 2 — /raw project-tree AdminUI + Calculation driver' (#470) from v3/batch2-raw-ui into master
This commit was merged in pull request #470.
This commit is contained in:
@@ -215,6 +215,8 @@ Address pickers in AdminUI support live browse for OpcUaClient and Galaxy driver
|
||||
|
||||
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
|
||||
|
||||
**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor` → `RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`.
|
||||
|
||||
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
||||
|
||||
## Scripting / Script Editor
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
@@ -81,6 +82,7 @@
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# The Raw project tree (`/raw`)
|
||||
|
||||
v3 authors device I/O in a **Raw project tree** — a cluster-rooted, Kepware-style hierarchy
|
||||
that is the single surface for driver/device/tag authoring. It is the peer of the `/uns`
|
||||
unified-namespace page; the two OPC UA namespaces (`ns=Raw` / `ns=UNS`) light up in Batch 4.
|
||||
|
||||
```
|
||||
Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag
|
||||
```
|
||||
|
||||
Every node's identity is its **RawPath** (`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`,
|
||||
cluster-scoped, `/`-separated, ordinal). Names are validated as RawPath segments (no `/`, no
|
||||
leading/trailing whitespace) at authoring and at the deploy gate.
|
||||
|
||||
## The tree
|
||||
|
||||
`/raw` (`GlobalRaw.razor` → `RawTree.razor`, backed by `IRawTreeService`) lazily loads each
|
||||
container level on expand (unlike `/uns`, which eager-loads). Each node has a right-click
|
||||
context menu **and** a `⋯` fallback (the reusable `ContextMenu` component):
|
||||
|
||||
| Node | Actions |
|
||||
|---|---|
|
||||
| Cluster | New folder · New driver |
|
||||
| Folder | New folder · New driver · Rename · Delete |
|
||||
| Driver | Configure · New device · Enable/Disable · Rename · Delete |
|
||||
| Device | Configure (endpoint + **Test connect**) · New tag-group · Add tags ▸ · Delete |
|
||||
| TagGroup | New group · Add tags ▸ · Rename · Delete |
|
||||
| Tag | Edit · Delete |
|
||||
|
||||
Deletes are blocked while a node has children (or, for a raw tag, while a `UnsTagReference`
|
||||
points at it — the error names the referencing equipment). Renames return non-blocking
|
||||
**warnings**: renaming a node changes the RawPath of every historized / UNS-referenced tag
|
||||
beneath it (its historian tagname / UNS projection path moves).
|
||||
|
||||
## Endpoint → DeviceConfig (the channel/device split)
|
||||
|
||||
v3 moves the **connection endpoint** off the driver and onto the **Device** (`DeviceConfig`),
|
||||
mirroring Kepware's channel/device split. The driver's `DriverConfig` holds protocol/channel
|
||||
settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe time
|
||||
`DriverDeviceConfigMerger` merges the device's endpoint up into the driver config, and Test
|
||||
connect (inside the **Device** modal) probes that merged config. Driver forms no longer
|
||||
serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
|
||||
|
||||
## Authoring tags
|
||||
|
||||
**Add tags ▸** on a Device or TagGroup offers:
|
||||
|
||||
- **Manual entry** — a grid of Name / DataType / AccessLevel / WriteIdempotent / PollGroup +
|
||||
the driver-typed `TagConfig` editor per row.
|
||||
- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row
|
||||
verdicts → commit) over an RFC-4180 parser. Columns: the common set
|
||||
(`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
|
||||
HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed
|
||||
columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath`
|
||||
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
|
||||
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
|
||||
Export round-trips the same shape.
|
||||
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
|
||||
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
|
||||
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
|
||||
target, with the browsed reference written into the driver-typed `TagConfig` **address
|
||||
field** (`nodeId` / `tagPath` / …), never an identity key. An opt-in "create matching
|
||||
tag-groups" toggle mirrors the browse folder nesting. The browser dials a real ephemeral
|
||||
driver against the **merged** Driver+Device config.
|
||||
|
||||
## The `Calculation` driver
|
||||
|
||||
Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driver
|
||||
(`DriverTypeNames.Calculation`), computed by C# scripts that read other tags' live values via
|
||||
`ctx.GetTag("<RawPath>")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for
|
||||
the full design. Highlights:
|
||||
|
||||
- One auto-created default `Engine` device; per-tag `TagConfig` is
|
||||
`{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`.
|
||||
- The host feeds dependency values via the new `IDependencyConsumer` capability + a
|
||||
`DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work
|
||||
because a calc output re-enters the mux. The driver rebuilds its tag/dependency table on
|
||||
every redeploy (`ReinitializeAsync`) and re-registers its refs after the delta applies.
|
||||
- **Deploy gates** (`DraftValidator`): `scriptId` existence, and a hard **cycle gate**
|
||||
(`DependencyGraph.DetectCycles` over calc→calc edges; cross-driver refs are terminal) that
|
||||
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
|
||||
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
|
||||
|
||||
## Retirement note
|
||||
|
||||
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
|
||||
`DriverEditRouter`, the 8 per-type `*DriverPage` shells, and the per-cluster Drivers list) is
|
||||
**retired** — authoring lives in `/raw`. The extracted driver/device **form bodies** live on
|
||||
inside the `/raw` modals. The `DriverType` dispatch maps (`TagConfigEditorMap`,
|
||||
`TagConfigValidator`, `EquipmentTagConfigInspector`) are keyed off the single-source-of-truth
|
||||
`DriverTypeNames` constants (fixing the historical `TwinCat`/`Focas` drift).
|
||||
@@ -0,0 +1,74 @@
|
||||
# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver
|
||||
|
||||
Implements `docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md` (Track 0 + WP1–WP8),
|
||||
executed via the `/v3-batch 2` coordinator: contracts-first fan-out, worktree-isolated Opus
|
||||
agents per wave, a code-reviewer pass + green build/test after each wave, then the
|
||||
non-negotiable 7-item live gate on docker-dev.
|
||||
|
||||
## What landed
|
||||
|
||||
- **Track 0** — reusable `ContextMenu` (right-click + `⋯`, keyboard/focus); RFC-4180
|
||||
`CsvParser`/`CsvWriter`; `DriverTypeNames` constants + a reflection-discovery guard;
|
||||
`CalculationEvaluator` core (VirtualTag-evaluator parity).
|
||||
- **Wave A** — `IRawTreeService`/`RawTreeService` (lazy tree + mutations, integrity guards,
|
||||
rename-warnings); `/raw` page + lazy `RawTree` with per-node context menus.
|
||||
- **Wave B** — driver→embeddable-form refactor + `DriverConfigModal`/`DeviceModal`
|
||||
(endpoint→`DeviceConfig`, Test-connect via merged config); manual tag entry + tag edit
|
||||
modal + `Calculation` tag editor; CSV import (staged review grid) / export; all wired into
|
||||
the tree (name/confirm/driver-type dialogs + post-mutation refresh).
|
||||
- **Wave C** — browse re-target (two-tier gate, merged-config feed, commit → raw tags with
|
||||
address-field mapping + folder mirroring); `Calculation` driver (`IDependencyConsumer` +
|
||||
mux adapter, timer + change triggers, `DraftValidator` scriptId + cycle gates);
|
||||
`DriverTypeNames` rewire + retirement of the routed driver flow.
|
||||
|
||||
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors;
|
||||
`AdminUI.Tests` 638 passed / 3 skipped, `Runtime.Tests` 357 passed, driver + guard suites green.
|
||||
|
||||
## Reviewer findings fixed in-branch
|
||||
|
||||
- **Wave B H1** — driver forms still serialized the endpoint into `DriverConfig`; stripped
|
||||
the endpoint keys (Modbus/S7/OpcUaClient) so `DeviceConfig` is the sole source (a 2nd
|
||||
device on a single-Host driver no longer reverts to `127.0.0.1`).
|
||||
- **Wave C HIGH** — `CalculationDriver.ReinitializeAsync` ignored the new config, so calc
|
||||
tag/script edits were silently inert after a redeploy and the re-register loop was
|
||||
decorative. Now rebuilds the tag/dependency table on reinit and re-registers deps after
|
||||
the delta applies (`DeltaApplied` notification, race-free). New redeploy test proves it.
|
||||
- Wave-A/B/0 mediums (auto-expand, friendly create-race/delete failures, discovery-driven
|
||||
guard, CSV comment). Deferred (documented): pre-existing Modbus/S7 form `TimeSpan`↔`*Ms`
|
||||
DTO mismatch; CSV alarm-subfield/flag-precedence edge cases; OpcUaClient endpoint
|
||||
precedence; unused `LoadMergedProbeConfigAsync`; refresh collapses sibling expansion.
|
||||
|
||||
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt)
|
||||
|
||||
1. **Authoring + Test-connect** — `/raw` → New Modbus driver `gate-modbus` (form body renders,
|
||||
`PLC family`/`MELSEC` enum dropdowns correct) → Device1 endpoint `10.100.0.35:5020` in
|
||||
`DeviceConfig` → **Test connect OK · 94 ms** against the live Modbus fixture; SQL confirms
|
||||
endpoint in `DeviceConfig` (`{"Host":"10.100.0.35","Port":5020,"UnitId":1}`).
|
||||
2. **Historian-tagname deploy block** — a historized tag with a 260-char effective tagname →
|
||||
`POST /api/deployments` **422** `[HistorianTagnameTooLong] tag 'TAG-gatelong' … is 260 chars
|
||||
(max 255)`; shortened override → **202 Accepted**.
|
||||
3. **CSV round-trip + bad-enum** — export produces the exact column dictionary
|
||||
(`…,Region,Address,ModbusDataType,ByteOrder,…`); importing 1 valid + 1 bad-enum row →
|
||||
review grid "2 rows, 1 valid, 1 invalid", row-2 error *"Column 'ModbusDataType':
|
||||
'NOTATYPE' is not a valid ModbusDataType (expected one of: Bool, Int16, UInt16, …)"*,
|
||||
**Commit disabled** (all-or-nothing; SQL confirms nothing committed); a valid-only import →
|
||||
Commit → `ImportedTag` lands with typed columns assembled into `TagConfig`.
|
||||
4. **Browse-commit** — OpcUaClient device → opc-plc (`opc.tcp://10.100.0.35:50000`): two-tier
|
||||
gate enabled browse, merged config connected, real tree walked (NetworkSet/…/OpcPlc);
|
||||
multi-selected 2 leaves with "create matching tag-groups" → raw tags land under the
|
||||
mirrored `Basic` group with DataType + the browse ref in the `nodeId` address field
|
||||
(`nsu=…;s=AlternatingBoolean`), not an identity key.
|
||||
5. **Calculation deploy + cycle gate** — a calc tag reading a Modbus RawPath deploys **202**;
|
||||
a 2-cycle (`calc1/Engine/A`↔`calc1/Engine/B`) → **422** `[CalculationDependencyCycle] …
|
||||
(members: calc1/Engine/A, calc1/Engine/B)`; clean redeploy 202, no crash loops (both
|
||||
central nodes stable; only a deploy-reject WRN in the log).
|
||||
6. **Rename warning** — renaming `gate-modbus`→`gate-modbus-renamed` fired the warning modal
|
||||
*"1 historized tag beneath this node will change its RawPath (historian tagname)…"*.
|
||||
7. **Retired routes** — `/clusters/{id}/drivers`, `…/new`, `…/new/{slug}`, `…/{id}` all
|
||||
return **404**; `/raw` + `/uns` still 200.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/Raw.md` (new — the `/raw` authoring tree + endpoint split + CSV + Calculation +
|
||||
retirement note); `CLAUDE.md` AdminUI section updated; the Calculation mini-design remains the
|
||||
driver's authority.
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
|
||||
/// <summary>
|
||||
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
|
||||
/// <see cref="string"/> or a <see cref="TextReader"/> and yields rows of fields. This is the single
|
||||
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
|
||||
/// <see cref="CsvWriter"/>) rather than re-deriving it at each call site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Faithful to RFC 4180.</b> The grammar handled:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fields are separated by the delimiter (default <c>','</c>); records are separated by
|
||||
/// newlines.</item>
|
||||
/// <item>A field may be quoted with double-quotes (<c>"</c>). A quoted field may contain the
|
||||
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
|
||||
/// (<c>""</c> → <c>"</c>).</item>
|
||||
/// <item>An unquoted field runs literally up to the next delimiter or newline; leading and
|
||||
/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field).</item>
|
||||
/// <item>CRLF, bare LF, and bare CR are all accepted as record terminators.</item>
|
||||
/// </list>
|
||||
/// <para><b>Empty-line policy.</b> Faithful to the RFC: a line with no characters yields a single row
|
||||
/// containing one empty field (<c>[""]</c>); it is NOT silently dropped. Callers that want blank lines
|
||||
/// skipped should filter the result (e.g. <c>rows.Where(r => r.Length > 1 || r[0].Length > 0)</c>).
|
||||
/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its
|
||||
/// absence does not lose the last row.</para>
|
||||
/// <para><b>Malformed-input policy.</b> This parser is <b>strict</b>. It throws
|
||||
/// <see cref="System.FormatException"/> (carrying a 1-based line/column position) for the malformed
|
||||
/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g.
|
||||
/// <c>ab"c</c>), (2) a stray character after a closing quote other than the delimiter or a newline
|
||||
/// (e.g. <c>"ab"c</c>), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
|
||||
/// — silent lenient recovery hides data-shape bugs in imported files.</para>
|
||||
/// </remarks>
|
||||
public static class CsvParser
|
||||
{
|
||||
private const char Quote = '"';
|
||||
private const char Cr = '\r';
|
||||
private const char Lf = '\n';
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="text"/> fully into rows of fields. Convenience wrapper over
|
||||
/// <see cref="Parse(TextReader,char)"/>; materialises the whole document.
|
||||
/// </summary>
|
||||
/// <param name="text">The CSV document. <c>null</c> is treated as empty.</param>
|
||||
/// <param name="delimiter">The field separator (default comma).</param>
|
||||
/// <returns>The rows, each an array of field values. Empty input yields zero rows.</returns>
|
||||
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||
public static IReadOnlyList<string[]> Parse(string? text, char delimiter = ',')
|
||||
{
|
||||
using var reader = new StringReader(text ?? string.Empty);
|
||||
var rows = new List<string[]>();
|
||||
foreach (var row in Parse(reader, delimiter))
|
||||
{
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams rows from <paramref name="reader"/> lazily — a single forward pass, one row
|
||||
/// materialised at a time. The reader is not disposed by this method.
|
||||
/// </summary>
|
||||
/// <param name="reader">The source. Read to end-of-input.</param>
|
||||
/// <param name="delimiter">The field separator (default comma).</param>
|
||||
/// <returns>A lazily-evaluated sequence of rows; each row is a freshly allocated field array.</returns>
|
||||
/// <exception cref="System.ArgumentNullException"><paramref name="reader"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
|
||||
public static IEnumerable<string[]> Parse(TextReader reader, char delimiter = ',')
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
if (delimiter is Quote or Cr or Lf)
|
||||
{
|
||||
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
|
||||
}
|
||||
|
||||
return Iterate(reader, delimiter);
|
||||
}
|
||||
|
||||
private static IEnumerable<string[]> Iterate(TextReader reader, char delimiter)
|
||||
{
|
||||
var field = new StringBuilder();
|
||||
var row = new List<string>();
|
||||
|
||||
// True once the current row has produced any field boundary or content — i.e. once we've seen a
|
||||
// char that commits us to emitting at least one field. Reset to false right after a record
|
||||
// terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row.
|
||||
var rowOpen = false;
|
||||
|
||||
// 1-based cursor, maintained for FormatException messages.
|
||||
var line = 1;
|
||||
var col = 0;
|
||||
|
||||
int read;
|
||||
while ((read = reader.Read()) != -1)
|
||||
{
|
||||
var c = (char)read;
|
||||
col++;
|
||||
|
||||
if (c == Quote)
|
||||
{
|
||||
if (field.Length != 0)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " +
|
||||
"A field is quoted only when the quote is its first character.");
|
||||
}
|
||||
|
||||
rowOpen = true;
|
||||
|
||||
// Consume the quoted body; the opening quote has been read.
|
||||
ReadQuotedField(reader, field, ref line, ref col);
|
||||
|
||||
// A closing quote must be followed by the delimiter, a newline, or EOF.
|
||||
var next = reader.Peek();
|
||||
if (next == -1)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
yield return row.ToArray();
|
||||
row.Clear();
|
||||
rowOpen = false;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var nc = (char)next;
|
||||
if (nc == delimiter)
|
||||
{
|
||||
reader.Read();
|
||||
col++;
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nc is Cr or Lf)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
ConsumeNewline(reader, ref line, ref col);
|
||||
yield return row.ToArray();
|
||||
row.Clear();
|
||||
rowOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new FormatException(
|
||||
$"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " +
|
||||
"A quoted field must be followed by a delimiter, a newline, or end-of-input.");
|
||||
}
|
||||
|
||||
if (c == delimiter)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
rowOpen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is Cr or Lf)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
if (c == Cr && reader.Peek() == Lf)
|
||||
{
|
||||
reader.Read();
|
||||
}
|
||||
|
||||
line++;
|
||||
col = 0;
|
||||
yield return row.ToArray();
|
||||
row.Clear();
|
||||
rowOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
field.Append(c);
|
||||
rowOpen = true;
|
||||
}
|
||||
|
||||
// EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen.
|
||||
if (rowOpen || field.Length != 0 || row.Count != 0)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
yield return row.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the body of a quoted field into <paramref name="field"/>. The opening quote has already
|
||||
/// been consumed. On return the reader sits immediately after the closing quote.
|
||||
/// </summary>
|
||||
private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col)
|
||||
{
|
||||
var openLine = line;
|
||||
var openCol = col;
|
||||
|
||||
int read;
|
||||
while ((read = reader.Read()) != -1)
|
||||
{
|
||||
var c = (char)read;
|
||||
col++;
|
||||
|
||||
if (c == Quote)
|
||||
{
|
||||
if (reader.Peek() == Quote)
|
||||
{
|
||||
reader.Read();
|
||||
col++;
|
||||
field.Append(Quote);
|
||||
continue;
|
||||
}
|
||||
|
||||
return; // closing quote
|
||||
}
|
||||
|
||||
if (c == Lf)
|
||||
{
|
||||
line++;
|
||||
col = 0;
|
||||
}
|
||||
|
||||
field.Append(c);
|
||||
}
|
||||
|
||||
throw new FormatException(
|
||||
$"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " +
|
||||
"before the closing double-quote.");
|
||||
}
|
||||
|
||||
/// <summary>Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.</summary>
|
||||
private static void ConsumeNewline(TextReader reader, ref int line, ref int col)
|
||||
{
|
||||
var first = reader.Read();
|
||||
if (first == Cr && reader.Peek() == Lf)
|
||||
{
|
||||
reader.Read();
|
||||
}
|
||||
|
||||
line++;
|
||||
col = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header-aware convenience: parses <paramref name="text"/> and maps every subsequent row onto the
|
||||
/// first (header) row's field names. Thin wrapper over <see cref="Parse(string,char)"/>.
|
||||
/// </summary>
|
||||
/// <param name="text">The CSV document; the first row is treated as the header.</param>
|
||||
/// <param name="delimiter">The field separator (default comma).</param>
|
||||
/// <returns>
|
||||
/// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than
|
||||
/// the header maps only the columns present; a column beyond the header's width is keyed by its
|
||||
/// 0-based index rendered as a string. An empty document yields zero rows.
|
||||
/// </returns>
|
||||
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.</exception>
|
||||
public static IReadOnlyList<IReadOnlyDictionary<string, string>> ParseWithHeader(string? text, char delimiter = ',')
|
||||
{
|
||||
var rows = Parse(text, delimiter);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return Array.Empty<IReadOnlyDictionary<string, string>>();
|
||||
}
|
||||
|
||||
var header = rows[0];
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var name in header)
|
||||
{
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
throw new FormatException($"Duplicate header column name '{name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<IReadOnlyDictionary<string, string>>(rows.Count - 1);
|
||||
for (var i = 1; i < rows.Count; i++)
|
||||
{
|
||||
var cells = rows[i];
|
||||
var map = new Dictionary<string, string>(cells.Length, StringComparer.Ordinal);
|
||||
for (var c = 0; c < cells.Length; c++)
|
||||
{
|
||||
var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture);
|
||||
map[key] = cells[c];
|
||||
}
|
||||
|
||||
result.Add(map);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
|
||||
/// <summary>
|
||||
/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a
|
||||
/// <see cref="string"/> or a <see cref="TextWriter"/>. The inverse of <see cref="CsvParser"/>:
|
||||
/// <c>CsvParser.Parse(CsvWriter.WriteToString(rows))</c> reproduces <c>rows</c> exactly for any field
|
||||
/// content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Quote-on-demand.</b> A field is wrapped in double-quotes only when it must be — i.e. when
|
||||
/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled
|
||||
/// (<c>"</c> → <c>""</c>). Fields that need no quoting are emitted verbatim, so ordinary values stay
|
||||
/// human-readable. Pass <c>quoteAllFields: true</c> to force every field quoted.</para>
|
||||
/// <para><b>Newline.</b> The record terminator defaults to CRLF (<c>\r\n</c>) per RFC 4180 and is
|
||||
/// configurable. No terminator is written after the final row (matching the parser's
|
||||
/// no-phantom-trailing-row contract, so a round-trip is exact).</para>
|
||||
/// </remarks>
|
||||
public static class CsvWriter
|
||||
{
|
||||
private const char Quote = '"';
|
||||
private const char Cr = '\r';
|
||||
private const char Lf = '\n';
|
||||
|
||||
/// <summary>The RFC 4180 record terminator, <c>"\r\n"</c>. The default <c>newline</c> for every write.</summary>
|
||||
public const string Crlf = "\r\n";
|
||||
|
||||
/// <summary>
|
||||
/// Renders <paramref name="rows"/> to a CSV string.
|
||||
/// </summary>
|
||||
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||
/// <param name="delimiter">The field separator (default comma).</param>
|
||||
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||
/// <returns>The CSV text. An empty <paramref name="rows"/> yields the empty string.</returns>
|
||||
/// <exception cref="System.ArgumentNullException"><paramref name="rows"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||
public static string WriteToString(
|
||||
IEnumerable<IEnumerable<string?>> rows,
|
||||
char delimiter = ',',
|
||||
string newline = Crlf,
|
||||
bool quoteAllFields = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var writer = new StringWriter(sb);
|
||||
Write(writer, rows, delimiter, newline, quoteAllFields);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="rows"/> to <paramref name="writer"/>. The writer is not disposed or
|
||||
/// flushed by this method.
|
||||
/// </summary>
|
||||
/// <param name="writer">The destination.</param>
|
||||
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
|
||||
/// <param name="delimiter">The field separator (default comma).</param>
|
||||
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
|
||||
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
|
||||
/// <exception cref="System.ArgumentNullException"><paramref name="writer"/> or <paramref name="rows"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
|
||||
public static void Write(
|
||||
TextWriter writer,
|
||||
IEnumerable<IEnumerable<string?>> rows,
|
||||
char delimiter = ',',
|
||||
string newline = Crlf,
|
||||
bool quoteAllFields = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (delimiter is Quote or Cr or Lf)
|
||||
{
|
||||
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
|
||||
}
|
||||
|
||||
var firstRow = true;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!firstRow)
|
||||
{
|
||||
writer.Write(newline);
|
||||
}
|
||||
|
||||
firstRow = false;
|
||||
|
||||
WriteRow(writer, row, delimiter, quoteAllFields);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRow(TextWriter writer, IEnumerable<string?> row, char delimiter, bool quoteAllFields)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
var firstField = true;
|
||||
foreach (var field in row)
|
||||
{
|
||||
if (!firstField)
|
||||
{
|
||||
writer.Write(delimiter);
|
||||
}
|
||||
|
||||
firstField = false;
|
||||
|
||||
WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields)
|
||||
{
|
||||
if (!quoteAllFields && !NeedsQuoting(field, delimiter))
|
||||
{
|
||||
writer.Write(field);
|
||||
return;
|
||||
}
|
||||
|
||||
writer.Write(Quote);
|
||||
foreach (var ch in field)
|
||||
{
|
||||
if (ch == Quote)
|
||||
{
|
||||
writer.Write(Quote); // double an internal quote
|
||||
}
|
||||
|
||||
writer.Write(ch);
|
||||
}
|
||||
|
||||
writer.Write(Quote);
|
||||
}
|
||||
|
||||
private static bool NeedsQuoting(string field, char delimiter)
|
||||
{
|
||||
foreach (var ch in field)
|
||||
{
|
||||
if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ public sealed class DraftSnapshot
|
||||
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
||||
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||
|
||||
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
||||
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
||||
/// <summary>Gets the list of poll groups.</summary>
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ public static class DraftSnapshotFactory
|
||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||
ActiveReservations = await db.ExternalIdReservations
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
@@ -38,9 +39,107 @@ public static class DraftValidator
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <list type="number">
|
||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||
/// a <see cref="Script"/> row in the draft.</item>
|
||||
/// <item><b>Cycle gate (hard error)</b> — build the calc→calc edge set (a calc tag → its
|
||||
/// <c>ctx.GetTag</c> deps that are <b>themselves</b> Calculation tags; cross-driver refs are terminal,
|
||||
/// not edges) and run <see cref="DependencyGraph.DetectCycles"/> (Tarjan SCC). Any SCC / self-loop is a
|
||||
/// deploy error naming the cycle members — an undetected calc cycle is a live oscillation loop.</item>
|
||||
/// </list>
|
||||
/// Compile is deliberately NOT hard-gated (VirtualTag parity — the editor's live diagnostics mirror it and
|
||||
/// a runtime compile failure lands as Bad quality + a script-log).</summary>
|
||||
private static void ValidateCalculationTags(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var driverTypeByInstance = draft.DriverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||
var driverTypeByDevice = draft.Devices
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => driverTypeByInstance.TryGetValue(g.First().DriverInstanceId, out var dt) ? dt : null,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var resolver = BuildRawPathResolver(draft);
|
||||
var scriptExists = new HashSet<string>(draft.Scripts.Select(s => s.ScriptId), StringComparer.Ordinal);
|
||||
var scriptSourceById = draft.Scripts
|
||||
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||
|
||||
// Identify calc tags (tags on a Calculation-typed device) + their RawPath + scriptId in one pass.
|
||||
var calcTags = new List<(Tag Tag, string? RawPath, string? ScriptId)>();
|
||||
var calcRawPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in draft.Tags)
|
||||
{
|
||||
if (!driverTypeByDevice.TryGetValue(t.DeviceId, out var dt)
|
||||
|| !string.Equals(dt, Core.Abstractions.DriverTypeNames.Calculation, StringComparison.Ordinal))
|
||||
continue;
|
||||
var rawPath = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
calcTags.Add((t, rawPath, ParseScriptId(t.TagConfig)));
|
||||
if (rawPath is not null) calcRawPaths.Add(rawPath);
|
||||
}
|
||||
if (calcTags.Count == 0) return;
|
||||
|
||||
// Rule 1 — scriptId existence.
|
||||
foreach (var (t, _, scriptId) in calcTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptId))
|
||||
errors.Add(new("CalculationScriptMissing",
|
||||
$"Calculation tag '{t.TagId}' has no 'scriptId' in its TagConfig", t.TagId));
|
||||
else if (!scriptExists.Contains(scriptId!))
|
||||
errors.Add(new("CalculationScriptNotFound",
|
||||
$"Calculation tag '{t.TagId}' references script '{scriptId}', which does not exist in the draft",
|
||||
t.TagId));
|
||||
}
|
||||
|
||||
// Rule 2 — cycle gate. Edges run from a calc tag to each dep that is ITSELF a calc tag.
|
||||
var graph = new DependencyGraph();
|
||||
foreach (var (_, rawPath, scriptId) in calcTags)
|
||||
{
|
||||
if (rawPath is null) continue; // broken chain — flagged by the charset rule; not a graph node
|
||||
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : null;
|
||||
var calcDeps = EquipmentScriptPaths.ExtractDependencyRefs(source)
|
||||
.Where(calcRawPaths.Contains)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
graph.Add(rawPath, calcDeps);
|
||||
}
|
||||
foreach (var cycle in graph.DetectCycles())
|
||||
errors.Add(new("CalculationDependencyCycle",
|
||||
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
|
||||
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
|
||||
cycle.Count > 0 ? cycle[0] : null));
|
||||
}
|
||||
|
||||
/// <summary>Extract the <c>scriptId</c> string from a calc tag's schemaless <c>TagConfig</c> JSON. Never
|
||||
/// throws — malformed/blank/non-object JSON or an absent/non-string <c>scriptId</c> yields null.</summary>
|
||||
private static string? ParseScriptId(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
|
||||
if (root.TryGetProperty("scriptId", out var el)
|
||||
&& el.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
var v = el.GetString();
|
||||
return string.IsNullOrWhiteSpace(v) ? null : v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
||||
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
||||
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<!-- WP7: the calc deploy-gate reuses the pure DependencyGraph (Tarjan SCC) from Core.VirtualTags,
|
||||
whose closure includes Microsoft.CodeAnalysis.CSharp.Scripting 4.12.0 (== CodeAnalysis.Common
|
||||
4.12.0), while EF's transitive CodeAnalysis.Common 5.0.0 wins resolution. Suppress NU1608 — the
|
||||
only surface Configuration touches (DependencyGraph.DetectCycles) has ZERO Roslyn dependency, so
|
||||
the version skew never runs. Mirrors the identical suppression + rationale in the Host csproj. -->
|
||||
<NoWarn>$(NoWarn);CS1591;NU1608</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -30,6 +35,9 @@
|
||||
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
||||
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<!-- WP7: the deploy-gate cycle check reuses DependencyGraph (Tarjan SCC) for the calc→calc
|
||||
dependency-cycle rule. Cycle-safe — Core.VirtualTags does not reference Configuration. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the driver-type identifier strings the runtime
|
||||
/// dispatches on. Each constant's <b>value</b> is the exact <c>DriverTypeName</c>
|
||||
/// that the corresponding driver factory registers under in the process
|
||||
/// <c>DriverFactoryRegistry</c> — the string the deploy pipeline stores in
|
||||
/// <c>DriverInstance.DriverType</c> and every dispatch map keys by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Historically several dispatch maps hand-authored these strings and drifted from
|
||||
/// the authoritative factory names (e.g. <c>"TwinCat"</c> vs the real
|
||||
/// <c>"TwinCAT"</c>, <c>"Focas"</c> vs <c>"FOCAS"</c>). Consumers should reference
|
||||
/// these constants instead of literals so the drift can never recur.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reflection guard test
|
||||
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
|
||||
/// constants and the set the driver factories actually register, so a rename on
|
||||
/// either side breaks the build's test gate. Only constants for
|
||||
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
|
||||
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
|
||||
/// constant when its factory is wired in.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DriverTypeNames
|
||||
{
|
||||
/// <summary>Modbus TCP / RTU-over-TCP driver.</summary>
|
||||
public const string Modbus = "Modbus";
|
||||
|
||||
/// <summary>Siemens S7 driver.</summary>
|
||||
public const string S7 = "S7";
|
||||
|
||||
/// <summary>Allen-Bradley CIP (ControlLogix / CompactLogix) driver.</summary>
|
||||
public const string AbCip = "AbCip";
|
||||
|
||||
/// <summary>Allen-Bradley legacy (PCCC / DF1-over-TCP) driver.</summary>
|
||||
public const string AbLegacy = "AbLegacy";
|
||||
|
||||
/// <summary>Beckhoff TwinCAT ADS driver.</summary>
|
||||
public const string TwinCAT = "TwinCAT";
|
||||
|
||||
/// <summary>FANUC FOCAS CNC driver.</summary>
|
||||
public const string FOCAS = "FOCAS";
|
||||
|
||||
/// <summary>OPC UA client (upstream-server) driver.</summary>
|
||||
public const string OpcUaClient = "OpcUaClient";
|
||||
|
||||
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
|
||||
public const string Galaxy = "GalaxyMxGateway";
|
||||
|
||||
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||
public const string Calculation = "Calculation";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> All { get; } =
|
||||
[
|
||||
Modbus,
|
||||
S7,
|
||||
AbCip,
|
||||
AbLegacy,
|
||||
TwinCAT,
|
||||
FOCAS,
|
||||
OpcUaClient,
|
||||
Galaxy,
|
||||
Calculation,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional composable driver capability: the driver consumes <b>other</b> tags' live values.
|
||||
/// The host registers the declared <see cref="DependencyRefs"/> with its per-node dependency mux
|
||||
/// and forwards every matching value change into <see cref="OnDependencyValue"/> — the seam that
|
||||
/// lets a host-blind driver (e.g. the <c>Calculation</c> pseudo-driver) read the address space's
|
||||
/// live values without any cross-driver plumbing of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
|
||||
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
|
||||
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
|
||||
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
|
||||
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
|
||||
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations of <see cref="OnDependencyValue"/> are invoked from an actor context and
|
||||
/// <b>must be non-blocking</b> — do the work inline and cheaply (the compile cache makes
|
||||
/// steady-state calc evaluation a bounded method invocation), never block on I/O.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDependencyConsumer
|
||||
{
|
||||
/// <summary>
|
||||
/// Wire-refs (RawPaths) this driver needs fed to it, derived from its authored tags. The host
|
||||
/// re-reads this after <see cref="IDriver.InitializeAsync"/> / <see cref="IDriver.ReinitializeAsync"/>
|
||||
/// and (re)registers the set with its dependency mux on every apply, so a tag/script edit that
|
||||
/// changes the set converges without a bespoke notification.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<string> DependencyRefs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Host push of a single dependency value change. Called from an actor context, so the
|
||||
/// implementation must be non-blocking.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The changed dependency's wire-ref (RawPath).</param>
|
||||
/// <param name="value">The new value (may be null).</param>
|
||||
/// <param name="statusCode">The OPC UA status code carried with the value (0 = Good).</param>
|
||||
/// <param name="timestampUtc">The source timestamp of the change.</param>
|
||||
void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Calculation</c> pseudo-driver. Its tags are ordinary raw tags whose value is <b>computed</b>
|
||||
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
|
||||
/// writes. It implements:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IDriver"/> — lifecycle + always-Connected health.</item>
|
||||
/// <item><see cref="IDependencyConsumer"/> — the host feeds it the other tags' live values.</item>
|
||||
/// <item><see cref="ISubscribable"/> — computed values flow out via <see cref="OnDataChange"/>
|
||||
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.</item>
|
||||
/// <item><see cref="IReadable"/> — on-demand read returns the last computed snapshot
|
||||
/// (<c>BadWaitingForInitialData</c> before the first evaluation).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Triggers.</b> A tag re-evaluates on any of its declared dependencies changing (change-trigger,
|
||||
/// gated exactly like <c>VirtualTagActor</c>: nothing publishes until every declared dep has arrived
|
||||
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error semantics (mini-design §6).</b> An evaluator <c>Failure</c> publishes <b>Bad</b> quality
|
||||
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
|
||||
/// and emits a <c>ScriptLogEntry</c> on the <c>script-logs</c> topic (via the injected
|
||||
/// <see cref="ScriptRootLogger"/>'s <c>ScriptLogTopicSink</c>). Recovery to Good is force-published.
|
||||
/// A historized Bad records <c>BadInternalError</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
|
||||
{
|
||||
// OPC UA status codes surfaced by the calc driver.
|
||||
private const uint StatusGood = 0u;
|
||||
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly CalculationDriverOptions _options;
|
||||
private readonly ILogger<CalculationDriver> _logger;
|
||||
private readonly ScriptRootLogger _scriptRoot;
|
||||
private readonly CalculationEvaluator _evaluator;
|
||||
|
||||
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
|
||||
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
|
||||
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
|
||||
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
|
||||
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
|
||||
private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);
|
||||
|
||||
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
|
||||
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
|
||||
private IReadOnlyCollection<string> _dependencyRefs = Array.Empty<string>();
|
||||
|
||||
// Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
|
||||
// (pool threads) can both drive an evaluation, so all state mutation is serialized.
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, object?> _depValues = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);
|
||||
|
||||
private CalculationTimerScheduler? _timers;
|
||||
private volatile bool _running;
|
||||
private DateTime? _lastComputeUtc;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Initializes a new <see cref="CalculationDriver"/>.</summary>
|
||||
/// <param name="options">Bound options carrying the authored raw calc tags.</param>
|
||||
/// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
|
||||
/// <param name="scriptRoot">Root script logger — user <c>ctx.Logger</c> output + failure entries fan out
|
||||
/// to the <c>script-logs</c> topic through its <c>ScriptLogTopicSink</c>. When null a no-op logger is used.</param>
|
||||
/// <param name="logger">Host-side diagnostics logger; defaults to the null logger.</param>
|
||||
public CalculationDriver(
|
||||
CalculationDriverOptions options,
|
||||
string driverInstanceId,
|
||||
ScriptRootLogger? scriptRoot = null,
|
||||
ILogger<CalculationDriver>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<CalculationDriver>.Instance;
|
||||
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
|
||||
_evaluator = new CalculationEvaluator(
|
||||
_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance,
|
||||
_scriptRoot,
|
||||
_options.RunTimeout);
|
||||
|
||||
RebuildTagTable(_options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
|
||||
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
|
||||
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
|
||||
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
|
||||
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
|
||||
/// and reinit paths share ONE construction routine and cannot drift.
|
||||
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
|
||||
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
|
||||
/// under <see cref="_lock"/> when invoked post-construction (the ctor runs single-threaded).</para>
|
||||
/// </summary>
|
||||
/// <param name="config">The (new) bound options carrying the authored raw calc tags.</param>
|
||||
private void RebuildTagTable(CalculationDriverOptions config)
|
||||
{
|
||||
_tagsByRawPath.Clear();
|
||||
_changeDependents.Clear();
|
||||
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
|
||||
var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entry in config.RawTags)
|
||||
{
|
||||
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(def.ScriptSource))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
|
||||
"it will not compute until the deploy artifact injects the script source",
|
||||
_driverInstanceId, entry.RawPath, def.ScriptId);
|
||||
}
|
||||
_tagsByRawPath[entry.RawPath] = def;
|
||||
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
|
||||
retainedState[entry.RawPath] =
|
||||
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
|
||||
if (def.ChangeTriggered)
|
||||
{
|
||||
foreach (var dep in def.DependencyRefs)
|
||||
{
|
||||
if (!_changeDependents.TryGetValue(dep, out var list))
|
||||
_changeDependents[dep] = list = new List<string>();
|
||||
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
|
||||
_stateByRawPath.Clear();
|
||||
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
|
||||
|
||||
_dependencyRefs = _tagsByRawPath.Values
|
||||
.SelectMany(t => t.DependencyRefs)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
|
||||
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
|
||||
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
|
||||
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
|
||||
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
|
||||
/// current tags so a malformed delta can never blank the driver.</summary>
|
||||
private CalculationDriverOptions ParseOptions(string driverConfigJson)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<ReinitConfigDto>(driverConfigJson, ReinitJsonOptions);
|
||||
if (dto is not null)
|
||||
{
|
||||
return new CalculationDriverOptions
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
RunTimeout = _options.RunTimeout,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
|
||||
_driverInstanceId);
|
||||
}
|
||||
}
|
||||
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
|
||||
}
|
||||
|
||||
/// <summary>Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
|
||||
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
|
||||
/// <c>CalculationDriverConfigDto</c>.</summary>
|
||||
private sealed class ReinitConfigDto
|
||||
{
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
public int? RunTimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
// ---- IDriver ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Calculation;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_running = true;
|
||||
StartTimers();
|
||||
_logger.LogInformation(
|
||||
"Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
|
||||
_driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
|
||||
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
|
||||
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
|
||||
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
|
||||
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
|
||||
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
|
||||
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
|
||||
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
|
||||
StopTimers();
|
||||
var newOptions = ParseOptions(driverConfigJson);
|
||||
lock (_lock)
|
||||
{
|
||||
RebuildTagTable(newOptions);
|
||||
}
|
||||
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
|
||||
// recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
|
||||
_evaluator.ClearCompiledScripts();
|
||||
_running = true;
|
||||
StartTimers();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_running = false;
|
||||
StopTimers();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
|
||||
_evaluator.ClearCompiledScripts();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IDependencyConsumer ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> DependencyRefs => _dependencyRefs;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||
{
|
||||
if (!_running) return;
|
||||
List<string>? dependents;
|
||||
lock (_lock)
|
||||
{
|
||||
_depValues[rawPath] = value;
|
||||
_arrivedDeps.Add(rawPath);
|
||||
if (!_changeDependents.TryGetValue(rawPath, out dependents) || dependents.Count == 0) return;
|
||||
// Evaluate each change-triggered dependent inline under the lock (the compile cache makes this
|
||||
// a bounded method invocation — the same "fast enough to run inline" contract as the VT evaluator).
|
||||
foreach (var calcRawPath in dependents)
|
||||
EvaluateTag(calcRawPath, timestampUtc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
|
||||
// Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
|
||||
// tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
|
||||
foreach (var reference in fullReferences)
|
||||
{
|
||||
var snapshot = ReadOne(reference);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
|
||||
}
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
results[i] = ReadOne(fullReferences[i]);
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOne(string reference)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
|
||||
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
|
||||
var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
|
||||
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- evaluation ----
|
||||
|
||||
/// <summary>Timer-trigger entry point — evaluates a single calc tag. Guards the lock itself (called
|
||||
/// from a pool thread, unlike the change path which is already under the lock).</summary>
|
||||
private void EvaluateTagLocked(string rawPath)
|
||||
{
|
||||
if (!_running) return;
|
||||
lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Evaluate one calc tag and publish the outcome. MUST be called under <see cref="_lock"/>.</summary>
|
||||
private void EvaluateTag(string rawPath, DateTime timestampUtc)
|
||||
{
|
||||
if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return;
|
||||
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return; // unresolved script — never computes (logged at init)
|
||||
|
||||
var st = _stateByRawPath[rawPath];
|
||||
|
||||
// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
|
||||
var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);
|
||||
|
||||
// Build the per-tag dependency snapshot (only its declared reads, current values).
|
||||
var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var dep in def.DependencyRefs)
|
||||
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
|
||||
|
||||
var result = _evaluator.Evaluate(rawPath, def.ScriptSource, deps);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready
|
||||
|
||||
// Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
|
||||
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
|
||||
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
|
||||
return;
|
||||
|
||||
st.HasValue = true;
|
||||
st.LastValue = result.Value;
|
||||
st.LastPublishedBad = false;
|
||||
st.LastTimestampUtc = timestampUtc;
|
||||
_lastComputeUtc = DateTime.UtcNow;
|
||||
Publish(rawPath, result.Value, StatusGood, timestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): always emits the per-failure
|
||||
/// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
|
||||
/// Good→Bad transition and only after every declared dependency has arrived.</summary>
|
||||
private void OnEvaluationFailed(
|
||||
CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
|
||||
{
|
||||
PublishScriptLog(def, reason);
|
||||
if (st.LastPublishedBad || !allArrived) return;
|
||||
|
||||
st.LastPublishedBad = true;
|
||||
st.LastTimestampUtc = timestampUtc;
|
||||
// Carry the last-known value (null if none) with Bad quality.
|
||||
Publish(def.RawPath, st.HasValue ? st.LastValue : null, StatusBadInternalError, timestampUtc);
|
||||
}
|
||||
|
||||
private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||
{
|
||||
var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
|
||||
// Fired under _lock — the subscriber (DriverInstanceActor) marshals the event to its own thread,
|
||||
// so there is no re-entrant call back into this driver.
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
|
||||
}
|
||||
|
||||
private void PublishScriptLog(CalculationTagDefinition def, string reason)
|
||||
{
|
||||
// Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
|
||||
// slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
|
||||
// Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
|
||||
_scriptRoot.Logger
|
||||
.ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
|
||||
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
|
||||
.Warning("{Reason}", reason);
|
||||
}
|
||||
|
||||
private void StartTimers()
|
||||
{
|
||||
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
|
||||
if (timed.Length == 0) return;
|
||||
_timers = new CalculationTimerScheduler(EvaluateTagLocked, _logger);
|
||||
_timers.Start(timed);
|
||||
}
|
||||
|
||||
private void StopTimers()
|
||||
{
|
||||
_timers?.Dispose();
|
||||
_timers = null;
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
|
||||
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
|
||||
|
||||
// A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
|
||||
// per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
|
||||
// always carries a non-null handle.
|
||||
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_running = false;
|
||||
StopTimers();
|
||||
_evaluator.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
|
||||
private sealed class TagState
|
||||
{
|
||||
public bool HasValue;
|
||||
public object? LastValue;
|
||||
public bool LastPublishedBad;
|
||||
public DateTime LastTimestampUtc;
|
||||
}
|
||||
|
||||
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="CalculationDriver"/>. The Server's
|
||||
/// <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the bootstrapper then
|
||||
/// materialises <c>Calculation</c> DriverInstance rows into live driver instances. Mirrors
|
||||
/// <c>ModbusDriverFactoryExtensions</c>, with the addition of a <see cref="ScriptRootLogger"/> so a
|
||||
/// script failure fans out onto the <c>script-logs</c> topic (via the root logger's
|
||||
/// <c>ScriptLogTopicSink</c>) — the calc driver has no Akka access of its own.
|
||||
/// </summary>
|
||||
public static class CalculationDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>The <c>DriverInstance.DriverType</c> string this factory registers under.</summary>
|
||||
public const string DriverTypeName = "Calculation";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Register the Calculation factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> + <paramref name="scriptRoot"/> are captured at registration
|
||||
/// time; both may be null (the guard test invokes <c>Register(registry)</c> reflectively with the
|
||||
/// trailing parameters defaulted), in which case the driver runs with the null logger + a no-op
|
||||
/// script-logger.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
||||
}
|
||||
|
||||
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||
public static CalculationDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, scriptRoot: null);
|
||||
|
||||
/// <summary>Logger/script-logger-aware overload — used by <see cref="Register"/>'s closure via DI.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
|
||||
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||
public static CalculationDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<CalculationDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new CalculationDriverOptions
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
|
||||
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
|
||||
};
|
||||
|
||||
return new CalculationDriver(
|
||||
options, driverInstanceId, scriptRoot,
|
||||
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
||||
}
|
||||
|
||||
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
|
||||
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).</summary>
|
||||
internal sealed class CalculationDriverConfigDto
|
||||
{
|
||||
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName).</summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
|
||||
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
|
||||
public int? RunTimeoutMs { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Bound options for a <see cref="CalculationDriver"/> instance. The Calculation pseudo-driver has
|
||||
/// no backend/endpoint config — its only input is the authored raw calc tags the deploy artifact
|
||||
/// injects as the <see cref="RawTags"/> list (each carrying the tag's <c>scriptId</c> + resolved
|
||||
/// <c>scriptSource</c> + trigger config inside its <see cref="RawTagEntry.TagConfig"/> blob).
|
||||
/// </summary>
|
||||
public sealed class CalculationDriverOptions
|
||||
{
|
||||
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent), from
|
||||
/// the deploy artifact. Each is mapped to a <see cref="CalculationTagDefinition"/> at construction.</summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = Array.Empty<RawTagEntry>();
|
||||
|
||||
/// <summary>Per-script wall-clock evaluation budget handed to the <see cref="CalculationEvaluator"/>.
|
||||
/// Defaults to 2 seconds (parity with the VirtualTag evaluator).</summary>
|
||||
public TimeSpan RunTimeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Test-connect probe for the <c>Calculation</c> pseudo-driver. There is no backend to connect to —
|
||||
/// the driver computes tags from other tags' values — so the probe just confirms the driver config
|
||||
/// parses (mini-design §7). Per-script compile verification belongs to the editor diagnostics + the
|
||||
/// tag editor's inline panel, not the probe (which receives only the driver config). Never throws;
|
||||
/// returns Ok with negligible latency.
|
||||
/// </summary>
|
||||
public sealed class CalculationDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => CalculationDriverFactoryExtensions.DriverTypeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson))
|
||||
return Task.FromResult(new DriverProbeResult(false, "Config JSON is empty.", null));
|
||||
|
||||
try
|
||||
{
|
||||
// Parse-only: a Calculation driver's config is well-formed as long as it deserialises to an
|
||||
// object (the RawTags array is optional — a driver with no calc tags is still valid).
|
||||
_ = JsonSerializer.Deserialize<CalculationDriverFactoryExtensions.CalculationDriverConfigDto>(configJson, Opts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
using SerilogLogger = Serilog.ILogger;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
|
||||
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
|
||||
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
|
||||
/// resulting evaluator keyed by source in a <see cref="CompiledScriptCache{TContext, TResult}"/>,
|
||||
/// and runs steady-state evaluations as in-process method invocations against the dependency
|
||||
/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
|
||||
/// <see cref="VirtualTagContext"/> verbatim so the Monaco editor, sandbox, and diagnostics pipeline
|
||||
/// apply to calc scripts with no divergence.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Single-tag mode.</b> A calc tag produces exactly one output value — its own — so cross-tag
|
||||
/// <c>ctx.SetVirtualTag</c> writes are dropped (and logged at Debug). Fan-out between tags is owned
|
||||
/// by the host's <c>DependencyMuxActor</c> (calc-of-calc chains re-enter the mux), never by the
|
||||
/// eval engine, exactly as in the VirtualTag adapter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error surfacing (for WP7).</b> This evaluator never throws for script faults: compile error,
|
||||
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
|
||||
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
|
||||
/// carrying the computed value (which may be null). WP7's <c>CalculationDriver</c> maps a
|
||||
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so WP7 can drop compiled
|
||||
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
|
||||
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
|
||||
/// script edits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
{
|
||||
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
|
||||
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
||||
private readonly ILogger<CalculationEvaluator> _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
||||
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</param>
|
||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
|
||||
public CalculationEvaluator(
|
||||
ILogger<CalculationEvaluator> logger,
|
||||
ScriptRootLogger scriptRoot,
|
||||
TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
|
||||
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
|
||||
/// faults are reported via <see cref="VirtualTagEvalResult.Failure"/>; a successful run returns
|
||||
/// <see cref="VirtualTagEvalResult.Ok"/> carrying the single computed value.
|
||||
/// </summary>
|
||||
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
|
||||
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
|
||||
// Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
|
||||
// Semantics are byte-identical to the compiled mirror: a present dependency returns its value
|
||||
// (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
|
||||
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
|
||||
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
|
||||
{
|
||||
return dependencies.TryGetValue(passthroughRef, out var ptValue)
|
||||
? VirtualTagEvalResult.Ok(ptValue)
|
||||
: VirtualTagEvalResult.Ok(null);
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
|
||||
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
|
||||
var scriptLog = _scriptRoot
|
||||
.ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
|
||||
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
|
||||
var context = new VirtualTagContext(
|
||||
readCache,
|
||||
// Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
|
||||
setVirtualTag: (path, _) =>
|
||||
_logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
|
||||
calcTagId, path),
|
||||
logger: scriptLog);
|
||||
|
||||
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
|
||||
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
|
||||
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrCompile(scriptSource);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
|
||||
// apply-boundary race — never retry.
|
||||
return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
|
||||
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
|
||||
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
|
||||
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
|
||||
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
|
||||
return VirtualTagEvalResult.Ok(raw);
|
||||
}
|
||||
catch (ScriptTimeoutException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
|
||||
{
|
||||
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
|
||||
// run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
|
||||
// and retry once. A second disposal mid-retry falls through to the failure path below.
|
||||
continue;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure("evaluator disposed during evaluation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearCompiledScripts() => _cache.Clear();
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
|
||||
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps)
|
||||
{
|
||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A single authored calc tag as the driver runs it: its <see cref="RawPath"/> identity, the script
|
||||
/// that computes it (<see cref="ScriptId"/> for attribution, <see cref="ScriptSource"/> for
|
||||
/// evaluation), its trigger config (<see cref="ChangeTriggered"/> / <see cref="TimerInterval"/>), and
|
||||
/// the static <see cref="DependencyRefs"/> extracted from the script's literal
|
||||
/// <c>ctx.GetTag("…")</c> reads.
|
||||
/// </summary>
|
||||
/// <param name="RawPath">The tag's cluster-scoped RawPath — its identity + the calc-tag id passed to the evaluator.</param>
|
||||
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity — bound to the failure script-log for attribution.</param>
|
||||
/// <param name="ScriptSource">The resolved C# script source that produces the tag's value; empty when unresolved.</param>
|
||||
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes.</param>
|
||||
/// <param name="TimerInterval">Optional periodic re-evaluation cadence; null ⇒ change-trigger only.</param>
|
||||
/// <param name="DependencyRefs">Distinct literal <c>ctx.GetTag</c> RawPaths this tag reads (first-seen order).</param>
|
||||
public sealed record CalculationTagDefinition(
|
||||
string RawPath,
|
||||
string ScriptId,
|
||||
string ScriptSource,
|
||||
bool ChangeTriggered,
|
||||
TimeSpan? TimerInterval,
|
||||
IReadOnlyList<string> DependencyRefs)
|
||||
{
|
||||
/// <summary>
|
||||
/// Map a calc tag's <c>TagConfig</c> blob (<c>{ "scriptId", "scriptSource", "changeTriggered",
|
||||
/// "timerIntervalMs" }</c>) + its RawPath into a <see cref="CalculationTagDefinition"/>. Returns
|
||||
/// <see langword="false"/> (and leaves <paramref name="def"/> null) only when the blob has no
|
||||
/// <c>scriptId</c> — a tag with no identifiable script cannot be a calc tag. A present
|
||||
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still maps (the driver logs it and the
|
||||
/// tag never computes). Never throws — malformed JSON yields a miss.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath identity.</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when a definition (with a scriptId) was mapped.</returns>
|
||||
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
|
||||
string? scriptId;
|
||||
string scriptSource;
|
||||
bool changeTriggered;
|
||||
int? timerMs;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
|
||||
? sidEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(scriptId)) return false;
|
||||
|
||||
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
|
||||
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
|
||||
&& ctEl.ValueKind == JsonValueKind.False);
|
||||
|
||||
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
|
||||
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
|
||||
? ms : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
|
||||
def = new CalculationTagDefinition(
|
||||
RawPath: rawPath,
|
||||
ScriptId: scriptId!,
|
||||
ScriptSource: scriptSource,
|
||||
ChangeTriggered: changeTriggered,
|
||||
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
|
||||
DependencyRefs: deps);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
|
||||
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
|
||||
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
|
||||
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
|
||||
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
|
||||
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
|
||||
/// callback per tag in the group.
|
||||
/// </summary>
|
||||
internal sealed class CalculationTimerScheduler : IDisposable
|
||||
{
|
||||
private readonly Action<string> _evaluate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<Timer> _timers = [];
|
||||
private readonly List<TickGroup> _groups = [];
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private long _skippedTickCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
|
||||
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
|
||||
{
|
||||
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Number of timer callbacks that skipped their work because the prior tick for the same
|
||||
/// group was still running. Monotonic; exposed for tests + operational metrics.</summary>
|
||||
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
|
||||
|
||||
/// <summary>Stand up one <see cref="Timer"/> per distinct interval. All tags sharing an interval share
|
||||
/// a timer; each tick re-evaluates every tag in the group.</summary>
|
||||
/// <param name="tags">The calc tags to schedule (only those with a positive <see cref="CalculationTagDefinition.TimerInterval"/> are scheduled).</param>
|
||||
public void Start(IEnumerable<CalculationTagDefinition> tags)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CalculationTimerScheduler));
|
||||
|
||||
var byInterval = tags
|
||||
.Where(t => t.TimerInterval is { } iv && iv > TimeSpan.Zero)
|
||||
.GroupBy(t => t.TimerInterval!.Value);
|
||||
|
||||
foreach (var group in byInterval)
|
||||
{
|
||||
var paths = group.Select(t => t.RawPath).ToArray();
|
||||
var interval = group.Key;
|
||||
var ctx = new TickGroup(paths);
|
||||
_groups.Add(ctx);
|
||||
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
|
||||
_timers.Add(timer);
|
||||
_logger.LogInformation("CalculationTimerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||
paths.Length, interval);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTimer(TickGroup ctx)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
|
||||
// Skip the tick when the prior one for this group is still running — bounds the outstanding
|
||||
// work to one tick per group regardless of how long an evaluation takes.
|
||||
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
|
||||
{
|
||||
Interlocked.Increment(ref _skippedTickCount);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var path in ctx.Paths)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
try { _evaluate(path); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref ctx.InFlight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cts.Cancel();
|
||||
foreach (var t in _timers)
|
||||
{
|
||||
try { t.Dispose(); } catch { /* best-effort teardown */ }
|
||||
}
|
||||
_timers.Clear();
|
||||
_groups.Clear();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TickGroup(IReadOnlyList<string> paths)
|
||||
{
|
||||
// 0 = idle, 1 = a tick is currently running for this group.
|
||||
public int InFlight;
|
||||
public IReadOnlyList<string> Paths { get; } = paths;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Calculation</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Scripting infra shared with the VirtualTag Roslyn evaluator. Core.Scripting brings
|
||||
CompiledScriptCache / TimedScriptEvaluator / ScriptEvaluator / the sandbox exceptions /
|
||||
ScriptRootLogger + ScriptLoggerFactory (and, transitively, Microsoft.CodeAnalysis
|
||||
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
|
||||
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
|
||||
owns DataValueSnapshot. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode (WP6 "Browse device…"
|
||||
/// re-target). Carries the toggled <see cref="Leaf"/> plus the captured browse-folder nesting above it
|
||||
/// (<see cref="FolderPath"/>, root→parent display names) so the browse modal can optionally mirror the
|
||||
/// nesting onto TagGroups on commit. Fired only for <see cref="BrowseNodeKind.Leaf"/> nodes.
|
||||
/// </summary>
|
||||
/// <param name="Leaf">The toggled leaf browse node (its <c>NodeId</c> is the driver full reference).</param>
|
||||
/// <param name="FolderPath">The ancestor folder display names, root→parent (empty for a top-level leaf).</param>
|
||||
public sealed record BrowseLeafSelection(BrowseNode Leaf, IReadOnlyList<string> FolderPath);
|
||||
@@ -16,6 +16,7 @@
|
||||
<NavRailItem Href="/hosts" Text="Host status" />
|
||||
<NavRailItem Href="/clusters" Text="Clusters" />
|
||||
<NavRailItem Href="/uns" Text="UNS" />
|
||||
<NavRailItem Href="/raw" Text="Raw" />
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Drivers · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers/new" class="btn btn-primary btn-sm">New driver</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No driver instances for this cluster.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var d in _rows)
|
||||
{
|
||||
<details style="border-top:1px solid var(--rule)">
|
||||
<summary style="padding:.75rem 1rem;cursor:pointer">
|
||||
<span class="mono">@d.DriverInstanceId</span>
|
||||
· <span>@d.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@d.DriverType</span>
|
||||
@if (!d.Enabled) { <span class="chip chip-idle ms-1">Disabled</span> }
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
<a href="/clusters/@ClusterId/drivers/@d.DriverInstanceId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</div>
|
||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.DriverConfig)</pre>
|
||||
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig))
|
||||
{
|
||||
<div class="text-muted small mt-2">Resilience overrides:</div>
|
||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.ResilienceConfig)</pre>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<DriverInstance>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static string FormatJson(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(raw);
|
||||
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
-448
@@ -1,448 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/abcip"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New AB CIP driver" : "Edit AB CIP driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="AB CIP address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Operation timeout *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Operation settings</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" class="form-check-input" id="enableBrowse" />
|
||||
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
|
||||
</div>
|
||||
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" class="form-check-input" id="enableUdtGroup" />
|
||||
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
|
||||
</div>
|
||||
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Alarm projection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" class="form-check-input" id="enableAlarm" />
|
||||
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Alarm poll interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Connectivity probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe tag path</label>
|
||||
<InputText @bind-Value="_form.ProbeTagPath" class="form-control form-control-sm mono"
|
||||
placeholder="e.g. Program:Main.SomeTag" />
|
||||
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".12s"
|
||||
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="AbCipDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="d">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="ab://gateway/1,0" /></div>
|
||||
<div class="col-md-3"><label class="form-label">PLC family</label>
|
||||
<select class="form-select form-select-sm" @bind="d.PlcFamily">
|
||||
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
||||
</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "AbCip";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
|
||||
private List<AbCipDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static AbCipDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
|
||||
public sealed class AbCipDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||
// (AllowPacking, ConnectionSize) across a load→save.
|
||||
private AbCipDeviceOptions? _source;
|
||||
|
||||
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public AbCipDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
PlcFamily = PlcFamily,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
|
||||
// Alarm projection
|
||||
public bool EnableAlarmProjection { get; set; } = false;
|
||||
public int AlarmPollIntervalSeconds { get; set; } = 1;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public string? ProbeTagPath { get; set; }
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Persistence
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeTagPath = o.Probe.ProbeTagPath,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public AbCipDriverOptions ToOptions(
|
||||
IReadOnlyList<AbCipDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
EnableAlarmProjection = EnableAlarmProjection,
|
||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-399
@@ -1,399 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New AB Legacy driver" : "Edit AB Legacy driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="AB Legacy address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Operation settings *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Operation settings</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Connectivity probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe address</label>
|
||||
<InputText @bind-Value="_form.ProbeAddress" class="form-control form-control-sm mono"
|
||||
placeholder="S:0" />
|
||||
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".10s"
|
||||
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="AbLegacyDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="d">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="10.0.0.10" /></div>
|
||||
<div class="col-md-3"><label class="form-label">PLC family</label>
|
||||
<select class="form-select form-select-sm" @bind="d.PlcFamily">
|
||||
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
||||
</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "AbLegacy";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||
private List<AbLegacyDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbLegacyDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
|
||||
public sealed class AbLegacyDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||
// across a load→save.
|
||||
private AbLegacyDeviceOptions? _source;
|
||||
|
||||
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public AbLegacyDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
PlcFamily = PlcFamily,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public string? ProbeAddress { get; set; } = "S:0";
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Persistence
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeAddress = o.Probe.ProbeAddress,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public AbLegacyDriverOptions ToOptions(
|
||||
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
@* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
|
||||
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
|
||||
no registered typed page. *@
|
||||
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_existing is null)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Edit driver instance · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else if (ResolveComponentType() is { } pageType)
|
||||
{
|
||||
<DynamicComponent Type="pageType" Parameters="BuildParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Edit driver instance · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
<section class="panel notice rise" style="animation-delay:.02s; border-color:var(--alert)">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> has an unknown <code>DriverType</code> value of
|
||||
<strong><span class="mono">@_existing.DriverType</span></strong>. No editor is registered for this type.
|
||||
Likely causes: the row was written by a newer build, or the type-string was corrupted in the database.
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string DriverInstanceId { get; set; } = "";
|
||||
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, Type> _componentMap =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Modbus"] = typeof(ModbusDriverPage),
|
||||
["AbCip"] = typeof(AbCipDriverPage),
|
||||
["AbLegacy"] = typeof(AbLegacyDriverPage),
|
||||
["S7"] = typeof(S7DriverPage),
|
||||
["TwinCat"] = typeof(TwinCATDriverPage),
|
||||
["Focas"] = typeof(FocasDriverPage),
|
||||
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
|
||||
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private Type? ResolveComponentType()
|
||||
=> _componentMap.TryGetValue(_existing!.DriverType, out var t) ? t : null;
|
||||
|
||||
private IDictionary<string, object> BuildParameters()
|
||||
=> new Dictionary<string, object>
|
||||
{
|
||||
["ClusterId"] = ClusterId,
|
||||
["DriverInstanceId"] = DriverInstanceId,
|
||||
};
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
@* Driver type picker — presents a card grid of registered driver types and links to the
|
||||
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
|
||||
@page "/clusters/{ClusterId}/drivers/new"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">New driver · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Pick a driver type</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
@foreach (var t in _types)
|
||||
{
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<a href="/clusters/@ClusterId/drivers/new/@t.Slug" class="card h-100 text-decoration-none">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small mono">@t.Icon</div>
|
||||
<div class="card-title mt-1"><strong>@t.DisplayName</strong></div>
|
||||
<div class="card-text small text-muted">@t.Description</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
private sealed record DriverTypeEntry(string DisplayName, string Slug, string Icon, string Description);
|
||||
|
||||
private static readonly IReadOnlyList<DriverTypeEntry> _types = new[]
|
||||
{
|
||||
new DriverTypeEntry("Modbus TCP", "modbustcp", "[M]", "Modbus/TCP — generic registers/coils via port 502."),
|
||||
new DriverTypeEntry("AbCip", "abcip", "[CIP]", "Allen-Bradley CompactLogix/ControlLogix via CIP."),
|
||||
new DriverTypeEntry("AbLegacy", "ablegacy", "[AB]", "Allen-Bradley PLC-5/SLC-500/MicroLogix via DF1."),
|
||||
new DriverTypeEntry("S7", "s7", "[S7]", "Siemens S7-300/400/1200/1500 via ISO-on-TCP."),
|
||||
new DriverTypeEntry("TwinCat", "twincat", "[TC]", "Beckhoff TwinCAT via ADS."),
|
||||
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
|
||||
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
|
||||
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
|
||||
};
|
||||
}
|
||||
-502
@@ -1,502 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/focas"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New Fanuc FOCAS driver" : "Edit Fanuc FOCAS driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="FOCAS address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Per-operation timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
|
||||
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
|
||||
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
|
||||
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||
<div class="panel-head">Alarm projection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Handle recycle *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Handle recycle</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
|
||||
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Fixed tree *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.17s">
|
||||
<div class="panel-head">Fixed-node tree</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
|
||||
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
|
||||
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
|
||||
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".20s"
|
||||
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="FocasDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="d">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Host address</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="192.168.0.10:8193" /></div>
|
||||
<div class="col-md-3"><label class="form-label">CNC series</label>
|
||||
<select class="form-select form-select-sm" @bind="d.Series">
|
||||
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
||||
</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "Focas";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded, _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||
private List<FocasDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
||||
_form = FormModel.FromOptions(new FocasDriverOptions());
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList());
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static FocasDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
|
||||
public sealed class FocasDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
|
||||
// expose across a load→save.
|
||||
private FocasDeviceOptions? _source;
|
||||
|
||||
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public FocasDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
Series = Series,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Connection
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Alarm projection
|
||||
public bool AlarmProjectionEnabled { get; set; } = false;
|
||||
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
|
||||
|
||||
// Handle recycle
|
||||
public bool HandleRecycleEnabled { get; set; } = false;
|
||||
public int HandleRecycleIntervalMinutes { get; set; } = 60;
|
||||
|
||||
// Fixed tree
|
||||
public bool FixedTreeEnabled { get; set; } = false;
|
||||
public int FixedTreePollIntervalMs { get; set; } = 250;
|
||||
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
|
||||
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(FocasDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
|
||||
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
|
||||
HandleRecycleEnabled = o.HandleRecycle.Enabled,
|
||||
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
|
||||
FixedTreeEnabled = o.FixedTree.Enabled,
|
||||
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
|
||||
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
|
||||
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
|
||||
};
|
||||
|
||||
public FocasDriverOptions ToOptions(
|
||||
IReadOnlyList<FocasDeviceOptions> devices) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = AlarmProjectionEnabled,
|
||||
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
|
||||
},
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = HandleRecycleEnabled,
|
||||
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
|
||||
},
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = FixedTreeEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
|
||||
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
|
||||
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
|
||||
},
|
||||
Devices = devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
-460
@@ -1,460 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/galaxy"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New AVEVA Galaxy driver" : "Edit AVEVA Galaxy driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="Galaxy address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* mxaccessgw connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">mxaccessgw connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gateway endpoint</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" class="form-control form-control-sm mono"
|
||||
placeholder="https://localhost:5001" />
|
||||
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">API key secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" class="form-control form-control-sm mono"
|
||||
placeholder="env:MX_API_KEY" />
|
||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" class="form-check-input" id="gwTls" />
|
||||
<label class="form-check-label" for="gwTls">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">CA certificate path (optional)</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\ca.pem" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Call timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 0 (lifetime of driver).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* MXAccess *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">MXAccess</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Client name</label>
|
||||
<InputText @bind-Value="_form.Galaxy.MxClientName" class="form-control form-control-sm"
|
||||
placeholder="OtOpcUa-Primary" />
|
||||
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Publishing interval (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Write user ID</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = anonymous.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Event pump channel capacity</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Repository *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Galaxy repository</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Discover page size</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5000 objects per page.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" class="form-check-input" id="watchDeploy" />
|
||||
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 500 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" class="form-check-input" id="replayOnLost" />
|
||||
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "GalaxyMxGateway";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
|
||||
_form = new FormModel();
|
||||
_form.Galaxy = GalaxyFormModel.FromRecord(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||
Repository: new GalaxyRepositoryOptions(),
|
||||
Reconnect: new GalaxyReconnectOptions());
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.Galaxy.ToRecord();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
|
||||
|
||||
private static GalaxyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public GalaxyFormModel Galaxy { get; set; } = new();
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.
|
||||
/// All positional-record fields are flattened with a section prefix to avoid name
|
||||
/// collisions. <see cref="FromRecord"/> / <see cref="ToRecord"/> handle translation.
|
||||
/// </summary>
|
||||
public sealed class GalaxyFormModel
|
||||
{
|
||||
// GalaxyGatewayOptions
|
||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||
public bool GatewayUseTls { get; set; } = true;
|
||||
public string? GatewayCaCertificatePath { get; set; }
|
||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
|
||||
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// GalaxyMxAccessOptions
|
||||
public string MxClientName { get; set; } = "OtOpcUa";
|
||||
public int MxPublishingIntervalMs { get; set; } = 1000;
|
||||
public int MxWriteUserId { get; set; } = 0;
|
||||
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
|
||||
|
||||
// GalaxyRepositoryOptions
|
||||
public int RepositoryDiscoverPageSize { get; set; } = 5000;
|
||||
public bool RepositoryWatchDeployEvents { get; set; } = true;
|
||||
|
||||
// GalaxyReconnectOptions
|
||||
public int ReconnectInitialBackoffMs { get; set; } = 500;
|
||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||
public bool ReconnectReplayOnSessionLost { get; set; } = true;
|
||||
|
||||
// GalaxyDriverOptions top-level
|
||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
// Null-coalesce each nested record to its default so that persisted configs
|
||||
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
|
||||
// don't match the camelCase deserializer) don't cause a NullReferenceException.
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
return new()
|
||||
{
|
||||
GatewayEndpoint = gw.Endpoint,
|
||||
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||
GatewayUseTls = gw.UseTls,
|
||||
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||
MxClientName = mx.ClientName,
|
||||
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
||||
MxWriteUserId = mx.WriteUserId,
|
||||
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
||||
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
||||
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
||||
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public GalaxyDriverOptions ToRecord() => new(
|
||||
Gateway: new GalaxyGatewayOptions(
|
||||
Endpoint: GatewayEndpoint,
|
||||
ApiKeySecretRef: GatewayApiKeySecretRef,
|
||||
UseTls: GatewayUseTls,
|
||||
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
|
||||
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
|
||||
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
|
||||
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
|
||||
MxAccess: new GalaxyMxAccessOptions(
|
||||
ClientName: MxClientName,
|
||||
PublishingIntervalMs: MxPublishingIntervalMs,
|
||||
WriteUserId: MxWriteUserId,
|
||||
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
|
||||
Repository: new GalaxyRepositoryOptions(
|
||||
DiscoverPageSize: RepositoryDiscoverPageSize,
|
||||
WatchDeployEvents: RepositoryWatchDeployEvents),
|
||||
Reconnect: new GalaxyReconnectOptions(
|
||||
InitialBackoffMs: ReconnectInitialBackoffMs,
|
||||
MaxBackoffMs: ReconnectMaxBackoffMs,
|
||||
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
||||
{
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-572
@@ -1,572 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New Modbus/TCP driver" : "Edit Modbus/TCP driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="Modbus address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Transport *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Transport</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host</label>
|
||||
<InputText @bind-Value="_form.Host" class="form-control form-control-sm" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Port</label>
|
||||
<InputNumber @bind-Value="_form.Port" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Unit ID (slave ID)</label>
|
||||
<InputNumber @bind-Value="_form.UnitId" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">PLC family</label>
|
||||
<InputSelect @bind-Value="_form.Family" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<InputSelect @bind-Value="_form.MelsecSubFamily" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Only used when Family = MELSEC.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.AutoReconnect" class="form-check-input" id="autoReconnect" />
|
||||
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Batch sizes *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Batch sizes</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerRead" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per write</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 123.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max coils per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxCoilsPerRead" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 2000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max read gap (coalescing)</label>
|
||||
<InputNumber @bind-Value="_form.MaxReadGap" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Write options *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Write options</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" class="form-check-input" id="useFC15" />
|
||||
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" class="form-check-input" id="useFC16" />
|
||||
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.DisableFC23" class="form-check-input" id="disableFC23" />
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" class="form-check-input" id="writeOnChangeOnly" />
|
||||
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Keep-alive *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">TCP keep-alive</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" class="form-check-input" id="kaEnabled" />
|
||||
<label class="form-check-label" for="kaEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Time (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Idle time before first probe. Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Between probes once started. Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveRetryCount" class="form-control form-control-sm" />
|
||||
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect backoff *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = immediate retry.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2.0 (doubles each step).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.16s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe register address</label>
|
||||
<InputNumber @bind-Value="_form.ProbeAddress" class="form-control form-control-sm" />
|
||||
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "Modbus";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
||||
|
||||
private static ModbusDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
|
||||
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Transport
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public int UnitId { get; set; } = 1;
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Family
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Transport flags
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// Batch sizes (using int; clamped to ushort on ToOptions)
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
// Write options
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool DisableFC23 { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
|
||||
// Keep-alive
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSeconds { get; set; } = 30;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
// Reconnect backoff
|
||||
public int ReconnectInitialDelaySeconds { get; set; } = 0;
|
||||
public int ReconnectMaxDelaySeconds { get; set; } = 30;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int ProbeAddress { get; set; } = 0;
|
||||
|
||||
// Auto-prohibit re-probe (0 = disabled)
|
||||
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0;
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Persistence
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(ModbusDriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
UnitId = o.UnitId,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Family = o.Family,
|
||||
MelsecSubFamily = o.MelsecSubFamily,
|
||||
AutoReconnect = o.AutoReconnect,
|
||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
||||
MaxReadGap = o.MaxReadGap,
|
||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = o.DisableFC23,
|
||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
|
||||
KeepAliveRetryCount = o.KeepAlive.RetryCount,
|
||||
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
|
||||
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
|
||||
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeAddress = o.Probe.ProbeAddress,
|
||||
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
|
||||
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public ModbusDriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
|
||||
},
|
||||
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
|
||||
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
|
||||
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
|
||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = DisableFC23,
|
||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||
AutoReconnect = AutoReconnect,
|
||||
KeepAlive = new ModbusKeepAliveOptions
|
||||
{
|
||||
Enabled = KeepAliveEnabled,
|
||||
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
|
||||
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
RetryCount = KeepAliveRetryCount,
|
||||
},
|
||||
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
|
||||
Reconnect = new ModbusReconnectOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
|
||||
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
|
||||
BackoffMultiplier = ReconnectBackoffMultiplier,
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-554
@@ -1,554 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New OPC UA Client driver" : "Edit OPC UA Client driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="OPC UA address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Endpoint *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Endpoint</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<InputText @bind-Value="_form.OpcUa.EndpointUrl" class="form-control form-control-sm mono"
|
||||
placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.BrowseRoot" class="form-control form-control-sm mono"
|
||||
placeholder="i=85" />
|
||||
<div class="form-text">Restrict mirroring to a sub-tree.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Application URI</label>
|
||||
<InputText @bind-Value="_form.OpcUa.ApplicationUri" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Session name</label>
|
||||
<InputText @bind-Value="_form.OpcUa.SessionName" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" class="form-check-input" id="autoAcceptCerts" />
|
||||
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Per-endpoint connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 3 s — failover sweep budget.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Operation timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Session timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 120 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Keep-alive interval (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Reconnect period (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max discovered nodes</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max browse depth</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
|
||||
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
|
||||
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
|
||||
Validate="EndpointUrlRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="e">
|
||||
<td class="mono">@e.Url</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="e">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<input class="form-control form-control-sm mono" @bind="e.Url"
|
||||
placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Security *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Security</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security mode</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security policy</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Authentication *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Authentication</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Auth type</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.AuthType" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Username" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Password" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">User certificate path (PFX/PEM)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\user.pfx" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Namespace mapping *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Namespace mapping</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Target namespace kind</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
|
||||
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
|
||||
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "OpcUaClient";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
|
||||
private List<EndpointUrlRow> _endpoints = [];
|
||||
|
||||
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
|
||||
private string _unsMappingTableJson = "{}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
|
||||
_form = new FormModel();
|
||||
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
|
||||
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
|
||||
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
|
||||
|
||||
private static OpcUaClientDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public OpcUaClientFormModel OpcUa { get; set; } = new();
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
|
||||
/// <c>List<string></c> (a failover list) so the row is a thin wrapper the
|
||||
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
|
||||
/// </summary>
|
||||
public sealed class EndpointUrlRow
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
|
||||
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
|
||||
public string ToUrl() => Url.Trim();
|
||||
|
||||
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
|
||||
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
||||
return "Endpoint URL must start with opc.tcp://";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate endpoint '{row.Url}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
|
||||
/// TimeSpan fields so Blazor InputNumber can bind them.
|
||||
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
|
||||
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
|
||||
/// survives round-trip via the original deserialized record, re-serialized unchanged.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientFormModel
|
||||
{
|
||||
// Connection
|
||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||
public string? BrowseRoot { get; set; }
|
||||
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
|
||||
public string SessionName { get; set; } = "OtOpcUa-Gateway";
|
||||
public bool AutoAcceptCertificates { get; set; } = false;
|
||||
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public int SessionTimeoutSeconds { get; set; } = 120;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 5;
|
||||
public int ReconnectPeriodSeconds { get; set; } = 5;
|
||||
public int MaxDiscoveredNodes { get; set; } = 10_000;
|
||||
public int MaxBrowseDepth { get; set; } = 10;
|
||||
|
||||
// Security
|
||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
// Authentication
|
||||
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? UserCertificatePath { get; set; }
|
||||
public string? UserCertificatePassword { get; set; }
|
||||
|
||||
// Namespace mapping
|
||||
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
|
||||
|
||||
// Diagnostics
|
||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
// Preserved read-only collection (round-tripped unchanged from original record)
|
||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
|
||||
|
||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
||||
{
|
||||
EndpointUrl = r.EndpointUrl,
|
||||
BrowseRoot = r.BrowseRoot,
|
||||
ApplicationUri = r.ApplicationUri,
|
||||
SessionName = r.SessionName,
|
||||
AutoAcceptCertificates = r.AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
|
||||
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
|
||||
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
|
||||
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
|
||||
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = r.MaxBrowseDepth,
|
||||
SecurityMode = r.SecurityMode,
|
||||
SecurityPolicy = r.SecurityPolicy,
|
||||
AuthType = r.AuthType,
|
||||
Username = r.Username,
|
||||
Password = r.Password,
|
||||
UserCertificatePath = r.UserCertificatePath,
|
||||
UserCertificatePassword = r.UserCertificatePassword,
|
||||
TargetNamespaceKind = r.TargetNamespaceKind,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
_unsMappingTable = r.UnsMappingTable,
|
||||
};
|
||||
|
||||
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
EndpointUrls = endpointUrls,
|
||||
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
|
||||
ApplicationUri = ApplicationUri,
|
||||
SessionName = SessionName,
|
||||
AutoAcceptCertificates = AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
|
||||
MaxDiscoveredNodes = MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = MaxBrowseDepth,
|
||||
SecurityMode = SecurityMode,
|
||||
SecurityPolicy = SecurityPolicy,
|
||||
AuthType = AuthType,
|
||||
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
|
||||
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
|
||||
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
|
||||
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
|
||||
TargetNamespaceKind = TargetNamespaceKind,
|
||||
UnsMappingTable = _unsMappingTable,
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-356
@@ -1,356 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/s7"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.S7
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New Siemens S7 driver" : "Edit Siemens S7 driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="S7 address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="s7Host">Host</label>
|
||||
<InputText id="s7Host" @bind-Value="_form.Host"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="192.168.0.1" />
|
||||
<div class="form-text">PLC IP address or hostname.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7Port">Port</label>
|
||||
<InputNumber id="s7Port" @bind-Value="_form.Port"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">ISO-on-TCP; usually 102.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="s7CpuType">CPU type</label>
|
||||
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType"
|
||||
class="form-select form-select-sm">
|
||||
@foreach (var v in Enum.GetValues<S7CpuType>())
|
||||
{
|
||||
<option value="@v">@v</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="s7Rack">Rack</label>
|
||||
<InputNumber id="s7Rack" @bind-Value="_form.Rack"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Almost always 0.</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="s7Slot">Slot</label>
|
||||
<InputNumber id="s7Slot" @bind-Value="_form.Slot"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">S7-300/400 = 2; S7-1200/1500 = 0.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
|
||||
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
|
||||
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
|
||||
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "S7";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded, _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
||||
_form = FormModel.FromOptions(new S7DriverOptions());
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.ToOptions();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(), _jsonOpts);
|
||||
|
||||
private static S7DriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Connection
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 102;
|
||||
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
|
||||
public short Rack { get; set; } = 0;
|
||||
public short Slot { get; set; } = 0;
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(S7DriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
CpuType = o.CpuType,
|
||||
Rack = o.Rack,
|
||||
Slot = o.Slot,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public S7DriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
CpuType = CpuType,
|
||||
Rack = Rack,
|
||||
Slot = Slot,
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new S7ProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
-410
@@ -1,410 +0,0 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new/twincat"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New Beckhoff TwinCAT driver" : "Edit Beckhoff TwinCAT driver") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
|
||||
|
||||
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
|
||||
|
||||
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
|
||||
{
|
||||
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="TwinCAT address"
|
||||
CurrentAddress="@_pickedAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Options *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Options</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s per operation.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
|
||||
</div>
|
||||
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
|
||||
</div>
|
||||
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
|
||||
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
|
||||
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
|
||||
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
|
||||
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".11s"
|
||||
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="TwinCATDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="d">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="192.168.0.1.1.1:851" /></div>
|
||||
<div class="col-md-6"><label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
|
||||
</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const string DriverTypeKey = "TwinCat";
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
|
||||
private DriverInstance? _existing;
|
||||
private bool _loaded, _busy;
|
||||
private string? _error;
|
||||
|
||||
// Address picker state
|
||||
private bool _showPicker;
|
||||
private string _pickedAddress = "";
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
|
||||
private List<TwinCATDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
||||
_form = FormModel.FromOptions(new TwinCATDriverOptions());
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _identityModel.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _identityModel.Name,
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = _identityModel.Enabled,
|
||||
DriverConfig = configJson,
|
||||
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _identityModel.Name;
|
||||
entity.Enabled = _identityModel.Enabled;
|
||||
entity.DriverConfig = configJson;
|
||||
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static TwinCATDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
|
||||
public sealed class TwinCATDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
|
||||
// expose across a load→save.
|
||||
private TwinCATDeviceOptions? _source;
|
||||
|
||||
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public TwinCATDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate device host address '{row.HostAddress}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// The Devices collection is kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Options
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
public bool UseNativeNotifications { get; set; } = true;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public int NotificationMaxDelayMs { get; set; } = 0;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
UseNativeNotifications = o.UseNativeNotifications,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public TwinCATDriverOptions ToOptions(
|
||||
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
UseNativeNotifications = UseNativeNotifications,
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = NotificationMaxDelayMs,
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
Devices = devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@page "/dev/context-menu-demo"
|
||||
@* Dev-only host page to live-verify the reusable ContextMenu component. *@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>ContextMenu demo</PageTitle>
|
||||
|
||||
<h1>ContextMenu demo</h1>
|
||||
<p>Right-click the card below, or use the <strong>⋯</strong> button.</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:1rem; margin:1rem 0;">
|
||||
<ContextMenu Items="_items" ShowEllipsis="false">
|
||||
<div style="padding:2rem 3rem; border:1px dashed var(--bs-border-color); border-radius:.5rem; user-select:none;">
|
||||
Right-click me
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
||||
<ContextMenu Items="_items" ShowEllipsis="true" />
|
||||
</div>
|
||||
|
||||
<p>Last action: <strong>@_lastAction</strong></p>
|
||||
|
||||
@code {
|
||||
private string _lastAction = "(none)";
|
||||
|
||||
private IReadOnlyList<ContextMenuItem> _items => new List<ContextMenuItem>
|
||||
{
|
||||
new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") },
|
||||
new() { Label = "Duplicate", Icon = "📄", OnClick = () => Set("Duplicate") },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Delete", Icon = "🗑️", Disabled = true, OnClick = () => Set("Delete") },
|
||||
};
|
||||
|
||||
private Task Set(string action)
|
||||
{
|
||||
_lastAction = action;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@page "/raw"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@* Peer of GlobalUns (/uns) for the v3 Raw project tree — the cluster-rooted
|
||||
Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy.
|
||||
Loads the eager roots on init, then hands off to RawTree, which lazily expands
|
||||
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
|
||||
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
|
||||
|
||||
<PageTitle>Raw</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Raw</h4>
|
||||
<span class="text-muted small">Changes apply on the next deployment.</span>
|
||||
</div>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span>Project tree</span>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
||||
</div>
|
||||
}
|
||||
else if (_loadError is not null)
|
||||
{
|
||||
<div style="padding:1rem" class="text-danger">@_loadError</div>
|
||||
}
|
||||
else if (_roots.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No clusters yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="padding:.5rem 1rem">
|
||||
<RawTree Roots="_roots" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
||||
private bool _loading = true;
|
||||
private string? _loadError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_roots = await Svc.LoadRootsAsync();
|
||||
// Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is
|
||||
// visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are
|
||||
// eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay
|
||||
// collapsed and load their folders/drivers on first expand.
|
||||
foreach (var enterprise in _roots)
|
||||
enterprise.Expanded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loadError = $"Failed to load the Raw tree: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
@* Reusable right-click / context menu.
|
||||
|
||||
Consumers get two (independently optional) triggers over one item list:
|
||||
• ChildContent — wrapped as a right-clickable surface (browser menu suppressed).
|
||||
• ShowEllipsis — a "⋯" button, the keyboard/touch fallback for the right-click.
|
||||
|
||||
Mouse/touch open the menu anchored at the pointer (fixed positioning). Keyboard
|
||||
activation of the "⋯" button carries no pointer coordinates, so the menu instead
|
||||
anchors just below the button (absolute positioning inside the trigger wrap).
|
||||
Outside-click (or a right-click elsewhere) closes via a transparent full-viewport
|
||||
backdrop — no JS alert/confirm. Keyboard: Arrow up/down move selection, Enter/Space
|
||||
activate, Esc closes; focus returns to the "⋯" trigger on close. *@
|
||||
|
||||
@if (ChildContent is not null)
|
||||
{
|
||||
<span class="ctx-surface" @oncontextmenu="OpenAt" @oncontextmenu:preventDefault="true">
|
||||
@ChildContent
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (_open)
|
||||
{
|
||||
@* Transparent catch-all: any click — or a right-click — that misses the menu closes it. *@
|
||||
<div class="ctx-backdrop" @onclick="Close" @oncontextmenu="Close" @oncontextmenu:preventDefault="true"></div>
|
||||
}
|
||||
|
||||
@if (ShowEllipsis)
|
||||
{
|
||||
<span class="ctx-ellipsis-wrap">
|
||||
<button type="button" class="ctx-ellipsis" title="More actions"
|
||||
aria-haspopup="true" aria-expanded="@_open" @ref="_ellipsisRef" @onclick="OpenAt">
|
||||
⋯@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
|
||||
</button>
|
||||
@if (_open && _anchorToEllipsis)
|
||||
{
|
||||
@RenderMenu("ctx-menu ctx-menu--anchored", null)
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (_open && !_anchorToEllipsis)
|
||||
{
|
||||
@RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px")
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo Culture =
|
||||
System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
/// <summary>The rows to show. Separators are permitted.</summary>
|
||||
[Parameter] public IReadOnlyList<ContextMenuItem> Items { get; set; } = Array.Empty<ContextMenuItem>();
|
||||
|
||||
/// <summary>Wrapped as the right-clickable surface. Optional.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Render the "⋯" trigger button (keyboard/touch fallback).</summary>
|
||||
[Parameter] public bool ShowEllipsis { get; set; } = true;
|
||||
|
||||
private bool _open;
|
||||
private double _x;
|
||||
private double _y;
|
||||
private bool _anchorToEllipsis;
|
||||
private int _activeIndex = -1;
|
||||
private ElementReference _menuRef;
|
||||
private ElementReference _ellipsisRef;
|
||||
private bool _focusMenuPending;
|
||||
private bool _focusEllipsisPending;
|
||||
|
||||
// Mouse right-click / touch-tap carry real pointer coordinates → anchor the menu there
|
||||
// (fixed). Keyboard activation of the "⋯" button dispatches a click with clientX/Y == 0
|
||||
// (no pointer) → anchor the menu below the button instead of at viewport origin (0,0).
|
||||
private void OpenAt(MouseEventArgs e)
|
||||
{
|
||||
_anchorToEllipsis = e.ClientX == 0 && e.ClientY == 0;
|
||||
_x = e.ClientX;
|
||||
_y = e.ClientY;
|
||||
_activeIndex = FirstEnabledIndex();
|
||||
_open = true;
|
||||
_focusMenuPending = true;
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
if (!_open) return;
|
||||
var returnFocus = _anchorToEllipsis; // keyboard-opened via the ⋯ button — restore its focus
|
||||
_open = false;
|
||||
_activeIndex = -1;
|
||||
_anchorToEllipsis = false;
|
||||
if (returnFocus && ShowEllipsis) _focusEllipsisPending = true;
|
||||
}
|
||||
|
||||
private async Task Activate(ContextMenuItem item)
|
||||
{
|
||||
if (item.Disabled || item.IsSeparator) return;
|
||||
Close();
|
||||
if (item.OnClick is not null) await item.OnClick.Invoke();
|
||||
}
|
||||
|
||||
private async Task OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case "Escape":
|
||||
Close();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
Move(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
Move(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (_activeIndex >= 0 && _activeIndex < Items.Count)
|
||||
{
|
||||
await Activate(Items[_activeIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the selection in the given direction, skipping separators and
|
||||
// disabled rows, wrapping around the ends.
|
||||
private void Move(int dir)
|
||||
{
|
||||
if (Items.Count == 0) return;
|
||||
var i = _activeIndex;
|
||||
for (var step = 0; step < Items.Count; step++)
|
||||
{
|
||||
i = (i + dir + Items.Count) % Items.Count;
|
||||
var item = Items[i];
|
||||
if (!item.IsSeparator && !item.Disabled)
|
||||
{
|
||||
_activeIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int FirstEnabledIndex()
|
||||
{
|
||||
for (var i = 0; i < Items.Count; i++)
|
||||
{
|
||||
if (!Items[i].IsSeparator && !Items[i].Disabled) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The menu body, shared between the pointer-anchored (fixed) and ellipsis-anchored
|
||||
// (absolute) placements so the item loop lives in exactly one place.
|
||||
private RenderFragment RenderMenu(string cssClass, string? style) => __builder =>
|
||||
{
|
||||
<div class="@cssClass" style="@style" role="menu" tabindex="0" @ref="_menuRef" @onkeydown="OnKeyDown">
|
||||
@for (var i = 0; i < Items.Count; i++)
|
||||
{
|
||||
var item = Items[i];
|
||||
if (item.IsSeparator)
|
||||
{
|
||||
<div class="ctx-separator" role="separator"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var index = i;
|
||||
<button type="button" role="menuitem"
|
||||
class="ctx-item @(index == _activeIndex ? "active" : "")"
|
||||
disabled="@item.Disabled"
|
||||
@onclick="() => Activate(item)">
|
||||
@if (!string.IsNullOrEmpty(item.Icon))
|
||||
{
|
||||
<span class="ctx-icon">@item.Icon</span>
|
||||
}
|
||||
<span class="ctx-label">@item.Label</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_focusMenuPending)
|
||||
{
|
||||
_focusMenuPending = false;
|
||||
try { await _menuRef.FocusAsync(); }
|
||||
catch { /* the menu may have closed before the focus round-trip completed */ }
|
||||
}
|
||||
if (_focusEllipsisPending)
|
||||
{
|
||||
_focusEllipsisPending = false;
|
||||
try { await _ellipsisRef.FocusAsync(); }
|
||||
catch { /* the trigger may have unmounted */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme
|
||||
tokens (theme.css) so the menu tracks the app palette in light and dark. */
|
||||
|
||||
.ctx-surface {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Positioned wrap so a keyboard-opened menu can anchor below the "⋯" button. */
|
||||
.ctx-ellipsis-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* The "⋯" fallback trigger — a compact, quiet icon button. */
|
||||
.ctx-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctx-ellipsis:hover,
|
||||
.ctx-ellipsis[aria-expanded="true"] {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Transparent full-viewport catch-all; sits just under the menu. */
|
||||
.ctx-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1080;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 1081; /* above the backdrop, below toasts (1090) */
|
||||
min-width: 11rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Keyboard-opened placement: anchored just below the "⋯" trigger instead of the pointer. */
|
||||
.ctx-menu--anchored {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctx-item:hover:not(:disabled),
|
||||
.ctx-item.active:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.ctx-item:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 1.1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctx-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ctx-separator {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0.3rem;
|
||||
background: var(--bs-border-color);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in a <see cref="ContextMenu"/>. An item is either an actionable
|
||||
/// row (<see cref="Label"/> + <see cref="OnClick"/>) or a visual divider
|
||||
/// (<see cref="IsSeparator"/> = true, in which case the other members are
|
||||
/// ignored). Use the <see cref="Separator"/> factory for dividers.
|
||||
/// </summary>
|
||||
public sealed class ContextMenuItem
|
||||
{
|
||||
/// <summary>Text shown for the row.</summary>
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the row is activated (click / Enter). Async so per-node menu
|
||||
/// actions (delete, redeploy, rename) can await their service calls without an
|
||||
/// exception-swallowing <c>async void</c>; the menu awaits this and re-renders.
|
||||
/// A synchronous handler returns <see cref="Task.CompletedTask"/>.
|
||||
/// </summary>
|
||||
public Func<Task>? OnClick { get; init; }
|
||||
|
||||
/// <summary>Optional leading icon — an emoji or single glyph.</summary>
|
||||
public string? Icon { get; init; }
|
||||
|
||||
/// <summary>Renders the row greyed-out and non-interactive.</summary>
|
||||
public bool Disabled { get; init; }
|
||||
|
||||
/// <summary>When true this entry is a horizontal divider, not a row.</summary>
|
||||
public bool IsSeparator { get; init; }
|
||||
|
||||
/// <summary>A horizontal divider between groups of items.</summary>
|
||||
public static ContextMenuItem Separator() => new() { IsSeparator = true };
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
@* Embeddable AB CIP device (connection endpoint) form body — authors HostAddress/PlcFamily into the
|
||||
Device's DeviceConfig (PascalCase, matching AbCipDeviceOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host address</label>
|
||||
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="ab://gateway/1,0" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">PLC family</label>
|
||||
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<AbCipPlcFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private AbCipDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = AbCipDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for an AB CIP <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
|
||||
/// <c>AbCipDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class AbCipDeviceModel
|
||||
{
|
||||
/// <summary>libplctag gateway/path host address (e.g. <c>ab://gateway/1,0</c>).</summary>
|
||||
public string HostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Allen-Bradley PLC family.</summary>
|
||||
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="AbCipDeviceModel"/>.</returns>
|
||||
public static AbCipDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new AbCipDeviceModel
|
||||
{
|
||||
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbCipPlcFamily.ControlLogix),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// keys over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
@* Embeddable AB Legacy device (connection endpoint) form body — authors HostAddress/PlcFamily into the
|
||||
Device's DeviceConfig (PascalCase, matching AbLegacyDeviceOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host address</label>
|
||||
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.0.0.10" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">PLC family</label>
|
||||
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private AbLegacyDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = AbLegacyDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for an AB Legacy <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
|
||||
/// <c>AbLegacyDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDeviceModel
|
||||
{
|
||||
/// <summary>PLC host / IP address.</summary>
|
||||
public string HostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Allen-Bradley legacy PLC family (SLC500 / MicroLogix / PLC-5).</summary>
|
||||
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="AbLegacyDeviceModel"/>.</returns>
|
||||
public static AbLegacyDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new AbLegacyDeviceModel
|
||||
{
|
||||
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbLegacyPlcFamily.Slc500),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// keys over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
@* Embeddable FOCAS device (connection endpoint) form body — authors HostAddress/Series into the
|
||||
Device's DeviceConfig (PascalCase, matching FocasDeviceOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host address</label>
|
||||
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.10:8193" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">CNC series</label>
|
||||
<InputSelect @bind-Value="_model.Series" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<FocasCncSeries>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase HostAddress/Series) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private FocasDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = FocasDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for a FOCAS <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>Series</c> keys that
|
||||
/// <c>FocasDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class FocasDeviceModel
|
||||
{
|
||||
/// <summary>CNC host address (host or host:port, e.g. <c>192.168.0.10:8193</c>).</summary>
|
||||
public string HostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>CNC series, enabling per-series address validation.</summary>
|
||||
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="FocasDeviceModel"/>.</returns>
|
||||
public static FocasDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new FocasDeviceModel
|
||||
{
|
||||
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||
Series = TagConfigJson.GetEnum(o, "Series", FocasCncSeries.Unknown),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// keys over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||
TagConfigJson.Set(_bag, "Series", Series.ToString());
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
@* Galaxy device form — informational only. Galaxy connects to a single mxaccessgw gateway configured on
|
||||
the driver, so there is no per-device connection endpoint to author. The DeviceConfig is round-tripped
|
||||
verbatim (Test-connect uses the driver's gateway settings via the merged config). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
Galaxy connects to a single <strong>mxaccessgw</strong> gateway configured on the driver — there is
|
||||
no per-device endpoint to author here. Edit the gateway endpoint / API key on the driver's config.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — Galaxy has no per-device endpoint).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private GalaxyDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = GalaxyDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the (preserved) DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Device model for the AVEVA Galaxy driver. Galaxy connects to a SINGLE mxaccessgw gateway configured
|
||||
/// on the <b>driver</b> (nested Gateway/MxAccess records) — there is no flat per-device connection
|
||||
/// endpoint to author, so this model authors no endpoint keys. It simply round-trips the DeviceConfig
|
||||
/// JSON verbatim (preserving any keys) so the device row stays valid. (Flagged: Galaxy has no v3
|
||||
/// endpoint→DeviceConfig split; its connection lives on the driver config.)
|
||||
/// </summary>
|
||||
public sealed class GalaxyDeviceModel
|
||||
{
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, retaining every original key.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="GalaxyDeviceModel"/>.</returns>
|
||||
public static GalaxyDeviceModel FromJson(string? json)
|
||||
=> new() { _bag = TagConfigJson.ParseOrNew(json) };
|
||||
|
||||
/// <summary>Serializes the (preserved) DeviceConfig back to a JSON string.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson() => TagConfigJson.Serialize(_bag);
|
||||
|
||||
/// <summary>Validation hook; Galaxy device rows carry no endpoint, so always valid.</summary>
|
||||
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
|
||||
public string? Validate() => null;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
@* Embeddable Modbus device (connection endpoint) form body — authors Host/Port/UnitId into the
|
||||
Device's DeviceConfig (PascalCase, matching ModbusDriverOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host</label>
|
||||
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Port</label>
|
||||
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Unit ID (slave ID)</label>
|
||||
<InputNumber @bind-Value="_model.UnitId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase Host/Port/UnitId) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private ModbusDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = ModbusDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for a Modbus <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>UnitId</c> keys that
|
||||
/// <c>ModbusDriverOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class ModbusDeviceModel
|
||||
{
|
||||
/// <summary>PLC host / IP address.</summary>
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
|
||||
/// <summary>Modbus/TCP port (usually 502).</summary>
|
||||
public int Port { get; set; } = 502;
|
||||
|
||||
/// <summary>Modbus unit / slave id (0-255).</summary>
|
||||
public int UnitId { get; set; } = 1;
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="ModbusDeviceModel"/>.</returns>
|
||||
public static ModbusDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new ModbusDeviceModel
|
||||
{
|
||||
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
|
||||
Port = TagConfigJson.GetInt(o, "Port", 502),
|
||||
UnitId = TagConfigJson.GetInt(o, "UnitId", 1),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// keys over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "Host", Host);
|
||||
TagConfigJson.Set(_bag, "Port", Port);
|
||||
TagConfigJson.Set(_bag, "UnitId", UnitId);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
@* Embeddable OPC UA Client device (connection endpoint) form body — authors EndpointUrl into the
|
||||
Device's DeviceConfig. Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Endpoint</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<InputText @bind-Value="_model.EndpointUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">The upstream server. Used only when the driver's EndpointUrls failover list is empty.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase EndpointUrl) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private OpcUaClientDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = OpcUaClientDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for an OPC UA Client <c>Device</c>'s connection endpoint. Authors the PascalCase
|
||||
/// <c>EndpointUrl</c> key that <c>OpcUaClientDriverOptions</c> binds. (The driver-level <c>EndpointUrls</c>
|
||||
/// failover list stays on the driver config; a device's <c>EndpointUrl</c> is used only when that list
|
||||
/// is empty.) Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientDeviceModel
|
||||
{
|
||||
/// <summary>The single upstream server endpoint URL (opc.tcp://…).</summary>
|
||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="OpcUaClientDeviceModel"/>.</returns>
|
||||
public static OpcUaClientDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new OpcUaClientDeviceModel
|
||||
{
|
||||
EndpointUrl = TagConfigJson.GetString(o, "EndpointUrl") ?? "opc.tcp://localhost:4840",
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase
|
||||
/// <c>EndpointUrl</c> key over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "EndpointUrl", EndpointUrl);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EndpointUrl)) return "Endpoint URL is required.";
|
||||
if (!EndpointUrl.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
||||
return "Endpoint URL must start with opc.tcp://";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
@* Embeddable S7 device (connection endpoint) form body — authors Host/Port/Rack/Slot into the
|
||||
Device's DeviceConfig (PascalCase, matching S7DriverOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host</label>
|
||||
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Port</label>
|
||||
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Rack</label>
|
||||
<InputNumber @bind-Value="_model.Rack" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Slot</label>
|
||||
<InputNumber @bind-Value="_model.Slot" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase Host/Port/Rack/Slot) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private S7DeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = S7DeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for an S7 <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>Rack</c>/<c>Slot</c> keys that
|
||||
/// <c>S7DriverOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class S7DeviceModel
|
||||
{
|
||||
/// <summary>PLC host / IP address.</summary>
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
|
||||
/// <summary>ISO-on-TCP port (usually 102).</summary>
|
||||
public int Port { get; set; } = 102;
|
||||
|
||||
/// <summary>PLC rack number (almost always 0).</summary>
|
||||
public int Rack { get; set; } = 0;
|
||||
|
||||
/// <summary>PLC slot number (S7-300/400 = 2; S7-1200/1500 = 0).</summary>
|
||||
public int Slot { get; set; } = 0;
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="S7DeviceModel"/>.</returns>
|
||||
public static S7DeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new S7DeviceModel
|
||||
{
|
||||
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
|
||||
Port = TagConfigJson.GetInt(o, "Port", 102),
|
||||
Rack = TagConfigJson.GetInt(o, "Rack", 0),
|
||||
Slot = TagConfigJson.GetInt(o, "Slot", 0),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// keys over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "Host", Host);
|
||||
TagConfigJson.Set(_bag, "Port", Port);
|
||||
TagConfigJson.Set(_bag, "Rack", Rack);
|
||||
TagConfigJson.Set(_bag, "Slot", Slot);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
@* Embeddable TwinCAT device (connection endpoint) form body — authors HostAddress (AMS Net Id:port) into
|
||||
the Device's DeviceConfig (PascalCase, matching TwinCATDeviceOptions). Hosted by DeviceModal. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
|
||||
<section class="panel rise" style="animation-delay:.04s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Host address (AMS Net Id:port)</label>
|
||||
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1.1.1:851" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
|
||||
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (PascalCase HostAddress) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
|
||||
|
||||
private TwinCATDeviceModel _model = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_model = TwinCATDeviceModel.FromJson(DeviceConfigJson);
|
||||
_lastParsed = DeviceConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
|
||||
public string GetConfigJson() => _model.ToJson();
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DeviceConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for a TwinCAT <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
|
||||
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c> key (the AMS Net Id:port host string) that
|
||||
/// <c>TwinCATDeviceOptions</c> binds — the exact casing the seed and runtime probe
|
||||
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDeviceModel
|
||||
{
|
||||
/// <summary>AMS Net Id:port host string (e.g. <c>192.168.0.1.1.1:851</c>).</summary>
|
||||
public string HostAddress { get; set; } = "";
|
||||
|
||||
private System.Text.Json.Nodes.JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
|
||||
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
|
||||
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
|
||||
/// <returns>The populated <see cref="TwinCATDeviceModel"/>.</returns>
|
||||
public static TwinCATDeviceModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new TwinCATDeviceModel
|
||||
{
|
||||
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
|
||||
/// key over the preserved key bag.</summary>
|
||||
/// <returns>The serialised DeviceConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@* Create / edit modal for a raw-tree Device's connection endpoint (v3 endpoint→DeviceConfig split).
|
||||
Dispatches to the right per-driver device form by DriverType. Test-connect builds the merged
|
||||
Driver+Device config transiently (so unsaved edits probe correctly), via DriverDeviceConfigMerger —
|
||||
the same merge LoadMergedProbeConfigAsync uses. The coordinator wires this into RawTree via @bind-Visible. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(_isNew ? "New device" : "Edit device") · <span class="mono">@_driverType</span></h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_loadError is not null)
|
||||
{
|
||||
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
|
||||
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input type="checkbox" class="form-check-input" id="devEnabled" @bind="_enabled" />
|
||||
<label class="form-check-label" for="devEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (_driverType)
|
||||
{
|
||||
case DriverTypeNames.Modbus:
|
||||
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.S7:
|
||||
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.AbCip:
|
||||
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.AbLegacy:
|
||||
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.TwinCAT:
|
||||
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.FOCAS:
|
||||
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.OpcUaClient:
|
||||
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
|
||||
</div>
|
||||
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
@if (_loadError is null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(_isNew ? "Create device" : "Save changes")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
/// <summary>Two-way visibility callback.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The device to edit; null ⇒ create a new device under <see cref="DriverInstanceId"/>.</summary>
|
||||
[Parameter] public string? DeviceId { get; set; }
|
||||
/// <summary>The owning driver instance (required for create; resolved from the device on edit).</summary>
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
/// <summary>The driver type (required for create; joined in from the device on edit).</summary>
|
||||
[Parameter] public string? DriverType { get; set; }
|
||||
|
||||
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private bool _isNew;
|
||||
private string _driverType = "";
|
||||
private string? _resolvedDriverInstanceId;
|
||||
private string _name = "";
|
||||
private bool _enabled = true;
|
||||
private string _deviceConfigJson = "{}";
|
||||
private byte[] _rowVersion = [];
|
||||
private string _parentDriverConfig = "{}";
|
||||
|
||||
private bool _busy;
|
||||
private string? _loadError;
|
||||
private string? _saveError;
|
||||
private bool _openHandled;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_openHandled)
|
||||
{
|
||||
_openHandled = true;
|
||||
await LoadAsync();
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_openHandled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_busy = false; _loadError = null; _saveError = null;
|
||||
_isNew = string.IsNullOrEmpty(DeviceId);
|
||||
|
||||
if (_isNew)
|
||||
{
|
||||
_resolvedDriverInstanceId = DriverInstanceId;
|
||||
_driverType = DriverType ?? "";
|
||||
_name = "Device1";
|
||||
_enabled = true;
|
||||
_deviceConfigJson = "{}";
|
||||
_rowVersion = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
|
||||
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
|
||||
_resolvedDriverInstanceId = dto.DriverInstanceId;
|
||||
_driverType = dto.DriverType;
|
||||
_name = dto.Name;
|
||||
_enabled = dto.Enabled;
|
||||
_deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig;
|
||||
_rowVersion = dto.RowVersion;
|
||||
}
|
||||
|
||||
// The parent driver's DriverConfig — folded with the (possibly unsaved) DeviceConfig for Test-connect.
|
||||
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
||||
{
|
||||
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
|
||||
_parentDriverConfig = drv?.DriverConfig ?? "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form —
|
||||
/// same merge as <c>LoadMergedProbeConfigAsync</c>, so Test-connect works before the device is saved.</summary>
|
||||
private string BuildMergedProbeConfig()
|
||||
=> DriverDeviceConfigMerger.Merge(
|
||||
_parentDriverConfig,
|
||||
new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) },
|
||||
Array.Empty<RawTagEntry>());
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true; _saveError = null;
|
||||
try
|
||||
{
|
||||
UnsMutationResult result;
|
||||
if (_isNew)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
||||
{
|
||||
_saveError = "No owning driver instance was supplied.";
|
||||
return;
|
||||
}
|
||||
result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion);
|
||||
}
|
||||
|
||||
if (!result.Ok)
|
||||
{
|
||||
_saveError = result.Error ?? "Save failed.";
|
||||
return;
|
||||
}
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
await SetVisibleAsync(false);
|
||||
}
|
||||
catch (Exception ex) { _saveError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private Task CloseAsync() => SetVisibleAsync(false);
|
||||
|
||||
private async Task SetVisibleAsync(bool value)
|
||||
{
|
||||
Visible = value;
|
||||
_openHandled = value;
|
||||
await VisibleChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
+47
-6
@@ -20,7 +20,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var n in _roots) { @RenderNode(n, 0) }
|
||||
@foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty<string>()) }
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
|
||||
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
||||
|
||||
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
|
||||
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
|
||||
/// false preserves the single-select address-picker behavior.</summary>
|
||||
[Parameter] public bool MultiSelect { get; set; }
|
||||
|
||||
/// <summary>In multi-select mode, the set of currently-selected leaf NodeIds (drives checkbox state +
|
||||
/// row highlight). Owned by the parent; the tree only reads it.</summary>
|
||||
[Parameter] public IReadOnlyCollection<string>? SelectedLeafIds { get; set; }
|
||||
|
||||
/// <summary>In multi-select mode, fired when a leaf's checkbox is toggled — carries the leaf plus its
|
||||
/// captured ancestor-folder display-name path (root→parent) for optional TagGroup mirroring.</summary>
|
||||
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnLeafToggled { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _error;
|
||||
private List<TreeItem>? _roots;
|
||||
@@ -86,10 +99,28 @@
|
||||
await OnNodeSelected.InvokeAsync(item.Node);
|
||||
}
|
||||
|
||||
private RenderFragment RenderNode(TreeItem item, int depth) => __builder =>
|
||||
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
|
||||
{
|
||||
await OnLeafToggled.InvokeAsync(
|
||||
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
|
||||
}
|
||||
|
||||
private bool IsLeafSelected(string nodeId) => SelectedLeafIds?.Contains(nodeId) == true;
|
||||
|
||||
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||
{
|
||||
var list = new List<string>(path.Count + 1);
|
||||
list.AddRange(path);
|
||||
list.Add(segment);
|
||||
return list;
|
||||
}
|
||||
|
||||
private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList<string> ancestorPath) => __builder =>
|
||||
{
|
||||
var indent = $"padding-left:{depth * 18}px";
|
||||
var selectedCls = _selectedNodeIdLocal == item.Node.NodeId ? "bg-primary-subtle" : "";
|
||||
var isMultiLeaf = MultiSelect && item.Node.Kind == BrowseNodeKind.Leaf;
|
||||
var selectedCls = (isMultiLeaf ? IsLeafSelected(item.Node.NodeId) : _selectedNodeIdLocal == item.Node.NodeId)
|
||||
? "bg-primary-subtle" : "";
|
||||
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
|
||||
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
|
||||
{
|
||||
@@ -102,8 +133,18 @@
|
||||
{
|
||||
<span style="width:18px"></span>
|
||||
}
|
||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||
@if (isMultiLeaf)
|
||||
{
|
||||
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
|
||||
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
|
||||
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
|
||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||
}
|
||||
@if (item.Node.Kind == BrowseNodeKind.Leaf)
|
||||
{
|
||||
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
|
||||
@@ -129,7 +170,7 @@
|
||||
@bind="item.Filter" @bind:event="oninput" />
|
||||
@foreach (var c in FilterChildren(item))
|
||||
{
|
||||
@RenderNode(c, depth + 1)
|
||||
@RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
@* Create / edit modal for a raw-tree DriverInstance's channel/protocol config (endpoint lives on the
|
||||
device — see DeviceModal). Dispatches to the right per-driver form body by DriverType; the form body
|
||||
carries its own resilience section. The coordinator wires this into RawTree via @bind-Visible. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(_isNew ? "New driver" : "Configure driver") · <span class="mono">@_driverType</span></h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_loadError is not null)
|
||||
{
|
||||
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Driver name</label>
|
||||
<input class="form-control form-control-sm mono" @bind="_name" placeholder="line3-modbus" />
|
||||
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (_driverType)
|
||||
{
|
||||
case DriverTypeNames.Modbus:
|
||||
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.S7:
|
||||
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.AbCip:
|
||||
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.AbLegacy:
|
||||
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.TwinCAT:
|
||||
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.FOCAS:
|
||||
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.OpcUaClient:
|
||||
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
}
|
||||
|
||||
<p class="form-text mt-3 mb-0">
|
||||
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
|
||||
</p>
|
||||
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
@if (_loadError is null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(_isNew ? "Create driver" : "Save changes")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
/// <summary>Two-way visibility callback.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The driver instance to edit; null ⇒ create a new driver.</summary>
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
// --- Create-mode inputs (ignored on edit) ---
|
||||
/// <summary>The owning cluster (required for create).</summary>
|
||||
[Parameter] public string? ClusterId { get; set; }
|
||||
/// <summary>The containing folder, or null for a cluster-root driver (create).</summary>
|
||||
[Parameter] public string? FolderId { get; set; }
|
||||
/// <summary>The driver type to create (required for create; immutable on edit).</summary>
|
||||
[Parameter] public string? DriverType { get; set; }
|
||||
|
||||
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private bool _isNew;
|
||||
private string _driverType = "";
|
||||
private string _name = "";
|
||||
private string _driverConfigJson = "{}";
|
||||
private string? _resilienceConfig;
|
||||
private byte[] _rowVersion = [];
|
||||
|
||||
private bool _busy;
|
||||
private string? _loadError;
|
||||
private string? _saveError;
|
||||
private bool _openHandled;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_openHandled)
|
||||
{
|
||||
_openHandled = true;
|
||||
await LoadAsync();
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_openHandled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_busy = false; _loadError = null; _saveError = null;
|
||||
_isNew = string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
if (_isNew)
|
||||
{
|
||||
_driverType = DriverType ?? "";
|
||||
_name = "";
|
||||
_driverConfigJson = "{}";
|
||||
_resilienceConfig = null;
|
||||
_rowVersion = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
|
||||
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
|
||||
_driverType = dto.DriverType;
|
||||
_name = dto.Name;
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(dto.DriverConfig) ? "{}" : dto.DriverConfig;
|
||||
_resilienceConfig = dto.ResilienceConfig;
|
||||
_rowVersion = dto.RowVersion;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true; _saveError = null;
|
||||
try
|
||||
{
|
||||
UnsMutationResult result;
|
||||
if (_isNew)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ClusterId))
|
||||
{
|
||||
_saveError = "No owning cluster was supplied.";
|
||||
return;
|
||||
}
|
||||
result = await Svc.CreateDriverAsync(ClusterId, FolderId, _name, _driverType, _driverConfigJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await Svc.UpdateDriverAsync(DriverInstanceId!, _name, _driverConfigJson, _resilienceConfig, _rowVersion);
|
||||
}
|
||||
|
||||
if (!result.Ok)
|
||||
{
|
||||
_saveError = result.Error ?? "Save failed.";
|
||||
return;
|
||||
}
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
await SetVisibleAsync(false);
|
||||
}
|
||||
catch (Exception ex) { _saveError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private Task CloseAsync() => SetVisibleAsync(false);
|
||||
|
||||
private async Task SetVisibleAsync(bool value)
|
||||
{
|
||||
Visible = value;
|
||||
_openHandled = value;
|
||||
await VisibleChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
@* Embeddable AB CIP driver (protocol/operation) config form body. The Devices collection moved out to
|
||||
AbCipDeviceForm in v3 (its devices are separate entities); this form preserves the driver's existing
|
||||
Devices across a load→save and the merge reconstructs the array from the device rows at deploy/probe
|
||||
time. Hosted by the routed AbCipDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the
|
||||
parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||
|
||||
@* Operation timeout *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Operation settings</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync" class="form-check-input" id="enableBrowse" />
|
||||
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
|
||||
</div>
|
||||
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" @bind-Value:after="EmitAsync" class="form-check-input" id="enableUdtGroup" />
|
||||
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
|
||||
</div>
|
||||
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Alarm projection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" @bind-Value:after="EmitAsync" class="form-check-input" id="enableAlarm" />
|
||||
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Alarm poll interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Connectivity probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe tag path</label>
|
||||
<InputText @bind-Value="_form.ProbeTagPath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="e.g. Program:Main.SomeTag" />
|
||||
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
|
||||
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
|
||||
private IReadOnlyList<AbCipDeviceOptions> _preservedDevices = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current protocol/operation config to camelCase JSON, carrying the preserved
|
||||
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||
/// from the device rows at deploy/probe time).</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static AbCipDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices) are preserved on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
|
||||
// Alarm projection
|
||||
public bool EnableAlarmProjection { get; set; } = false;
|
||||
public int AlarmPollIntervalSeconds { get; set; } = 1;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public string? ProbeTagPath { get; set; }
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeTagPath = o.Probe.ProbeTagPath,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public AbCipDriverOptions ToOptions(
|
||||
IReadOnlyList<AbCipDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
EnableAlarmProjection = EnableAlarmProjection,
|
||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
@* Embeddable AB Legacy driver (channel/protocol) config form body. The Devices collection is authored
|
||||
as separate v3 entities (the merge rebuilds the Devices array at deploy/probe time), so this form does
|
||||
NOT edit it — the driver's existing devices are preserved verbatim. Hosted by the routed
|
||||
AbLegacyDriverPage. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and
|
||||
fires DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||
|
||||
@* Operation settings *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Operation settings</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Connectivity probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe address</label>
|
||||
<InputText @bind-Value="_form.ProbeAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="S:0" />
|
||||
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
|
||||
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
|
||||
private IReadOnlyList<AbLegacyDeviceOptions> _preservedDevices = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
|
||||
/// existing Devices array (authored as separate v3 entities).</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public string? ProbeAddress { get; set; } = "S:0";
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Persistence
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeAddress = o.Probe.ProbeAddress,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public AbLegacyDriverOptions ToOptions(
|
||||
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
@* Embeddable Fanuc FOCAS driver (channel/protocol) config form body. Endpoint fields (per-device
|
||||
HostAddress/Series) moved to FocasDeviceForm in v3 (they live in the Device's DeviceConfig / the
|
||||
multi-device Devices collection). Hosted by the routed FocasDriverPage and by DriverConfigModal.
|
||||
Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
|
||||
DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Per-operation timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
|
||||
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
|
||||
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
|
||||
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||
<div class="panel-head">Alarm projection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Handle recycle *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Handle recycle</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
|
||||
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Fixed tree *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.17s">
|
||||
<div class="panel-head">Fixed-node tree</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
|
||||
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
|
||||
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
|
||||
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
// FOCAS is multi-device; the Devices collection is authored on the Raw tree, not on this form.
|
||||
// Preserve whatever devices the persisted config carried so a driver-form load→save round-trip
|
||||
// never drops them.
|
||||
private IReadOnlyList<FocasDeviceOptions> _preservedDevices = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new FocasDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, re-attaching the
|
||||
/// preserved multi-device collection (authored on the Raw tree).</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static FocasDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Connection
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Alarm projection
|
||||
public bool AlarmProjectionEnabled { get; set; } = false;
|
||||
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
|
||||
|
||||
// Handle recycle
|
||||
public bool HandleRecycleEnabled { get; set; } = false;
|
||||
public int HandleRecycleIntervalMinutes { get; set; } = 60;
|
||||
|
||||
// Fixed tree
|
||||
public bool FixedTreeEnabled { get; set; } = false;
|
||||
public int FixedTreePollIntervalMs { get; set; } = 250;
|
||||
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
|
||||
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(FocasDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
|
||||
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
|
||||
HandleRecycleEnabled = o.HandleRecycle.Enabled,
|
||||
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
|
||||
FixedTreeEnabled = o.FixedTree.Enabled,
|
||||
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
|
||||
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
|
||||
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
|
||||
};
|
||||
|
||||
public FocasDriverOptions ToOptions(
|
||||
IReadOnlyList<FocasDeviceOptions> devices) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = AlarmProjectionEnabled,
|
||||
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
|
||||
},
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = HandleRecycleEnabled,
|
||||
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
|
||||
},
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = FixedTreeEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
|
||||
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
|
||||
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
|
||||
},
|
||||
Devices = devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
@* Embeddable AVEVA Galaxy driver config form body. Galaxy connects to a SINGLE mxaccessgw gateway
|
||||
(nested Gateway/MxAccess/Repository/Reconnect records) — there is no flat per-device endpoint to split
|
||||
out, so the whole connection stays here (GalaxyDeviceForm is informational only). Hosted by the routed
|
||||
page + DriverConfigModal. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
|
||||
|
||||
@* mxaccessgw connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">mxaccessgw connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gateway endpoint</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="https://localhost:5001" />
|
||||
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">API key secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="env:MX_API_KEY" />
|
||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" @bind-Value:after="EmitAsync" class="form-check-input" id="gwTls" />
|
||||
<label class="form-check-label" for="gwTls">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">CA certificate path (optional)</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\ca.pem" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Call timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 0 (lifetime of driver).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* MXAccess *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">MXAccess</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Client name</label>
|
||||
<InputText @bind-Value="_form.Galaxy.MxClientName" @bind-Value:after="EmitAsync" class="form-control form-control-sm"
|
||||
placeholder="OtOpcUa-Primary" />
|
||||
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Publishing interval (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Write user ID</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = anonymous.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Event pump channel capacity</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Repository *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Galaxy repository</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Discover page size</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5000 objects per page.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" @bind-Value:after="EmitAsync" class="form-check-input" id="watchDeploy" />
|
||||
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 500 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" @bind-Value:after="EmitAsync" class="form-check-input" id="replayOnLost" />
|
||||
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? CreateDefaultOptions();
|
||||
_form = new FormModel { Galaxy = GalaxyFormModel.FromRecord(opts) };
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||
Repository: new GalaxyRepositoryOptions(),
|
||||
Reconnect: new GalaxyReconnectOptions());
|
||||
|
||||
private static GalaxyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public GalaxyFormModel Galaxy { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.</summary>
|
||||
public sealed class GalaxyFormModel
|
||||
{
|
||||
// GalaxyGatewayOptions
|
||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||
public bool GatewayUseTls { get; set; } = true;
|
||||
public string? GatewayCaCertificatePath { get; set; }
|
||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
|
||||
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// GalaxyMxAccessOptions
|
||||
public string MxClientName { get; set; } = "OtOpcUa";
|
||||
public int MxPublishingIntervalMs { get; set; } = 1000;
|
||||
public int MxWriteUserId { get; set; } = 0;
|
||||
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
|
||||
|
||||
// GalaxyRepositoryOptions
|
||||
public int RepositoryDiscoverPageSize { get; set; } = 5000;
|
||||
public bool RepositoryWatchDeployEvents { get; set; } = true;
|
||||
|
||||
// GalaxyReconnectOptions
|
||||
public int ReconnectInitialBackoffMs { get; set; } = 500;
|
||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||
public bool ReconnectReplayOnSessionLost { get; set; } = true;
|
||||
|
||||
// GalaxyDriverOptions top-level
|
||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
return new()
|
||||
{
|
||||
GatewayEndpoint = gw.Endpoint,
|
||||
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||
GatewayUseTls = gw.UseTls,
|
||||
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||
MxClientName = mx.ClientName,
|
||||
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
||||
MxWriteUserId = mx.WriteUserId,
|
||||
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
||||
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
||||
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
||||
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public GalaxyDriverOptions ToRecord() => new(
|
||||
Gateway: new GalaxyGatewayOptions(
|
||||
Endpoint: GatewayEndpoint,
|
||||
ApiKeySecretRef: GatewayApiKeySecretRef,
|
||||
UseTls: GatewayUseTls,
|
||||
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
|
||||
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
|
||||
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
|
||||
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
|
||||
MxAccess: new GalaxyMxAccessOptions(
|
||||
ClientName: MxClientName,
|
||||
PublishingIntervalMs: MxPublishingIntervalMs,
|
||||
WriteUserId: MxWriteUserId,
|
||||
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
|
||||
Repository: new GalaxyRepositoryOptions(
|
||||
DiscoverPageSize: RepositoryDiscoverPageSize,
|
||||
WatchDeployEvents: RepositoryWatchDeployEvents),
|
||||
Reconnect: new GalaxyReconnectOptions(
|
||||
InitialBackoffMs: ReconnectInitialBackoffMs,
|
||||
MaxBackoffMs: ReconnectMaxBackoffMs,
|
||||
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
||||
{
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
@* Embeddable Modbus driver (channel/protocol) config form body. Endpoint fields (Host/Port/UnitId)
|
||||
moved to ModbusDeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
|
||||
ModbusDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
|
||||
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@* Family / transport-flags *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Protocol</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">PLC family</label>
|
||||
<InputSelect @bind-Value="_form.Family" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Only used when Family = MELSEC.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.AutoReconnect" @bind-Value:after="EmitAsync" class="form-check-input" id="autoReconnect" />
|
||||
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Batch sizes *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Batch sizes</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerRead" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per write</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 123.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max coils per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxCoilsPerRead" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 2000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max read gap (coalescing)</label>
|
||||
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Write options *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Write options</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC15" />
|
||||
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC16" />
|
||||
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" @bind-Value:after="EmitAsync" class="form-check-input" id="writeOnChangeOnly" />
|
||||
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Keep-alive *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">TCP keep-alive</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="kaEnabled" />
|
||||
<label class="form-check-label" for="kaEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Time (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Idle time before first probe. Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Between probes once started. Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveRetryCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect backoff *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = immediate retry.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2.0 (doubles each step).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.16s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe register address</label>
|
||||
<InputNumber @bind-Value="_form.ProbeAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new ModbusDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
// v3: the endpoint lives on the Device (DeviceConfig), not the driver. Strip the endpoint keys from
|
||||
// the serialized channel config so DeviceConfig is the SOLE endpoint source — otherwise a stale
|
||||
// driver-level default (host=127.0.0.1) is carried in DriverConfig and shadows the device on any
|
||||
// driver that reads a single top-level Host, silently, as soon as a 2nd device exists (reviewer H1).
|
||||
private static readonly string[] EndpointKeys = ["host", "port", "unitId"];
|
||||
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, with the endpoint keys
|
||||
/// removed (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time).</summary>
|
||||
public string GetConfigJson()
|
||||
{
|
||||
var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject();
|
||||
foreach (var key in EndpointKeys) node.Remove(key);
|
||||
return node.ToJsonString(_jsonOpts);
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — retains Host/Port/UnitId at defaults (no UI; the device form authors the real
|
||||
// endpoint into DeviceConfig, which merges up and overrides these at deploy/probe time).
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Transport endpoint (no UI — authored via the device form; kept at default so ToOptions is valid)
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public int UnitId { get; set; } = 1;
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Family
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Transport flags
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// Batch sizes (int; clamped to ushort on ToOptions)
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
// Write options
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool DisableFC23 { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
|
||||
// Keep-alive
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSeconds { get; set; } = 30;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
// Reconnect backoff
|
||||
public int ReconnectInitialDelaySeconds { get; set; } = 0;
|
||||
public int ReconnectMaxDelaySeconds { get; set; } = 30;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int ProbeAddress { get; set; } = 0;
|
||||
|
||||
// Auto-prohibit re-probe (0 = disabled)
|
||||
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0;
|
||||
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public static FormModel FromOptions(ModbusDriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
UnitId = o.UnitId,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Family = o.Family,
|
||||
MelsecSubFamily = o.MelsecSubFamily,
|
||||
AutoReconnect = o.AutoReconnect,
|
||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
||||
MaxReadGap = o.MaxReadGap,
|
||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = o.DisableFC23,
|
||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
|
||||
KeepAliveRetryCount = o.KeepAlive.RetryCount,
|
||||
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
|
||||
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
|
||||
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeAddress = o.Probe.ProbeAddress,
|
||||
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
|
||||
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public ModbusDriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
|
||||
},
|
||||
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
|
||||
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
|
||||
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
|
||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = DisableFC23,
|
||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||
AutoReconnect = AutoReconnect,
|
||||
KeepAlive = new ModbusKeepAliveOptions
|
||||
{
|
||||
Enabled = KeepAliveEnabled,
|
||||
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
|
||||
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
RetryCount = KeepAliveRetryCount,
|
||||
},
|
||||
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
|
||||
Reconnect = new ModbusReconnectOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
|
||||
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
|
||||
BackoffMultiplier = ReconnectBackoffMultiplier,
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
@* Embeddable OPC UA Client driver config form body. The single connection Endpoint URL moved to
|
||||
OpcUaClientDeviceForm (Device.DeviceConfig) in v3; the EndpointUrls FAILOVER LIST stays here (it is a
|
||||
driver-level policy). Hosted by the routed page + DriverConfigModal. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
|
||||
|
||||
@* Endpoint (single Endpoint URL moved to the device form) *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Endpoint</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.BrowseRoot" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="i=85" />
|
||||
<div class="form-text">Restrict mirroring to a sub-tree.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Application URI</label>
|
||||
<InputText @bind-Value="_form.OpcUa.ApplicationUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Session name</label>
|
||||
<InputText @bind-Value="_form.OpcUa.SessionName" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" @bind-Value:after="EmitAsync" class="form-check-input" id="autoAcceptCerts" />
|
||||
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Per-endpoint connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 3 s — failover sweep budget.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Operation timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Session timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 120 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Keep-alive interval (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Reconnect period (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max discovered nodes</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max browse depth</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints" ItemsChanged="EmitAsync"
|
||||
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
|
||||
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
|
||||
Validate="EndpointUrlRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="e">
|
||||
<td class="mono">@e.Url</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="e">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<input class="form-control form-control-sm mono" @bind="e.Url"
|
||||
placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">Failover list. The per-device Endpoint URL (on the device) is used only when this list is empty.</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Security *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Security</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security mode</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security policy</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Authentication *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Authentication</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Auth type</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.AuthType" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Username" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">User certificate path (PFX/PEM)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\user.pfx" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Namespace mapping *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Namespace mapping</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Target namespace kind</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
|
||||
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
|
||||
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private List<EndpointUrlRow> _endpoints = [];
|
||||
private string _unsMappingTableJson = "{}";
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new OpcUaClientDriverOptions();
|
||||
_form = new FormModel { OpcUa = OpcUaClientFormModel.FromRecord(opts) };
|
||||
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
|
||||
_unsMappingTableJson = JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetConfigJson()
|
||||
{
|
||||
// v3 (reviewer H1): the convenience scalar endpoint lives on the Device (DeviceConfig). Strip the
|
||||
// scalar "endpointUrl" so the device is its sole source; the "endpointUrls" failover list stays
|
||||
// here (it is a driver-level policy and wins over the scalar at runtime when populated).
|
||||
var node = JsonSerializer.SerializeToNode(
|
||||
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts)!.AsObject();
|
||||
node.Remove("endpointUrl");
|
||||
return node.ToJsonString(_jsonOpts);
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static OpcUaClientDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public OpcUaClientFormModel OpcUa { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Mutable VM for a single failover endpoint URL row.</summary>
|
||||
public sealed class EndpointUrlRow
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
|
||||
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
|
||||
public string ToUrl() => Url.Trim();
|
||||
|
||||
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
|
||||
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
||||
return "Endpoint URL must start with opc.tcp://";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate endpoint '{row.Url}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mutable mirror of <see cref="OpcUaClientDriverOptions"/> (int wrappers for TimeSpans).
|
||||
/// EndpointUrl (single) has NO UI — the device form authors it; kept at default so ToRecord is valid.</summary>
|
||||
public sealed class OpcUaClientFormModel
|
||||
{
|
||||
// Connection (EndpointUrl authored on the device — no UI here)
|
||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||
public string? BrowseRoot { get; set; }
|
||||
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
|
||||
public string SessionName { get; set; } = "OtOpcUa-Gateway";
|
||||
public bool AutoAcceptCertificates { get; set; } = false;
|
||||
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public int SessionTimeoutSeconds { get; set; } = 120;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 5;
|
||||
public int ReconnectPeriodSeconds { get; set; } = 5;
|
||||
public int MaxDiscoveredNodes { get; set; } = 10_000;
|
||||
public int MaxBrowseDepth { get; set; } = 10;
|
||||
|
||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? UserCertificatePath { get; set; }
|
||||
public string? UserCertificatePassword { get; set; }
|
||||
|
||||
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
|
||||
|
||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
|
||||
|
||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
||||
{
|
||||
EndpointUrl = r.EndpointUrl,
|
||||
BrowseRoot = r.BrowseRoot,
|
||||
ApplicationUri = r.ApplicationUri,
|
||||
SessionName = r.SessionName,
|
||||
AutoAcceptCertificates = r.AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
|
||||
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
|
||||
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
|
||||
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
|
||||
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = r.MaxBrowseDepth,
|
||||
SecurityMode = r.SecurityMode,
|
||||
SecurityPolicy = r.SecurityPolicy,
|
||||
AuthType = r.AuthType,
|
||||
Username = r.Username,
|
||||
Password = r.Password,
|
||||
UserCertificatePath = r.UserCertificatePath,
|
||||
UserCertificatePassword = r.UserCertificatePassword,
|
||||
TargetNamespaceKind = r.TargetNamespaceKind,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
_unsMappingTable = r.UnsMappingTable,
|
||||
};
|
||||
|
||||
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
EndpointUrls = endpointUrls,
|
||||
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
|
||||
ApplicationUri = ApplicationUri,
|
||||
SessionName = SessionName,
|
||||
AutoAcceptCertificates = AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
|
||||
MaxDiscoveredNodes = MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = MaxBrowseDepth,
|
||||
SecurityMode = SecurityMode,
|
||||
SecurityPolicy = SecurityPolicy,
|
||||
AuthType = AuthType,
|
||||
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
|
||||
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
|
||||
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
|
||||
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
|
||||
TargetNamespaceKind = TargetNamespaceKind,
|
||||
UnsMappingTable = _unsMappingTable,
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
@* Embeddable Siemens S7 driver (protocol) config form body. Endpoint fields (Host/Port/Rack/Slot)
|
||||
moved to S7DeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
|
||||
S7DriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
|
||||
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.S7
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="s7CpuType">CPU type</label>
|
||||
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType" @bind-Value:after="EmitAsync"
|
||||
class="form-select form-select-sm">
|
||||
@foreach (var v in Enum.GetValues<S7CpuType>())
|
||||
{
|
||||
<option value="@v">@v</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
|
||||
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
|
||||
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
|
||||
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol; endpoint lives in the device).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a protocol field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new S7DriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
// v3: the endpoint lives on the Device (DeviceConfig). Strip the endpoint keys so DeviceConfig is the
|
||||
// sole endpoint source — a stale driver-level default would otherwise shadow the device on the
|
||||
// single-top-level-Host read path as soon as a 2nd device exists (reviewer H1).
|
||||
private static readonly string[] EndpointKeys = ["host", "port", "rack", "slot"];
|
||||
|
||||
/// <summary>Serializes the current protocol config to camelCase JSON, with the endpoint keys removed
|
||||
/// (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time).</summary>
|
||||
public string GetConfigJson()
|
||||
{
|
||||
var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject();
|
||||
foreach (var key in EndpointKeys) node.Remove(key);
|
||||
return node.ToJsonString(_jsonOpts);
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static S7DriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Connection
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 102;
|
||||
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
|
||||
public short Rack { get; set; } = 0;
|
||||
public short Slot { get; set; } = 0;
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(S7DriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
CpuType = o.CpuType,
|
||||
Rack = o.Rack,
|
||||
Slot = o.Slot,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public S7DriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
CpuType = CpuType,
|
||||
Rack = Rack,
|
||||
Slot = Slot,
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new S7ProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
@* Embeddable TwinCAT driver (channel/protocol) config form body. Endpoint/device fields (AMS Net Id:port)
|
||||
moved to TwinCATDeviceForm in v3 (they live in Device.DeviceConfig); this form never edits the Devices
|
||||
collection — it preserves it verbatim across a load→save. Hosted by the routed TwinCATDriverPage and by
|
||||
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
|
||||
DriverConfigJsonChanged for @bind support. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
|
||||
|
||||
@* Options *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Options</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s per operation.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
|
||||
</div>
|
||||
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
|
||||
</div>
|
||||
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
|
||||
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Connectivity probe</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
|
||||
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
|
||||
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
|
||||
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
// TwinCAT is multi-device: this form never edits the Devices collection — it preserves whatever was
|
||||
// authored (via the device modal on the Raw tree) verbatim across a load→save.
|
||||
private IReadOnlyList<TwinCATDeviceOptions> _preservedDevices = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new TwinCATDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the Devices
|
||||
/// collection (authored elsewhere) verbatim.</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static TwinCATDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
// The Devices collection is kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Options
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
public bool UseNativeNotifications { get; set; } = true;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public int NotificationMaxDelayMs { get; set; } = 0;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
UseNativeNotifications = o.UseNativeNotifications,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public TwinCATDriverOptions ToOptions(
|
||||
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
UseNativeNotifications = UseNativeNotifications,
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
NotificationMaxDelayMs = NotificationMaxDelayMs,
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
Devices = devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the
|
||||
merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in
|
||||
multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via
|
||||
IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed
|
||||
TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the
|
||||
captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit
|
||||
raises OnSaved so the host refreshes the tree.
|
||||
|
||||
Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy)
|
||||
wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports
|
||||
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
|
||||
with a tooltip. *@
|
||||
@implements IAsyncDisposable
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||
@inject IBrowserSessionService BrowserService
|
||||
@inject IRawTreeService Svc
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
Browse device <span class="text-muted small">(@_driverType)</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_resolving)
|
||||
{
|
||||
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading device config…</div>
|
||||
}
|
||||
else if (!_canBrowse)
|
||||
{
|
||||
<div class="alert alert-secondary mb-0" title="@_disabledReason">
|
||||
<strong>Browsing unavailable.</strong>
|
||||
<div class="small">@_disabledReason</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2 small text-muted">
|
||||
Committing under: <code class="mono">@TargetLabel()</code>
|
||||
</div>
|
||||
|
||||
@if (_token == Guid.Empty)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
disabled="@_opening" @onclick="OpenBrowseAsync">
|
||||
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Connect & browse
|
||||
</button>
|
||||
@if (_openError is not null)
|
||||
{
|
||||
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="chip chip-ok">Browser open</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
|
||||
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
|
||||
<label class="form-check-label small" for="raw-browse-mirror">
|
||||
Create matching tag-groups (mirror browse folders)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
|
||||
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
|
||||
|
||||
<div class="mt-2 small">
|
||||
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_commitErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0">
|
||||
<div class="small mb-1">Nothing was committed — fix these and retry:</div>
|
||||
<ul class="mb-0 small">
|
||||
@foreach (var err in _commitErrors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="CommitAsync"
|
||||
disabled="@(_busy || _selected.Count == 0)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Add @_selected.Count tag@(_selected.Count == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a
|
||||
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
|
||||
private const string DefaultDataType = "Double";
|
||||
|
||||
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The device the browsed tags are committed under.</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>The target tag group (its device-relative path is prepended to every committed tag), or null
|
||||
/// to commit directly under the device.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The owning device's driver type (informational header; the live gate + config come from the
|
||||
/// merged probe config).</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
/// <summary>Raised after a successful commit so the host can refresh the affected subtree.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private bool _open; // guards re-seeding on unrelated re-renders
|
||||
private bool _resolving;
|
||||
private bool _canBrowse;
|
||||
private string? _disabledReason;
|
||||
|
||||
private string _driverType = "";
|
||||
private string _mergedConfig = "{}";
|
||||
private string _effectiveDeviceId = "";
|
||||
private string? _groupPrefix;
|
||||
|
||||
private Guid _token = Guid.Empty;
|
||||
private bool _opening;
|
||||
private string? _openError;
|
||||
|
||||
private bool _createGroups;
|
||||
private bool _busy;
|
||||
private List<string> _commitErrors = new();
|
||||
|
||||
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!Visible)
|
||||
{
|
||||
_open = false;
|
||||
return;
|
||||
}
|
||||
if (_open) { return; }
|
||||
_open = true;
|
||||
await ResetAndResolveAsync();
|
||||
}
|
||||
|
||||
private async Task ResetAndResolveAsync()
|
||||
{
|
||||
_token = Guid.Empty;
|
||||
_opening = false;
|
||||
_openError = null;
|
||||
_createGroups = false;
|
||||
_busy = false;
|
||||
_commitErrors = new();
|
||||
_selected.Clear();
|
||||
_selectedIds.Clear();
|
||||
_driverType = DriverType;
|
||||
_mergedConfig = "{}";
|
||||
_effectiveDeviceId = DeviceId;
|
||||
_groupPrefix = null;
|
||||
_canBrowse = false;
|
||||
_disabledReason = null;
|
||||
|
||||
_resolving = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
|
||||
// the merged blob (same merge the driver-probe/test-connect path uses).
|
||||
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
|
||||
if (merged is { } m)
|
||||
{
|
||||
_driverType = m.DriverType;
|
||||
_mergedConfig = m.MergedConfigJson;
|
||||
}
|
||||
|
||||
// Resolve the target group's device-relative path prefix (prepended to every committed tag).
|
||||
if (!string.IsNullOrEmpty(TagGroupId))
|
||||
{
|
||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||
if (ctx is { } c)
|
||||
{
|
||||
_groupPrefix = c.GroupPath;
|
||||
if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; }
|
||||
}
|
||||
}
|
||||
|
||||
// Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config.
|
||||
_canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig);
|
||||
if (!_canBrowse)
|
||||
{
|
||||
_disabledReason =
|
||||
$"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry).";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_canBrowse = false;
|
||||
_disabledReason = $"Could not load device config: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resolving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenBrowseAsync()
|
||||
{
|
||||
_opening = true;
|
||||
_openError = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
||||
if (result.Ok) { _token = result.Token; }
|
||||
else { _openError = result.Message; }
|
||||
}
|
||||
finally
|
||||
{
|
||||
_opening = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnLeafToggledAsync(BrowseLeafSelection sel)
|
||||
{
|
||||
var nodeId = sel.Leaf.NodeId;
|
||||
if (_selectedIds.Contains(nodeId))
|
||||
{
|
||||
_selectedIds.Remove(nodeId);
|
||||
_selected.Remove(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal
|
||||
// (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to
|
||||
// the display name + the default data type.
|
||||
string name = sel.Leaf.DisplayName;
|
||||
string? driverDataType = null;
|
||||
try
|
||||
{
|
||||
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
|
||||
if (attrs.Count > 0)
|
||||
{
|
||||
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
|
||||
driverDataType = attrs[0].DriverDataType;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
|
||||
}
|
||||
|
||||
_selectedIds.Add(nodeId);
|
||||
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
|
||||
}
|
||||
|
||||
private async Task CommitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_commitErrors = new();
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var rows = _selected.Values
|
||||
.Select(s => RawBrowseCommitMapper.MapLeaf(
|
||||
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
|
||||
_groupPrefix, s.FolderPath, _createGroups))
|
||||
.ToList();
|
||||
|
||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||
if (outcome.Errors.Count > 0)
|
||||
{
|
||||
_commitErrors = outcome.Errors.ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
await CloseAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
|
||||
}
|
||||
|
||||
private string TargetLabel()
|
||||
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
|
||||
|
||||
private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_token != Guid.Empty)
|
||||
{
|
||||
// Fire-and-forget — don't block circuit teardown on a slow remote.
|
||||
_ = BrowserService.CloseAsync(_token);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@* Reusable confirm dialog for the /raw destructive operations (delete folder / driver / device /
|
||||
tag-group / tag). Raises OnConfirm when the operator confirms; the caller runs the delete service
|
||||
call and surfaces any UnsMutationResult.Error (a blocked delete names its blocker) via a follow-up
|
||||
message. Markup mirrors the other /raw modal shells. *@
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@Title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">@Message</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_busy">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="Confirm" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@ConfirmLabel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Two-way visibility callback.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The dialog title (e.g. "Delete driver").</summary>
|
||||
[Parameter] public string Title { get; set; } = "Confirm";
|
||||
|
||||
/// <summary>The confirmation body message.</summary>
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
|
||||
/// <summary>The confirm-button label.</summary>
|
||||
[Parameter] public string ConfirmLabel { get; set; } = "Delete";
|
||||
|
||||
/// <summary>Raised when the operator confirms. The dialog awaits it, then self-closes.</summary>
|
||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||
|
||||
private bool _busy;
|
||||
|
||||
private async Task Confirm()
|
||||
{
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
await OnConfirm.InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
await Close();
|
||||
}
|
||||
|
||||
private Task Cancel() => Close();
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Visible = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
@* CSV tag export for a Raw-tree Device / TagGroup (B2-WP5).
|
||||
|
||||
Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and
|
||||
renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver
|
||||
CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no
|
||||
external host — matches the app's CSP posture) plus a preview textarea.
|
||||
|
||||
Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub):
|
||||
<RawCsvExportModal @bind-Visible="_exportVisible"
|
||||
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
|
||||
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
|
||||
DriverType="@_exportDriverType" /> *@
|
||||
@using System.Text
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Export tags — CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm"></span> Reading tags…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">
|
||||
<span class="badge bg-secondary">@_rowCount tags</span>
|
||||
for <span class="mono">@DriverType</span> device
|
||||
@if (!string.IsNullOrEmpty(_groupFilter))
|
||||
{
|
||||
<span>, group <span class="mono">@_groupFilter</span></span>
|
||||
}
|
||||
</p>
|
||||
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
|
||||
@if (!_loading && _error is null)
|
||||
{
|
||||
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (two-way bound).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The device to export. May be blank when <see cref="TagGroupId"/> is supplied.</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>When set, only tags under this group's subtree are exported; null exports the whole device.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
private bool _loading;
|
||||
private bool _built;
|
||||
private string? _error;
|
||||
private string _csv = "";
|
||||
private string _dataUri = "";
|
||||
private string _fileName = "tags.csv";
|
||||
private int _rowCount;
|
||||
private string? _groupFilter;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_built)
|
||||
{
|
||||
_built = true;
|
||||
await BuildAsync();
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_built = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BuildAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var deviceId = DeviceId;
|
||||
_groupFilter = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(TagGroupId))
|
||||
{
|
||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||
if (ctx is { } c)
|
||||
{
|
||||
_groupFilter = c.GroupPath;
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
deviceId = c.DeviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_error = "Could not resolve the target device.";
|
||||
return;
|
||||
}
|
||||
|
||||
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
|
||||
|
||||
// When exporting a single group, keep only that group's subtree (its path + descendants).
|
||||
if (!string.IsNullOrEmpty(_groupFilter))
|
||||
{
|
||||
var prefix = _groupFilter;
|
||||
tags = tags
|
||||
.Where(t => t.TagGroupPath is not null &&
|
||||
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
_rowCount = tags.Count;
|
||||
_csv = RawTagCsvMapper.Export(DriverType, tags);
|
||||
_fileName = BuildFileName(deviceId);
|
||||
|
||||
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv));
|
||||
_dataUri = $"data:text/csv;charset=utf-8;base64,{base64}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildFileName(string deviceId)
|
||||
{
|
||||
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
|
||||
return $"tags-{safe}.csv";
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
_built = false;
|
||||
_csv = "";
|
||||
_dataUri = "";
|
||||
Visible = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5).
|
||||
|
||||
Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap →
|
||||
a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were
|
||||
typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync.
|
||||
ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit.
|
||||
|
||||
Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub):
|
||||
<RawCsvImportModal @bind-Visible="_importVisible"
|
||||
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
|
||||
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
|
||||
DriverType="@_importDriverType"
|
||||
OnSaved="ReloadNodeChildren" />
|
||||
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
|
||||
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IRawTreeService Svc
|
||||
@inject RawTagCsvExportReader ExportReader
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import tags — CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (!string.IsNullOrEmpty(_fatal))
|
||||
{
|
||||
<div class="alert alert-danger">@_fatal</div>
|
||||
}
|
||||
|
||||
@if (_result is null)
|
||||
{
|
||||
<p class="text-muted">
|
||||
Upload a CSV whose header row carries the columns for a
|
||||
<span class="mono">@DriverType</span> device.
|
||||
Target: <span class="mono">@TargetLabel</span>.
|
||||
</p>
|
||||
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
|
||||
@if (_parsing)
|
||||
{
|
||||
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
|
||||
<span class="badge bg-success">@OkCount valid</span>
|
||||
@if (ErrorCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@ErrorCount invalid</span>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
|
||||
</div>
|
||||
|
||||
@if (ErrorCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning py-1">
|
||||
Import is all-or-nothing — fix or remove the invalid rows before committing.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-responsive" style="max-height:50vh;overflow:auto">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
<th style="width:2.5rem"></th>
|
||||
<th>Row</th>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>Verdict</th>
|
||||
<th>Provenance</th>
|
||||
<th style="width:2.5rem"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _result.Rows)
|
||||
{
|
||||
if (_removed.Contains(row.RowNumber)) { continue; }
|
||||
<tr class="@(row.Ok ? "" : "table-danger")">
|
||||
<td>@(row.Ok ? "✓" : "✕")</td>
|
||||
<td class="mono">@row.RowNumber</td>
|
||||
<td class="mono">@row.Name</td>
|
||||
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
|
||||
<td>
|
||||
@if (row.Ok)
|
||||
{
|
||||
<span class="text-success">OK</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger">@row.Error</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.TypedColumnsUsed.Count > 0)
|
||||
{
|
||||
<span class="text-muted">typed: </span>
|
||||
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
|
||||
}
|
||||
@if (row.FallbackKeys.Count > 0)
|
||||
{
|
||||
<br /><span class="text-muted">TagConfigJson: </span>
|
||||
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
|
||||
}
|
||||
@if (row.TypedOverrodeFallback.Count > 0)
|
||||
{
|
||||
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
|
||||
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger py-0"
|
||||
title="Remove this row from the import"
|
||||
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (_commitErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<strong>Commit rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var e in _commitErrors)
|
||||
{
|
||||
<li>@e</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
disabled="@(!CanCommit || _committing)"
|
||||
@onclick="Commit">
|
||||
@if (_committing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown (two-way bound).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
|
||||
/// (the device is then resolved from the group).</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>The target tag-group's logical id, or null to import at the device root. Its device-relative
|
||||
/// path is prepended to every imported row's <c>TagGroupPath</c>.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private RawCsvParseResult? _result;
|
||||
private readonly HashSet<int> _removed = new();
|
||||
private List<string> _commitErrors = new();
|
||||
private string? _fatal;
|
||||
private bool _parsing;
|
||||
private bool _committing;
|
||||
private bool _resolved;
|
||||
|
||||
private string _effectiveDeviceId = "";
|
||||
private string? _groupPrefix;
|
||||
|
||||
private string TargetLabel =>
|
||||
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
|
||||
|
||||
private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0;
|
||||
|
||||
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
|
||||
private bool CanCommit =>
|
||||
_result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Visible && !_resolved)
|
||||
{
|
||||
await ResolveTargetAsync();
|
||||
_resolved = true;
|
||||
}
|
||||
else if (!Visible)
|
||||
{
|
||||
_resolved = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the effective device id + the group-path prefix once per open.
|
||||
private async Task ResolveTargetAsync()
|
||||
{
|
||||
_effectiveDeviceId = DeviceId;
|
||||
_groupPrefix = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(TagGroupId))
|
||||
{
|
||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||
if (ctx is { } c)
|
||||
{
|
||||
_groupPrefix = c.GroupPath;
|
||||
if (string.IsNullOrEmpty(_effectiveDeviceId))
|
||||
{
|
||||
_effectiveDeviceId = c.DeviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_fatal = null;
|
||||
_commitErrors = new();
|
||||
_removed.Clear();
|
||||
_parsing = true;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
var text = await reader.ReadToEndAsync();
|
||||
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
|
||||
_fatal = _result.FatalError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_fatal = $"Could not read the file: {ex.Message}";
|
||||
_result = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_parsing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_result = null;
|
||||
_fatal = null;
|
||||
_commitErrors = new();
|
||||
_removed.Clear();
|
||||
}
|
||||
|
||||
private async Task Commit()
|
||||
{
|
||||
if (_result is null || !CanCommit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_committing = true;
|
||||
_commitErrors = new();
|
||||
try
|
||||
{
|
||||
var rows = _result.Rows
|
||||
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
|
||||
.Select(r => r.ImportRow!)
|
||||
.ToList();
|
||||
|
||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
||||
if (outcome.Errors.Count > 0)
|
||||
{
|
||||
_commitErrors = outcome.Errors.ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
await Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_commitErrors = new() { ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_committing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Reset();
|
||||
Visible = false;
|
||||
_resolved = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller
|
||||
creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via
|
||||
the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now
|
||||
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
|
||||
/raw modal shells. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">New driver</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="raw-drv-type">Driver type</label>
|
||||
<select id="raw-drv-type" class="form-select form-select-sm" @bind="_type">
|
||||
@foreach (var t in Types)
|
||||
{
|
||||
<option value="@t.Value">@t.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label" for="raw-drv-name">Name</label>
|
||||
<input id="raw-drv-name" class="form-control form-control-sm mono"
|
||||
@bind="_name" @bind:event="oninput" placeholder="line3-modbus" />
|
||||
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Two-way visibility callback.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
|
||||
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
|
||||
|
||||
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is
|
||||
// authorable now though its factory arrives in Wave C.
|
||||
private static readonly (string Label, string Value)[] Types =
|
||||
[
|
||||
("Modbus", DriverTypeNames.Modbus),
|
||||
("S7", DriverTypeNames.S7),
|
||||
("AbCip", DriverTypeNames.AbCip),
|
||||
("AbLegacy", DriverTypeNames.AbLegacy),
|
||||
("TwinCAT", DriverTypeNames.TwinCAT),
|
||||
("FOCAS", DriverTypeNames.FOCAS),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Calculation", "Calculation"),
|
||||
];
|
||||
|
||||
private string _type = DriverTypeNames.Modbus;
|
||||
private string _name = "";
|
||||
private string? _error;
|
||||
private bool _open; // seed once per open; preserve edits across unrelated re-renders
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!Visible) { _open = false; return; }
|
||||
if (_open) { return; }
|
||||
_open = true;
|
||||
_type = DriverTypeNames.Modbus;
|
||||
_name = "";
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
var err = RawPaths.ValidateSegment(_name);
|
||||
if (err is not null) { _error = err; return; }
|
||||
await OnSubmit.InvokeAsync((_type, _name));
|
||||
await Close();
|
||||
}
|
||||
|
||||
private Task Cancel() => Close();
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
@* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a
|
||||
TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a
|
||||
per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row — the grid stays
|
||||
compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each
|
||||
row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so
|
||||
nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add tags — manual entry <span class="text-muted small">(@DriverType)</span></h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:22%">Name</th>
|
||||
<th style="width:14%">Data type</th>
|
||||
<th style="width:14%">Access</th>
|
||||
<th style="width:12%">WriteIdemp.</th>
|
||||
<th style="width:18%">PollGroup</th>
|
||||
<th style="width:12%">Config</th>
|
||||
<th style="width:8%"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _rows)
|
||||
{
|
||||
<tr @key="row.Key">
|
||||
<td>
|
||||
<input class="form-control form-control-sm" placeholder="TagName"
|
||||
value="@row.Name" @onchange="@(e => row.Name = e.Value?.ToString() ?? "")" />
|
||||
@if (row.Error is not null)
|
||||
{
|
||||
<div class="text-danger small">@row.Error</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@row.DataType"
|
||||
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@row.AccessLevel"
|
||||
@onchange="@(e => row.AccessLevel = ParseAccess(e.Value))">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input" checked="@row.WriteIdempotent"
|
||||
@onchange="@(e => row.WriteIdempotent = e.Value is true)" />
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm mono" placeholder="(optional)"
|
||||
value="@row.PollGroupId" @onchange="@(e => row.PollGroupId = e.Value?.ToString())" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="@(() => row.Expanded = !row.Expanded)">
|
||||
@(row.Expanded ? "Hide" : "Configure")
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
@onclick="@(() => RemoveRow(row))" disabled="@(_rows.Count == 1)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (row.Expanded)
|
||||
{
|
||||
<tr @key="@(row.Key + "-cfg")">
|
||||
<td colspan="7">
|
||||
<div class="border rounded p-2 bg-body-tertiary">
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(DriverType);
|
||||
}
|
||||
@if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters(row)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<textarea class="form-control form-control-sm mono" rows="4"
|
||||
placeholder='{ "FullName": "tag_name.AttributeName" }'
|
||||
value="@row.TagConfig"
|
||||
@onchange="@(e => row.TagConfig = e.Value?.ToString() ?? "{}")"></textarea>
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="AddRow">+ Add row</button>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_summary))
|
||||
{
|
||||
<div class="alert @(_summaryOk ? "alert-success" : "alert-warning") py-2 mt-3 mb-0">@_summary</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create @_rows.Count tag@(_rows.Count == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The device the new tags are created under.</summary>
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
/// <summary>The containing tag group, or null to create the tags directly under the device.</summary>
|
||||
[Parameter] public string? TagGroupId { get; set; }
|
||||
|
||||
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
/// <summary>Raised after at least one tag is committed so the host can refresh the tree.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private readonly List<RowModel> _rows = new();
|
||||
private bool _busy;
|
||||
private string? _summary;
|
||||
private bool _summaryOk;
|
||||
private bool _open; // guards re-seeding rows on unrelated re-renders
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!Visible)
|
||||
{
|
||||
_open = false;
|
||||
return;
|
||||
}
|
||||
if (_open) { return; } // same open, re-render → preserve in-progress rows
|
||||
_open = true;
|
||||
_rows.Clear();
|
||||
_rows.Add(new RowModel());
|
||||
_summary = null;
|
||||
_summaryOk = false;
|
||||
}
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = row.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
|
||||
["DriverType"] = DriverType,
|
||||
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
|
||||
};
|
||||
|
||||
private void AddRow() => _rows.Add(new RowModel());
|
||||
|
||||
private void RemoveRow(RowModel row)
|
||||
{
|
||||
if (_rows.Count > 1) { _rows.Remove(row); }
|
||||
}
|
||||
|
||||
private static TagAccessLevel ParseAccess(object? v)
|
||||
=> Enum.TryParse<TagAccessLevel>(v?.ToString(), out var a) ? a : TagAccessLevel.Read;
|
||||
|
||||
private async Task CommitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_summary = null;
|
||||
try
|
||||
{
|
||||
// --- Client-side validation first: name segment + per-driver config + intra-grid dup names. ---
|
||||
foreach (var row in _rows) { row.Error = null; }
|
||||
|
||||
var seen = new HashSet<string>(RawPaths.Comparer);
|
||||
var anyClientError = false;
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
var nameError = RawPaths.ValidateSegment(row.Name);
|
||||
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
|
||||
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
|
||||
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
|
||||
if (configError is not null) { row.Error = configError; anyClientError = true; }
|
||||
}
|
||||
if (anyClientError)
|
||||
{
|
||||
_summaryOk = false;
|
||||
_summary = "Fix the highlighted rows and try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Commit each row; keep failures (with their error), drop committed rows. ---
|
||||
var created = 0;
|
||||
var failed = 0;
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
var input = new RawTagInput(
|
||||
row.Name,
|
||||
row.DataType,
|
||||
row.AccessLevel,
|
||||
row.WriteIdempotent,
|
||||
string.IsNullOrWhiteSpace(row.PollGroupId) ? null : row.PollGroupId,
|
||||
string.IsNullOrWhiteSpace(row.TagConfig) ? "{}" : row.TagConfig);
|
||||
|
||||
var result = await Svc.CreateTagAsync(DeviceId, TagGroupId, input);
|
||||
if (result.Ok)
|
||||
{
|
||||
row.Committed = true;
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Error = result.Error;
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
_rows.RemoveAll(r => r.Committed);
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
if (created > 0) { await OnSaved.InvokeAsync(); }
|
||||
await CloseAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Some succeeded, some failed: refresh the tree for the good ones, keep the modal open on
|
||||
// the failed rows so the operator can correct them without re-entering the whole batch.
|
||||
if (created > 0) { await OnSaved.InvokeAsync(); }
|
||||
_summaryOk = false;
|
||||
_summary = $"{created} tag(s) created; {failed} failed. Fix the highlighted rows and retry.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private sealed class RowModel
|
||||
{
|
||||
public string Key { get; } = Guid.NewGuid().ToString("N");
|
||||
public string Name { get; set; } = "";
|
||||
public string DataType { get; set; } = "Float";
|
||||
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
|
||||
public bool WriteIdempotent { get; set; }
|
||||
public string? PollGroupId { get; set; }
|
||||
public string TagConfig { get; set; } = "{}";
|
||||
public bool Expanded { get; set; }
|
||||
public bool Committed { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
@* Reusable name-input dialog for the /raw create + rename operations (New folder / New tag-group /
|
||||
New group / Rename folder-driver-device-tag-group). Inline-validates the input as a RawPath segment
|
||||
(RawPaths.ValidateSegment) before raising OnSubmit; a valid submit self-closes the dialog and the
|
||||
caller runs the create/rename service call + surfaces any server-side error/warnings. Markup mirrors
|
||||
the other /raw modal shells (RawStubModal). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@Title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="raw-name-input">Name</label>
|
||||
<input id="raw-name-input" class="form-control form-control-sm mono"
|
||||
@bind="_value" @bind:event="oninput" @onkeydown="OnKeyDown"
|
||||
placeholder="segment-name" />
|
||||
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Submit">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Two-way visibility callback.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The dialog title (e.g. "New folder", "Rename driver").</summary>
|
||||
[Parameter] public string Title { get; set; } = "Name";
|
||||
|
||||
/// <summary>The initial value seeded into the input on open (blank for create, current name for rename).</summary>
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
|
||||
/// <summary>Raised with the validated name when the operator confirms. The dialog self-closes after.</summary>
|
||||
[Parameter] public EventCallback<string> OnSubmit { get; set; }
|
||||
|
||||
private string _value = "";
|
||||
private string? _error;
|
||||
private bool _open; // seed the input once per open; preserve typing across unrelated re-renders
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!Visible) { _open = false; return; }
|
||||
if (_open) { return; }
|
||||
_open = true;
|
||||
_value = Value ?? "";
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
var err = RawPaths.ValidateSegment(_value);
|
||||
if (err is not null) { _error = err; return; }
|
||||
await OnSubmit.InvokeAsync(_value);
|
||||
await Close();
|
||||
}
|
||||
|
||||
private async Task OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") { await Submit(); }
|
||||
}
|
||||
|
||||
private Task Cancel() => Close();
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Visible = false;
|
||||
_open = false;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@* Placeholder modal for the /raw context-menu actions whose real dialogs
|
||||
(Configure / Device / TagModal / CSV import-export / Browse device / Rename)
|
||||
land in later Batch-2 waves (B/C). This wave wires every menu action to a
|
||||
named handler that opens this "coming soon" placeholder, so the tree + lazy
|
||||
expansion + menus can be live-verified without the real modals existing yet.
|
||||
Wave B/C replaces each handler body with the real modal invocation and can
|
||||
then delete this component. Markup mirrors the GlobalUns delete-confirm modal. *@
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@Title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="OnClose"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@Message</p>
|
||||
@if (!string.IsNullOrWhiteSpace(NodeName))
|
||||
{
|
||||
<p class="text-muted small mb-0">Target: <span class="mono">@NodeName</span></p>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="OnClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the placeholder is shown.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Modal title — the action name (e.g. "Configure driver").</summary>
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
|
||||
/// <summary>"Coming soon" body message.</summary>
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
|
||||
/// <summary>The target node's display name, shown as context.</summary>
|
||||
[Parameter] public string? NodeName { get; set; }
|
||||
|
||||
/// <summary>Raised when the placeholder is dismissed.</summary>
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
@* Edit modal for a single raw Tag in the v3 /raw tree. Unlike the equipment TagModal there is NO
|
||||
driver selector — the driver is fixed by the device the tag lives under, so the owning DriverType is
|
||||
passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for
|
||||
unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it
|
||||
loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful
|
||||
save raises OnSaved so the coordinator can refresh the tree. *@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="rawTagModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit tag</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_loadError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2">@_loadError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="raw-tag-name">Name</label>
|
||||
<InputText id="raw-tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
|
||||
placeholder="Temperature" />
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
@if (_nameError is not null)
|
||||
{
|
||||
<div class="text-danger small">@_nameError</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="raw-tag-dtype">Data type</label>
|
||||
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="raw-tag-access">Access level</label>
|
||||
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">WriteIdempotent</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
|
||||
<label class="form-check-label">Safe to retry writes</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="raw-tag-pgroup">PollGroupId (optional)</label>
|
||||
<InputText id="raw-tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tag config <span class="text-muted small">(@DriverType)</span></label>
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(DriverType);
|
||||
}
|
||||
@if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputTextArea id="raw-tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "FullName": "tag_name.AttributeName" }' />
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@(_busy || _loadError is not null)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
|
||||
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
|
||||
|
||||
/// <summary>The raw tag to edit. The modal loads it via <c>LoadTagForEditAsync</c> on open.</summary>
|
||||
[Parameter] public string? TagId { get; set; }
|
||||
|
||||
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
|
||||
[Parameter] public string? DriverType { get; set; }
|
||||
|
||||
/// <summary>Raised after a successful save so the host can refresh the tree.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
private FormModel _form = new();
|
||||
private byte[] _rowVersion = Array.Empty<byte>();
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private string? _nameError;
|
||||
private string? _loadError;
|
||||
|
||||
// Tracks which (open, tag) this modal last loaded for, so unrelated Blazor Server re-renders don't
|
||||
// reload + clobber in-progress edits. Null while closed.
|
||||
private string? _loadedKey;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!Visible)
|
||||
{
|
||||
_loadedKey = null; // closed → next open reloads fresh
|
||||
return;
|
||||
}
|
||||
|
||||
var key = TagId;
|
||||
if (key == _loadedKey) { return; } // same open, re-render → preserve in-progress edits
|
||||
_loadedKey = key;
|
||||
|
||||
_error = null;
|
||||
_nameError = null;
|
||||
_loadError = null;
|
||||
|
||||
if (string.IsNullOrEmpty(TagId))
|
||||
{
|
||||
_loadError = "No tag selected.";
|
||||
return;
|
||||
}
|
||||
|
||||
var dto = await Svc.LoadTagForEditAsync(TagId);
|
||||
// RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null.
|
||||
if (dto is not { } d || string.IsNullOrEmpty(d.TagId))
|
||||
{
|
||||
_loadError = "This tag no longer exists.";
|
||||
return;
|
||||
}
|
||||
|
||||
_form = new FormModel
|
||||
{
|
||||
Name = d.Name,
|
||||
DataType = string.IsNullOrEmpty(d.DataType) ? "Float" : d.DataType,
|
||||
AccessLevel = d.AccessLevel,
|
||||
WriteIdempotent = d.WriteIdempotent,
|
||||
PollGroupId = d.PollGroupId,
|
||||
TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig,
|
||||
};
|
||||
_rowVersion = d.RowVersion;
|
||||
}
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
["DriverType"] = DriverType ?? "",
|
||||
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
|
||||
};
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_nameError = null;
|
||||
try
|
||||
{
|
||||
// Client-side name validation (RawPath segment) so a bad name is caught before the round-trip.
|
||||
var nameError = RawPaths.ValidateSegment(_form.Name);
|
||||
if (nameError is not null)
|
||||
{
|
||||
_nameError = nameError;
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side per-driver config validation (the typed editor's Validate()).
|
||||
var configError = TagConfigValidator.Validate(DriverType, _form.TagConfig);
|
||||
if (configError is not null)
|
||||
{
|
||||
_error = configError;
|
||||
return;
|
||||
}
|
||||
|
||||
var input = new RawTagInput(
|
||||
_form.Name,
|
||||
_form.DataType,
|
||||
_form.AccessLevel,
|
||||
_form.WriteIdempotent,
|
||||
string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
|
||||
string.IsNullOrWhiteSpace(_form.TagConfig) ? "{}" : _form.TagConfig);
|
||||
|
||||
var result = await Svc.UpdateTagAsync(TagId!, input, _rowVersion);
|
||||
if (result.Ok)
|
||||
{
|
||||
await OnSaved.InvokeAsync();
|
||||
await CloseAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
Visible = false;
|
||||
_loadedKey = null;
|
||||
await VisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required] public string Name { get; set; } = "";
|
||||
public string DataType { get; set; } = "Float";
|
||||
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
|
||||
public bool WriteIdempotent { get; set; }
|
||||
public string? PollGroupId { get; set; }
|
||||
[Required] public string TagConfig { get; set; } = "{}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).
|
||||
|
||||
Unlike UnsTree (which eager-loads the whole structural tree and never touches
|
||||
the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
|
||||
expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
|
||||
the returned level into node.Children and flips Loaded/Loading, showing a spinner
|
||||
in flight and an error row on failure. Per-node context menus (right-click surface
|
||||
+ "⋯" fallback) are built per RawNodeKind.
|
||||
|
||||
Wave-B integration (this pass): every named handler now opens the REAL modal/dialog
|
||||
(Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService
|
||||
directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
|
||||
built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
|
||||
refreshes the affected subtree afterwards (reload the container for create-under
|
||||
ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C
|
||||
owns it). The former RawStubModal is repurposed here as the shared message surface
|
||||
for delete-blockers, rename warnings, and the Browse "coming soon" note. This
|
||||
component is a single instance that recurses via the RenderNode RenderFragment, so it
|
||||
owns one set of modals + one injected service for the whole tree. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@foreach (var root in Roots)
|
||||
{
|
||||
@RenderNode(root, 0)
|
||||
}
|
||||
|
||||
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
|
||||
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
|
||||
|
||||
<DeviceModal @bind-Visible="_deviceVisible" DeviceId="@_deviceEditId"
|
||||
DriverInstanceId="@_deviceCreateDriverId" DriverType="@_deviceDriverType" OnSaved="DeviceSaved" />
|
||||
|
||||
<RawTagModal @bind-Visible="_tagVisible" TagId="@_tagId" DriverType="@_tagDriverType" OnSaved="TagSaved" />
|
||||
|
||||
<RawManualTagEntryModal @bind-Visible="_manualVisible" DeviceId="@_manualDeviceId"
|
||||
TagGroupId="@_manualTagGroupId" DriverType="@_manualDriverType" OnSaved="ManualSaved" />
|
||||
|
||||
<RawCsvImportModal @bind-Visible="_csvImportVisible" DeviceId="@_csvImportDeviceId"
|
||||
TagGroupId="@_csvImportTagGroupId" DriverType="@_csvImportDriverType" OnSaved="CsvImportSaved" />
|
||||
|
||||
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
|
||||
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
|
||||
|
||||
<RawBrowseModal @bind-Visible="_browseVisible" DeviceId="@_browseDeviceId"
|
||||
TagGroupId="@_browseTagGroupId" DriverType="@_browseDriverType" OnSaved="BrowseSaved" />
|
||||
|
||||
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
|
||||
|
||||
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
|
||||
OnConfirm="Confirmed" />
|
||||
|
||||
<RawDriverTypeDialog @bind-Visible="_driverTypeVisible" OnSubmit="DriverTypeSubmitted" />
|
||||
|
||||
@* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@
|
||||
<RawStubModal Visible="_msgVisible" Title="@_msgTitle" Message="@_msgMessage"
|
||||
NodeName="@_msgNodeName" OnClose="CloseMessage" />
|
||||
|
||||
@code {
|
||||
/// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
|
||||
[Parameter, EditorRequired] public IReadOnlyList<RawNode> Roots { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload
|
||||
/// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the
|
||||
/// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRootsChanged { get; set; }
|
||||
|
||||
// --- Configure-driver modal (edit) ---
|
||||
private bool _driverCfgVisible;
|
||||
private string? _driverCfgId;
|
||||
private RawNode? _driverCfgNode;
|
||||
|
||||
// --- Device modal (create + edit) ---
|
||||
private bool _deviceVisible;
|
||||
private string? _deviceEditId;
|
||||
private string? _deviceCreateDriverId;
|
||||
private string? _deviceDriverType;
|
||||
private RawNode? _deviceNode;
|
||||
private bool _deviceIsCreate;
|
||||
|
||||
// --- Edit-tag modal ---
|
||||
private bool _tagVisible;
|
||||
private string? _tagId;
|
||||
private string? _tagDriverType;
|
||||
private RawNode? _tagNode;
|
||||
|
||||
// --- Manual tag-entry modal ---
|
||||
private bool _manualVisible;
|
||||
private string _manualDeviceId = "";
|
||||
private string? _manualTagGroupId;
|
||||
private string _manualDriverType = "";
|
||||
private RawNode? _manualNode;
|
||||
|
||||
// --- CSV import modal ---
|
||||
private bool _csvImportVisible;
|
||||
private string _csvImportDeviceId = "";
|
||||
private string? _csvImportTagGroupId;
|
||||
private string _csvImportDriverType = "";
|
||||
private RawNode? _csvImportNode;
|
||||
|
||||
// --- CSV export modal (read-only, no refresh) ---
|
||||
private bool _csvExportVisible;
|
||||
private string _csvExportDeviceId = "";
|
||||
private string? _csvExportTagGroupId;
|
||||
private string _csvExportDriverType = "";
|
||||
|
||||
// --- Browse-device modal (WP6 discovery-browser re-target) ---
|
||||
private bool _browseVisible;
|
||||
private string _browseDeviceId = "";
|
||||
private string? _browseTagGroupId;
|
||||
private string _browseDriverType = "";
|
||||
private RawNode? _browseNode;
|
||||
|
||||
// --- Name dialog (New folder/group, Rename) ---
|
||||
private bool _nameVisible;
|
||||
private string _nameTitle = "";
|
||||
private string _nameInitial = "";
|
||||
private Func<string, Task>? _nameSubmit;
|
||||
|
||||
// --- Confirm dialog (deletes) ---
|
||||
private bool _confirmVisible;
|
||||
private string _confirmTitle = "";
|
||||
private string _confirmMessage = "";
|
||||
private Func<Task>? _confirmAction;
|
||||
|
||||
// --- Driver-type dialog (New driver) ---
|
||||
private bool _driverTypeVisible;
|
||||
private Func<(string DriverType, string Name), Task>? _driverTypeSubmit;
|
||||
|
||||
// --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note ---
|
||||
private bool _msgVisible;
|
||||
private string _msgTitle = "";
|
||||
private string _msgMessage = "";
|
||||
private string? _msgNodeName;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
|
||||
{
|
||||
var indent = $"padding-left:{depth * 18}px";
|
||||
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
|
||||
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
|
||||
var items = MenuFor(node);
|
||||
|
||||
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
|
||||
@if (hasExpander)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
|
||||
@onclick="@(() => ToggleAsync(node))">
|
||||
@(node.Expanded ? "▼" : "▶")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="width:18px"></span>
|
||||
}
|
||||
|
||||
@if (items.Count > 0)
|
||||
{
|
||||
<ContextMenu Items="items" ShowEllipsis="true">
|
||||
@RenderRowBody(node, muted)
|
||||
</ContextMenu>
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderRowBody(node, muted)
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (node.Expanded && node.Loading)
|
||||
{
|
||||
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
||||
</div>
|
||||
}
|
||||
else if (node.Expanded && node.Error is not null)
|
||||
{
|
||||
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
|
||||
@node.Error
|
||||
</div>
|
||||
}
|
||||
else if (node.Expanded)
|
||||
{
|
||||
@foreach (var child in node.Children)
|
||||
{
|
||||
@RenderNode(child, depth + 1)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
|
||||
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
|
||||
{
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
<span aria-hidden="true">@KindIcon(node.Kind)</span>
|
||||
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
|
||||
@if (muted)
|
||||
{
|
||||
<span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
|
||||
}
|
||||
@if (node.ChildCount > 0)
|
||||
{
|
||||
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
|
||||
}
|
||||
</span>
|
||||
};
|
||||
|
||||
private static string KindIcon(RawNodeKind kind) => kind switch
|
||||
{
|
||||
RawNodeKind.Enterprise => "🏢",
|
||||
RawNodeKind.Cluster => "🖥️",
|
||||
RawNodeKind.Folder => "📁",
|
||||
RawNodeKind.Driver => "🔌",
|
||||
RawNodeKind.Device => "📟",
|
||||
RawNodeKind.TagGroup => "🗂️",
|
||||
RawNodeKind.Tag => "🏷️",
|
||||
_ => "•",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy expansion — the whole point of WP2 (UNS never did this).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Toggles a node's expansion. On first expand of an unloaded container this sets
|
||||
/// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
|
||||
/// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
|
||||
/// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
|
||||
/// reaches here.
|
||||
/// </summary>
|
||||
private async Task ToggleAsync(RawNode node)
|
||||
{
|
||||
if (node.Loading) { return; }
|
||||
|
||||
node.Expanded = !node.Expanded;
|
||||
|
||||
if (node.Expanded && node.HasLazyChildren && !node.Loaded)
|
||||
{
|
||||
node.Loading = true;
|
||||
node.Error = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var children = await Svc.LoadChildrenAsync(node);
|
||||
node.Children.Clear();
|
||||
node.Children.AddRange(children);
|
||||
node.ChildCount = node.Children.Count;
|
||||
node.Loaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
node.Error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
node.Loading = false;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the
|
||||
// named handlers below are the seam Wave B/C replaces with the real modals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
|
||||
{
|
||||
RawNodeKind.Cluster => ClusterMenu(node),
|
||||
RawNodeKind.Folder => FolderMenu(node),
|
||||
RawNodeKind.Driver => DriverMenu(node),
|
||||
RawNodeKind.Device => DeviceMenu(node),
|
||||
RawNodeKind.TagGroup => TagGroupMenu(node),
|
||||
RawNodeKind.Tag => TagMenu(node),
|
||||
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
||||
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> FolderMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
||||
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
|
||||
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> DriverMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
|
||||
new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
|
||||
OnClick = () => OnToggleDriverEnabled(node) },
|
||||
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
|
||||
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
|
||||
new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
// "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
|
||||
new() { Label = "Add tags", Disabled = true },
|
||||
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
|
||||
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
|
||||
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
|
||||
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Add tags", Disabled = true },
|
||||
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
|
||||
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
|
||||
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
|
||||
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
|
||||
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
|
||||
};
|
||||
|
||||
private List<ContextMenuItem> TagMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
|
||||
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named action handlers — each opens the real modal/dialog or calls the service
|
||||
// directly, then refreshes the affected subtree. Only OnBrowseDevice is a
|
||||
// placeholder (Wave C owns device browse).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Folder / Cluster ---
|
||||
|
||||
// New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder).
|
||||
private Task OnNewFolder(RawNode node)
|
||||
{
|
||||
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
|
||||
var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
|
||||
ShowNameDialog("New folder", "", async name =>
|
||||
{
|
||||
var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name);
|
||||
if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
||||
await ReloadNodeAsync(node);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward.
|
||||
private Task OnNewDriver(RawNode node)
|
||||
{
|
||||
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
|
||||
var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
|
||||
ShowDriverTypeDialog(async choice =>
|
||||
{
|
||||
var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}");
|
||||
if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
||||
await ReloadNodeAsync(node);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnRenameFolder(RawNode node)
|
||||
{
|
||||
ShowNameDialog("Rename folder", node.DisplayName, async name =>
|
||||
await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnDeleteFolder(RawNode node)
|
||||
{
|
||||
ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () =>
|
||||
await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Driver ---
|
||||
|
||||
private Task OnConfigureDriver(RawNode node)
|
||||
{
|
||||
_driverCfgId = node.EntityId;
|
||||
_driverCfgNode = node;
|
||||
_driverCfgVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnNewDevice(RawNode node)
|
||||
{
|
||||
_deviceIsCreate = true;
|
||||
_deviceEditId = null;
|
||||
_deviceCreateDriverId = node.EntityId;
|
||||
_deviceDriverType = node.DriverType;
|
||||
_deviceNode = node;
|
||||
_deviceVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task OnToggleDriverEnabled(RawNode node)
|
||||
{
|
||||
var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion);
|
||||
if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; }
|
||||
await ReloadParentAsync(node);
|
||||
}
|
||||
|
||||
private Task OnRenameDriver(RawNode node)
|
||||
{
|
||||
ShowNameDialog("Rename driver", node.DisplayName, async name =>
|
||||
await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnDeleteDriver(RawNode node)
|
||||
{
|
||||
ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () =>
|
||||
await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Device ---
|
||||
|
||||
private Task OnConfigureDevice(RawNode node)
|
||||
{
|
||||
_deviceIsCreate = false;
|
||||
_deviceEditId = node.EntityId;
|
||||
_deviceCreateDriverId = null;
|
||||
_deviceDriverType = node.DriverType;
|
||||
_deviceNode = node;
|
||||
_deviceVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnNewTagGroup(RawNode node)
|
||||
{
|
||||
// node is a Device — its root tag-groups.
|
||||
ShowNameDialog("New tag-group", "", async name =>
|
||||
{
|
||||
var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name);
|
||||
if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
||||
await ReloadNodeAsync(node);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnDeleteDevice(RawNode node)
|
||||
{
|
||||
ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () =>
|
||||
await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Add-tags surfaces (shared by Device + TagGroup) ---
|
||||
|
||||
private Task OnAddManualTags(RawNode node)
|
||||
{
|
||||
// Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up.
|
||||
_manualDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
||||
_manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
|
||||
_manualDriverType = node.DriverType ?? "";
|
||||
_manualNode = node;
|
||||
_manualVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnImportCsv(RawNode node)
|
||||
{
|
||||
// Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device).
|
||||
if (node.Kind == RawNodeKind.Device)
|
||||
{
|
||||
_csvImportDeviceId = node.EntityId ?? "";
|
||||
_csvImportTagGroupId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_csvImportDeviceId = "";
|
||||
_csvImportTagGroupId = node.EntityId;
|
||||
}
|
||||
_csvImportDriverType = node.DriverType ?? "";
|
||||
_csvImportNode = node;
|
||||
_csvImportVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnExportCsv(RawNode node)
|
||||
{
|
||||
if (node.Kind == RawNodeKind.Device)
|
||||
{
|
||||
_csvExportDeviceId = node.EntityId ?? "";
|
||||
_csvExportTagGroupId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_csvExportDeviceId = "";
|
||||
_csvExportTagGroupId = node.EntityId;
|
||||
}
|
||||
_csvExportDriverType = node.DriverType ?? "";
|
||||
_csvExportVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Opens the discovery-browser modal against the target device/group. On a Device node the browse commits
|
||||
// at the device root; on a TagGroup node the group's device-relative path is prepended to every tag.
|
||||
private Task OnBrowseDevice(RawNode node)
|
||||
{
|
||||
_browseDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
||||
_browseTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
|
||||
_browseDriverType = node.DriverType ?? "";
|
||||
_browseNode = node;
|
||||
_browseVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- TagGroup ---
|
||||
|
||||
private Task OnNewGroup(RawNode node)
|
||||
{
|
||||
// node is a TagGroup — create a sub-group under it (needs its owning device id).
|
||||
var deviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
||||
ShowNameDialog("New group", "", async name =>
|
||||
{
|
||||
var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name);
|
||||
if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
||||
await ReloadNodeAsync(node);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnRenameTagGroup(RawNode node)
|
||||
{
|
||||
ShowNameDialog("Rename tag-group", node.DisplayName, async name =>
|
||||
await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnDeleteTagGroup(RawNode node)
|
||||
{
|
||||
ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () =>
|
||||
await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Tag ---
|
||||
|
||||
private Task OnEditTag(RawNode node)
|
||||
{
|
||||
_tagId = node.EntityId;
|
||||
_tagDriverType = node.DriverType;
|
||||
_tagNode = node;
|
||||
_tagVisible = true;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnDeleteTag(RawNode node)
|
||||
{
|
||||
ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () =>
|
||||
await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal OnSaved callbacks — refresh the affected subtree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async Task DriverCfgSaved()
|
||||
{
|
||||
if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); }
|
||||
}
|
||||
|
||||
private async Task DeviceSaved()
|
||||
{
|
||||
if (_deviceNode is null) { return; }
|
||||
// Create → reload the driver container; edit → reload the device's parent driver.
|
||||
if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); }
|
||||
else { await ReloadParentAsync(_deviceNode); }
|
||||
}
|
||||
|
||||
private async Task TagSaved()
|
||||
{
|
||||
if (_tagNode is not null) { await ReloadParentAsync(_tagNode); }
|
||||
}
|
||||
|
||||
private async Task ManualSaved()
|
||||
{
|
||||
if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); }
|
||||
}
|
||||
|
||||
private async Task CsvImportSaved()
|
||||
{
|
||||
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
|
||||
}
|
||||
|
||||
private async Task BrowseSaved()
|
||||
{
|
||||
// New tags (and any mirrored groups) land under the browsed node — reload its children.
|
||||
if (_browseNode is not null) { await ReloadNodeAsync(_browseNode); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rename / delete outcome surfacing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async Task AfterRename(RawRenameResult res, RawNode node)
|
||||
{
|
||||
if (res.Ok) { await ReloadParentAsync(node); }
|
||||
|
||||
if (!res.Ok)
|
||||
{
|
||||
ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName);
|
||||
}
|
||||
else if (res.Warnings.Count > 0)
|
||||
{
|
||||
ShowMessage("Renamed — with warnings", string.Join(" • ", res.Warnings), node.DisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AfterDelete(UnsMutationResult res, RawNode node)
|
||||
{
|
||||
if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; }
|
||||
await ReloadParentAsync(node);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog show-helpers (one instance each; a pending delegate carries the op).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private void ShowNameDialog(string title, string initial, Func<string, Task> submit)
|
||||
{
|
||||
_nameTitle = title;
|
||||
_nameInitial = initial;
|
||||
_nameSubmit = submit;
|
||||
_nameVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task NameSubmitted(string name)
|
||||
{
|
||||
var submit = _nameSubmit;
|
||||
if (submit is not null) { await submit(name); }
|
||||
}
|
||||
|
||||
private void ShowConfirm(string title, string message, Func<Task> action)
|
||||
{
|
||||
_confirmTitle = title;
|
||||
_confirmMessage = message;
|
||||
_confirmAction = action;
|
||||
_confirmVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Confirmed()
|
||||
{
|
||||
var action = _confirmAction;
|
||||
if (action is not null) { await action(); }
|
||||
}
|
||||
|
||||
private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit)
|
||||
{
|
||||
_driverTypeSubmit = submit;
|
||||
_driverTypeVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task DriverTypeSubmitted((string DriverType, string Name) choice)
|
||||
{
|
||||
var submit = _driverTypeSubmit;
|
||||
if (submit is not null) { await submit(choice); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared message surface (repurposed RawStubModal).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private void ShowMessage(string title, string message, string? nodeName)
|
||||
{
|
||||
_msgTitle = title;
|
||||
_msgMessage = message;
|
||||
_msgNodeName = nodeName;
|
||||
_msgVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseMessage()
|
||||
{
|
||||
_msgVisible = false;
|
||||
_msgTitle = "";
|
||||
_msgMessage = "";
|
||||
_msgNodeName = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree refresh + parent lookup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reloads a container node's children in place (create-under refresh): clears its loaded level and
|
||||
/// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown.
|
||||
/// </summary>
|
||||
private async Task ReloadNodeAsync(RawNode container)
|
||||
{
|
||||
try
|
||||
{
|
||||
container.Expanded = true;
|
||||
container.Loaded = false;
|
||||
container.Children.Clear();
|
||||
container.Error = null;
|
||||
|
||||
if (container.HasLazyChildren)
|
||||
{
|
||||
container.Loading = true;
|
||||
StateHasChanged();
|
||||
var children = await Svc.LoadChildrenAsync(container);
|
||||
container.Children.Clear();
|
||||
container.Children.AddRange(children);
|
||||
container.ChildCount = container.Children.Count;
|
||||
container.Loaded = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
container.Error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
container.Loading = false;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed
|
||||
/// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every
|
||||
/// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces
|
||||
/// path for a would-be cluster-less top-level node.
|
||||
/// </summary>
|
||||
private async Task ReloadParentAsync(RawNode node)
|
||||
{
|
||||
var parent = FindParent(node);
|
||||
if (parent is not null) { await ReloadNodeAsync(parent); }
|
||||
else { await OnRootsChanged.InvokeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Finds a node's parent by searching the currently-loaded tree; null for a root.</summary>
|
||||
private RawNode? FindParent(RawNode target)
|
||||
{
|
||||
foreach (var root in Roots)
|
||||
{
|
||||
var found = FindParentIn(root, target);
|
||||
if (found is not null) { return found; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RawNode? FindParentIn(RawNode node, RawNode target)
|
||||
{
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
if (ReferenceEquals(child, target)) { return node; }
|
||||
var deeper = FindParentIn(child, target);
|
||||
if (deeper is not null) { return deeper; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Walks up from <paramref name="node"/> (inclusive) to the nearest Device node, or null.</summary>
|
||||
private RawNode? FindAncestorDevice(RawNode node)
|
||||
{
|
||||
var current = node;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current.Kind == RawNodeKind.Device) { return current; }
|
||||
current = FindParent(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding —
|
||||
scriptId + change/timer triggers — reusing the VirtualTagModal's script dropdown, "New
|
||||
script" action, and inline Monaco source panel (all backed by the same IUnsTreeService
|
||||
script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap,
|
||||
so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter
|
||||
shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch
|
||||
uniformity but unused (a calc tag has no address to browse). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Script</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ScriptId" @onchange="OnScriptChangedAsync">
|
||||
<option value="">— pick script —</option>
|
||||
@foreach (var (id, display) in _scripts)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Change-triggered</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="calc-change-@_uid"
|
||||
checked="@_m.ChangeTriggered"
|
||||
@onchange="@(e => Update(() => _m.ChangeTriggered = e.Value is true))" />
|
||||
<label class="form-check-label small" for="calc-change-@_uid">On dependency change</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timer (ms)</label>
|
||||
<input type="number" min="50" step="1" class="form-control form-control-sm"
|
||||
value="@_m.TimerIntervalMs"
|
||||
@onchange="@(e => Update(() => _m.TimerIntervalMs = ParseTimer(e.Value)))" />
|
||||
<div class="form-text">Blank = change-trigger only. Min 50 ms.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* No script bound yet → offer a one-click "New script" that creates a blank Script row, binds
|
||||
it, and expands the inline editor so the operator can author the body without leaving. *@
|
||||
@if (string.IsNullOrEmpty(_m.ScriptId))
|
||||
{
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
@onclick="CreateNewScriptAsync" disabled="@_scriptCreating">
|
||||
@if (_scriptCreating) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
New script
|
||||
</button>
|
||||
<span class="form-text ms-2">Creates a blank C# script bound to this calc tag.</span>
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptCreateError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptCreateError</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Inline script-source editor — shown only when a script is bound. Saves the SHARED Script row on
|
||||
its own concurrency-guarded button; independent of the enclosing tag save. *@
|
||||
@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded)
|
||||
{
|
||||
<div class="card mt-2">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Script source</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
@onclick="ToggleScriptPanel">
|
||||
@(_scriptExpanded ? "Hide" : "Edit source")
|
||||
</button>
|
||||
</div>
|
||||
@if (_scriptExpanded)
|
||||
{
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning py-2 small mb-2">
|
||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||
@_scriptUsageCount virtual tag(s). Changes affect every tag using it.
|
||||
</div>
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptError</div>
|
||||
}
|
||||
else if (_scriptSaved)
|
||||
{
|
||||
<div class="text-success small mt-2">Script saved.</div>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
@onclick="SaveScriptAsync" disabled="@_scriptSaving">
|
||||
@if (_scriptSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity; unused here.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig — accepted for dispatch uniformity;
|
||||
/// unused (a calc tag has no address to browse).</summary>
|
||||
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private CalculationTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
// Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids.
|
||||
private readonly string _uid = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
// Script dropdown options — loaded once, plus any inline-created script appended.
|
||||
private List<(string Id, string Display)> _scripts = new();
|
||||
|
||||
// Inline script-source editor state (independent of the tag save).
|
||||
private string _scriptSource = "";
|
||||
private bool _scriptLoaded;
|
||||
private byte[] _scriptRowVersion = Array.Empty<byte>();
|
||||
private string? _scriptName;
|
||||
private int _scriptUsageCount;
|
||||
private bool _scriptExpanded;
|
||||
private bool _scriptSaving;
|
||||
private bool _scriptSaved;
|
||||
private string? _scriptError;
|
||||
|
||||
// "New script" action state.
|
||||
private bool _scriptCreating;
|
||||
private string? _scriptCreateError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var scripts = await Svc.LoadScriptsAsync();
|
||||
_scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList();
|
||||
}
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes) can't reset the operator's in-progress edits.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = CalculationTagConfigModel.FromJson(ConfigJson);
|
||||
await LoadScriptSourceAsync();
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await WriteBackAsync();
|
||||
}
|
||||
|
||||
// Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse.
|
||||
private async Task WriteBackAsync()
|
||||
{
|
||||
var json = _m.ToJson();
|
||||
_lastConfigJson = json;
|
||||
await ConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private static int? ParseTimer(object? v)
|
||||
=> int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null;
|
||||
|
||||
/// <summary>Refreshes the inline script panel + writes the new binding back when the operator picks
|
||||
/// a different script.</summary>
|
||||
private async Task OnScriptChangedAsync(ChangeEventArgs e)
|
||||
{
|
||||
_m.ScriptId = e.Value?.ToString() ?? "";
|
||||
await WriteBackAsync();
|
||||
await LoadScriptSourceAsync();
|
||||
}
|
||||
|
||||
/// <summary>Loads the bound script's source for the inline panel; clears it when no script is bound.</summary>
|
||||
private async Task LoadScriptSourceAsync()
|
||||
{
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
|
||||
if (string.IsNullOrEmpty(_m.ScriptId))
|
||||
{
|
||||
_scriptSource = "";
|
||||
_scriptLoaded = false;
|
||||
_scriptRowVersion = Array.Empty<byte>();
|
||||
_scriptName = null;
|
||||
_scriptUsageCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
|
||||
if (loaded is null)
|
||||
{
|
||||
_scriptSource = "";
|
||||
_scriptLoaded = false;
|
||||
_scriptRowVersion = Array.Empty<byte>();
|
||||
_scriptName = null;
|
||||
_scriptUsageCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptSource = loaded.Value.SourceCode;
|
||||
_scriptLoaded = true;
|
||||
_scriptRowVersion = loaded.Value.RowVersion;
|
||||
_scriptName = loaded.Value.Name;
|
||||
_scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId);
|
||||
}
|
||||
|
||||
/// <summary>Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists
|
||||
/// only the new Script row — the tag binding lives in the model until the host's save.</summary>
|
||||
private async Task CreateNewScriptAsync()
|
||||
{
|
||||
_scriptCreating = true;
|
||||
_scriptCreateError = null;
|
||||
try
|
||||
{
|
||||
const string seedName = "Calc script";
|
||||
var result = await Svc.CreateScriptAsync(seedName);
|
||||
if (!result.Ok || string.IsNullOrEmpty(result.CreatedId))
|
||||
{
|
||||
_scriptCreateError = result.Error ?? "Could not create the script.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the option BEFORE binding so the <select> can resolve the label on first render.
|
||||
if (!_scripts.Any(s => s.Id == result.CreatedId))
|
||||
{
|
||||
_scripts.Add((result.CreatedId, $"{seedName} (CSharp)"));
|
||||
}
|
||||
_m.ScriptId = result.CreatedId;
|
||||
await WriteBackAsync();
|
||||
await LoadScriptSourceAsync();
|
||||
_scriptExpanded = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scriptCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggles the inline script-source panel, clearing a stale "saved" banner on (re)expand.</summary>
|
||||
private void ToggleScriptPanel()
|
||||
{
|
||||
_scriptExpanded = !_scriptExpanded;
|
||||
if (_scriptExpanded) { _scriptSaved = false; }
|
||||
}
|
||||
|
||||
/// <summary>Saves the edited script body via its own RowVersion-guarded call. Wholly separate from the
|
||||
/// tag save: never touches the model or closes the enclosing modal.</summary>
|
||||
private async Task SaveScriptAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_m.ScriptId) || !_scriptLoaded) { return; }
|
||||
|
||||
_scriptSaving = true;
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
try
|
||||
{
|
||||
var result = await Svc.UpdateScriptSourceAsync(_m.ScriptId, _scriptSource, _scriptRowVersion);
|
||||
if (result.Ok)
|
||||
{
|
||||
var reloaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
|
||||
if (reloaded is not null)
|
||||
{
|
||||
_scriptSource = reloaded.Value.SourceCode;
|
||||
_scriptRowVersion = reloaded.Value.RowVersion;
|
||||
_scriptName = reloaded.Value.Name;
|
||||
}
|
||||
_scriptSaved = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_scriptError = result.Error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scriptSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a raw <c>Tag</c> — the read side of tag editing. WP1 exposes this
|
||||
/// loader; WP4 owns the create/update authoring surface and should align its input model with these
|
||||
/// fields (identity is the RawPath, formed from the tag's device + optional group + name).
|
||||
/// </summary>
|
||||
/// <param name="TagId">The tag's stable logical id.</param>
|
||||
/// <param name="DeviceId">The owning device's logical id.</param>
|
||||
/// <param name="TagGroupId">The containing tag group's logical id, or null when directly under the device.</param>
|
||||
/// <param name="Name">The tag name (a RawPath segment).</param>
|
||||
/// <param name="DataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="AccessLevel">The tag's access-level baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes to this tag are retry-eligible.</param>
|
||||
/// <param name="PollGroupId">The optional poll-group id.</param>
|
||||
/// <param name="TagConfig">The schemaless per-driver TagConfig JSON blob.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
public readonly record struct RawTagEditDto(
|
||||
string TagId,
|
||||
string DeviceId,
|
||||
string? TagGroupId,
|
||||
string Name,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the Wave-B (WP3)
|
||||
/// "Configure driver" modal. <c>DriverType</c> is included so the modal can dispatch to the correct
|
||||
/// typed driver-config editor; it is immutable, so <see cref="IRawTreeService.UpdateDriverAsync"/> does
|
||||
/// not change it.
|
||||
/// </summary>
|
||||
/// <param name="DriverInstanceId">The driver instance's stable logical id.</param>
|
||||
/// <param name="Name">The driver instance name (a RawPath segment).</param>
|
||||
/// <param name="DriverType">The immutable driver-type string (see <c>DriverTypeNames</c>).</param>
|
||||
/// <param name="DriverConfig">The schemaless per-driver-type <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="ResilienceConfig">Optional per-instance resilience-pipeline overrides JSON, or null.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
public sealed record RawDriverEditDto(
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DriverType,
|
||||
string DriverConfig,
|
||||
string? ResilienceConfig,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a <c>Device</c> — the read side of the Wave-B (WP3) "Configure
|
||||
/// device" modal. <c>DriverType</c> is joined in from the parent <c>DriverInstance</c> so the modal can
|
||||
/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in
|
||||
/// <c>DeviceConfig</c>).
|
||||
/// </summary>
|
||||
/// <param name="DeviceId">The device's stable logical id.</param>
|
||||
/// <param name="DriverInstanceId">The owning driver instance's logical id.</param>
|
||||
/// <param name="Name">The device name (a RawPath segment).</param>
|
||||
/// <param name="DeviceConfig">The schemaless per-driver-type <c>DeviceConfig</c> JSON blob (host/endpoint + per-device settings).</param>
|
||||
/// <param name="Enabled">Whether the device is enabled.</param>
|
||||
/// <param name="DriverType">The parent driver's type string, joined in for editor dispatch.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
public sealed record RawDeviceEditDto(
|
||||
string DeviceId,
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DeviceConfig,
|
||||
bool Enabled,
|
||||
string DriverType,
|
||||
byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Loads and mutates the v3 Raw project tree (<c>/raw</c>) — the cluster-rooted
|
||||
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
|
||||
/// database. The peer of <see cref="IUnsTreeService"/> for the Raw subtree.
|
||||
/// <para>
|
||||
/// <b>Contract split (Batch 2 Wave A):</b> this file's committed surface is the <b>read</b> contract
|
||||
/// the <c>RawTree</c> component consumes — <see cref="LoadRootsAsync"/> (eager Enterprise→Cluster roots)
|
||||
/// and <see cref="LoadChildrenAsync"/> (one lazy level per expand). B2-WP1 (<c>RawTreeService</c>)
|
||||
/// <b>extends this interface</b> with the mutation surface — create/rename/delete per node type,
|
||||
/// move-into-folder, and the per-node edit projections — enforcing the integrity rules with
|
||||
/// user-readable errors (a delete blocked by a <c>UnsTagReference</c> names the referencing equipment).
|
||||
/// Rename methods return <see cref="RawRenameResult"/> so the UI can show non-blocking warnings.
|
||||
/// Mutations reuse <see cref="UnsMutationResult"/>. WP2 must not add to this interface.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IRawTreeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the eager top of the Raw tree: Enterprise → Cluster roots, each cluster marked
|
||||
/// <see cref="RawNode.HasLazyChildren"/> so its folders + drivers load on first expand. Empty
|
||||
/// clusters are retained so they stay visible and authorable. The returned nodes are detached
|
||||
/// view-models, safe to hold and mutate UI state on after the underlying context is disposed.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The enterprise roots, populated down to (but not through) their clusters.</returns>
|
||||
Task<IReadOnlyList<RawNode>> LoadRootsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the direct children of one container node — the next level down, keyed off
|
||||
/// <paramref name="parent"/>'s <see cref="RawNode.Kind"/> and <see cref="RawNode.EntityId"/>:
|
||||
/// a Cluster yields its root Folders + Drivers; a Folder yields sub-Folders + Drivers; a Driver
|
||||
/// yields its Devices; a Device yields its root TagGroups + Tags; a TagGroup yields sub-TagGroups
|
||||
/// + Tags. Tags are leaves and yield nothing. Ordering is deterministic and ordinal. Reads
|
||||
/// untracked; returns detached nodes. Per-device tag counts can be large — the implementation may
|
||||
/// page, but the committed shape returns the level's nodes (paging knobs, if any, are WP1's to add
|
||||
/// on the concrete type without changing this signature's meaning for the tree).
|
||||
/// </summary>
|
||||
/// <param name="parent">The container node whose direct children to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The parent's direct child nodes; empty for a leaf or an empty container.</returns>
|
||||
Task<IReadOnlyList<RawNode>> LoadChildrenAsync(RawNode parent, CancellationToken ct = default);
|
||||
|
||||
// --- Folder mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <c>RawFolder</c> under a cluster (<paramref name="parentFolderId"/> null) or under
|
||||
/// another folder. The name is validated as a RawPath segment and must be unique among its siblings
|
||||
/// (case-sensitive ordinal). On success the result's <c>CreatedId</c> carries the new folder's
|
||||
/// logical id.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The owning cluster id.</param>
|
||||
/// <param name="parentFolderId">The parent folder's logical id, or null for a cluster-root folder.</param>
|
||||
/// <param name="name">The folder name (a RawPath segment).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>RawFolderId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>RawFolder</c>. Validates the new name as a RawPath segment and sibling-unique.
|
||||
/// Because a folder rename changes the RawPath of every tag beneath it, the returned
|
||||
/// <see cref="RawRenameResult"/> carries advisory warnings for historized and equipment-referenced
|
||||
/// tags in the folder's subtree.
|
||||
/// </summary>
|
||||
/// <param name="rawFolderId">The folder's logical id.</param>
|
||||
/// <param name="newName">The new folder name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an empty <c>RawFolder</c>. Blocked (with the blocker named) while it still contains child
|
||||
/// folders or driver instances.
|
||||
/// </summary>
|
||||
/// <param name="rawFolderId">The folder's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a driver instance into a folder (<paramref name="targetFolderId"/>) or to the cluster root
|
||||
/// (null target). The target folder must be in the driver's cluster and the driver's name must be
|
||||
/// unique among the target's driver siblings.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="targetFolderId">The destination folder's logical id, or null for the cluster root.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The move outcome.</returns>
|
||||
Task<UnsMutationResult> MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Driver mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal driver instance and its auto-created default <c>Device</c> so the tree has a
|
||||
/// child to show. Full typed driver/device config authoring is Wave B (WP3) — this create takes the
|
||||
/// driver config JSON verbatim and seeds an empty (<c>{}</c>) default device. The driver name is a
|
||||
/// RawPath segment, unique among its cluster/folder siblings.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The owning cluster id.</param>
|
||||
/// <param name="folderId">The containing folder's logical id, or null for a cluster-root driver.</param>
|
||||
/// <param name="name">The driver instance name (a RawPath segment).</param>
|
||||
/// <param name="driverType">The driver-type string (see <c>DriverTypeNames</c>).</param>
|
||||
/// <param name="driverConfigJson">The driver config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DriverInstanceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a driver instance. Validates the new name as a sibling-unique RawPath segment and returns
|
||||
/// downstream-impact warnings for historized/equipment-referenced tags beneath the driver.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="newName">The new driver name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Enables or disables a driver instance (last-write-wins on <paramref name="rowVersion"/>).</summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="enabled">The new enabled state.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a driver instance, cascading its auto-created empty devices. Blocked (with the blocking
|
||||
/// device named) when any of its devices still contains tag groups or tags — remove those first.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Device mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <c>Device</c> under a driver. Full typed device-config authoring is Wave B
|
||||
/// (WP3) — this create takes the device config JSON verbatim. The device name must be unique among
|
||||
/// the driver's devices.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The owning driver instance's logical id.</param>
|
||||
/// <param name="name">The device name (a RawPath segment).</param>
|
||||
/// <param name="deviceConfigJson">The device config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DeviceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>Device</c>. Validates the new name as a driver-unique RawPath segment and returns
|
||||
/// downstream-impact warnings for historized/equipment-referenced tags beneath the device.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="newName">The new device name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Deletes a <c>Device</c>. Blocked (with the blocker named) while it holds tag groups or tags.</summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- TagGroup mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <c>TagGroup</c> under a device (<paramref name="parentGroupId"/> null) or under
|
||||
/// another group. The name is validated as a RawPath segment, unique among its siblings.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The owning device's logical id.</param>
|
||||
/// <param name="parentGroupId">The parent group's logical id, or null for a device-root group.</param>
|
||||
/// <param name="name">The group name (a RawPath segment).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagGroupId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>TagGroup</c>. Validates the new name as a sibling-unique RawPath segment and returns
|
||||
/// downstream-impact warnings for historized/equipment-referenced tags beneath the group.
|
||||
/// </summary>
|
||||
/// <param name="tagGroupId">The group's logical id.</param>
|
||||
/// <param name="newName">The new group name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Deletes a <c>TagGroup</c>. Blocked (with the blocker named) while it holds child groups or tags.</summary>
|
||||
/// <param name="tagGroupId">The group's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Tag mutations / projections (create + update are WP4) ---
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a raw <c>Tag</c>. Blocked while any <c>UnsTagReference</c> points at it; the failure
|
||||
/// message names the referencing equipment(s) so the operator can remove the reference(s) first.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a raw tag's editable fields as a detached projection. WP1 provides the read-only projection;
|
||||
/// the create/update authoring surface is WP4 (which should align its input shape with
|
||||
/// <see cref="RawTagEditDto"/>).
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The tag's editable projection, or null when the tag does not exist.</returns>
|
||||
Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw <c>Tag</c> under a device (<paramref name="tagGroupId"/> null) or a tag group. The
|
||||
/// name is validated as a RawPath segment and must be unique among its siblings on the same
|
||||
/// <c>(DeviceId, TagGroupId)</c>; the <see cref="RawTagInput.TagConfig"/> is validated for JSON
|
||||
/// well-formedness. On success the result's <c>CreatedId</c> carries the new tag's logical id.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The owning device's logical id.</param>
|
||||
/// <param name="tagGroupId">The containing tag group's logical id, or null for a device-root tag.</param>
|
||||
/// <param name="input">The tag's editable fields (identity is the RawPath — no TagId here).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a raw <c>Tag</c>'s editable fields. The new name is re-validated as a RawPath segment and
|
||||
/// sibling-unique on the tag's existing <c>(DeviceId, TagGroupId)</c> excluding itself; the
|
||||
/// <see cref="RawTagInput.TagConfig"/> is validated for JSON well-formedness. Last-write-wins on
|
||||
/// <paramref name="rowVersion"/>. The tag's <c>DeviceId</c>/<c>TagGroupId</c> (its identity location)
|
||||
/// are preserved — moving a tag is not this method's job.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="input">The tag's edited fields.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded tag.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Commits a batch of raw tags under a device — the WP5 CSV-import path. Each row's
|
||||
/// <c>TagGroupPath</c> is a <c>/</c>-separated tag-group path under the device whose segments are
|
||||
/// auto-created as nested <c>TagGroup</c>s (reusing existing ones). Per-row validation errors (bad
|
||||
/// name segment, malformed <c>TagConfig</c> JSON, a duplicate name within the device+group) are
|
||||
/// collected, not thrown. The import is <b>all-or-nothing</b>: if any row errors, nothing is inserted
|
||||
/// and the errors are returned; otherwise all tags (and any newly-needed groups) are committed in one
|
||||
/// <c>SaveChanges</c>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The target device's logical id.</param>
|
||||
/// <param name="rows">The rows to import.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The import outcome: the inserted count on success, or the per-row errors with a 0 count.</returns>
|
||||
Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver"
|
||||
/// modal. <c>DriverType</c> is included for typed-editor dispatch (it is immutable).
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The driver's editable projection, or null when it does not exist.</returns>
|
||||
Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a driver instance's name + config from the WP3 "Configure driver" modal. The name is
|
||||
/// validated as a sibling-unique RawPath segment (same scope as <see cref="RenameDriverAsync"/>);
|
||||
/// last-write-wins on <paramref name="rowVersion"/>. <c>DriverType</c> is immutable and is not changed.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="name">The (possibly changed) driver name.</param>
|
||||
/// <param name="driverConfigJson">The new <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="resilienceConfig">The new resilience-config JSON, or null/blank to clear.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal.
|
||||
/// <c>DriverType</c> is joined in from the parent driver so the modal can dispatch to the correct typed
|
||||
/// device-config editor.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The device's editable projection, or null when it does not exist.</returns>
|
||||
Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a device's name + config + enabled state from the WP3 "Configure device" modal. The name
|
||||
/// is validated as a RawPath segment unique among the driver's devices; last-write-wins on
|
||||
/// <paramref name="rowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="name">The (possibly changed) device name.</param>
|
||||
/// <param name="deviceConfigJson">The new <c>DeviceConfig</c> JSON blob (endpoint + per-device settings).</param>
|
||||
/// <param name="enabled">The new enabled state.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device:
|
||||
/// the parent driver's <c>DriverConfig</c> folded together with this device's <c>DeviceConfig</c> (via
|
||||
/// <c>DriverDeviceConfigMerger</c>, endpoint keys merging up from <c>DeviceConfig</c>). Used by the
|
||||
/// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in
|
||||
/// <c>DeviceConfig</c>.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device to probe.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved.</returns>
|
||||
Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a
|
||||
/// <see cref="RawTagImportRow"/> ready for <see cref="IRawTreeService.ImportTagsAsync"/>. Under the v3
|
||||
/// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's
|
||||
/// driver reference is written into the driver-typed <c>TagConfig</c> as an ordinary <b>address field</b>
|
||||
/// (<c>nodeId</c>/<c>tagPath</c>/<c>symbolPath</c>/<c>address</c>/<c>attributeRef</c> per driver) — never an
|
||||
/// identity key. The address-field write reuses the same <c><Driver>TagConfigModel</c> the typed tag
|
||||
/// editors use, so a browse-committed tag round-trips through that editor unchanged.
|
||||
/// </summary>
|
||||
public static class RawBrowseCommitMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps one selected browse leaf into an import row.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning device's driver type (a <see cref="DriverTypeNames"/> value).</param>
|
||||
/// <param name="fullName">The leaf's driver-side full reference (the <c>BrowseNode.NodeId</c> = the
|
||||
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
|
||||
/// <param name="browseName">The leaf's browse name — becomes the raw tag <c>Name</c> (a RawPath segment).</param>
|
||||
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
|
||||
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
|
||||
/// <param name="defaultDataType">The OPC-UA built-in type name to fall back to when
|
||||
/// <paramref name="driverDataType"/> is null/unmappable.</param>
|
||||
/// <param name="groupPrefix">The target TagGroup's device-relative path when committing under a group,
|
||||
/// or null when committing at the device root. Prepended to any mirrored folder path.</param>
|
||||
/// <param name="folderPath">The captured browse-folder nesting above the leaf (root→parent display
|
||||
/// names); mirrored onto nested TagGroups only when <paramref name="createGroups"/> is true.</param>
|
||||
/// <param name="createGroups">When true, mirror <paramref name="folderPath"/> as nested TagGroups.</param>
|
||||
/// <returns>The assembled import row.</returns>
|
||||
public static RawTagImportRow MapLeaf(
|
||||
string driverType,
|
||||
string fullName,
|
||||
string browseName,
|
||||
string? driverDataType,
|
||||
string defaultDataType,
|
||||
string? groupPrefix,
|
||||
IReadOnlyList<string>? folderPath,
|
||||
bool createGroups)
|
||||
{
|
||||
var dataType = MapDataType(driverDataType) ?? defaultDataType;
|
||||
var tagConfig = BuildTagConfig(driverType, fullName);
|
||||
var mirrored = createGroups && folderPath is { Count: > 0 }
|
||||
? string.Join(RawPaths.Separator, folderPath)
|
||||
: null;
|
||||
var groupPath = CombineGroupPath(groupPrefix, mirrored);
|
||||
|
||||
// Access baseline mirrors manual entry (Read); write-idempotent stays false (unknown at browse time);
|
||||
// no poll-group (batching is authored later).
|
||||
return new RawTagImportRow(
|
||||
groupPath,
|
||||
new RawTagInput(browseName, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="DriverDataType"/> enum NAME (as reported by the browse attribute side-channel) to the
|
||||
/// OPC-UA built-in type name a <see cref="RawTagInput.DataType"/> carries. Mirrors
|
||||
/// <c>DiscoveredNodeMapper.ToBuiltinTypeString</c>: most names pass through, <c>Float32</c>/<c>Float64</c>
|
||||
/// become <c>Float</c>/<c>Double</c>, and <c>Reference</c> (a Galaxy attribute ref) carries as <c>String</c>.
|
||||
/// Returns null when the input is null/blank or not a known driver data type (caller supplies a default).
|
||||
/// </summary>
|
||||
/// <param name="driverDataType">The <see cref="DriverDataType"/> name, or null/blank.</param>
|
||||
/// <returns>The OPC-UA built-in type name, or null when unmappable.</returns>
|
||||
public static string? MapDataType(string? driverDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverDataType)
|
||||
|| !Enum.TryParse<DriverDataType>(driverDataType, ignoreCase: true, out var dt))
|
||||
return null;
|
||||
|
||||
return dt switch
|
||||
{
|
||||
DriverDataType.Boolean => "Boolean",
|
||||
DriverDataType.Int16 => "Int16",
|
||||
DriverDataType.Int32 => "Int32",
|
||||
DriverDataType.Int64 => "Int64",
|
||||
DriverDataType.UInt16 => "UInt16",
|
||||
DriverDataType.UInt32 => "UInt32",
|
||||
DriverDataType.UInt64 => "UInt64",
|
||||
DriverDataType.Float32 => "Float",
|
||||
DriverDataType.Float64 => "Double",
|
||||
DriverDataType.String => "String",
|
||||
DriverDataType.DateTime => "DateTime",
|
||||
DriverDataType.Reference => "String",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the driver-typed <c>TagConfig</c> JSON for a browse-committed tag, setting ONLY the driver's
|
||||
/// address field to <paramref name="fullName"/> and leaving every other field at its model default. Reuses
|
||||
/// the <c><Driver>TagConfigModel</c> for driver types that have a typed editor; the model-less Galaxy
|
||||
/// driver gets the canonical camelCase <c>attributeRef</c> key directly.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning device's driver type.</param>
|
||||
/// <param name="fullName">The leaf's driver-side full reference to write into the address field.</param>
|
||||
/// <returns>The serialised driver-typed <c>TagConfig</c> JSON.</returns>
|
||||
public static string BuildTagConfig(string driverType, string fullName)
|
||||
{
|
||||
var address = fullName ?? "";
|
||||
if (Is(driverType, DriverTypeNames.OpcUaClient))
|
||||
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.AbCip))
|
||||
return new AbCipTagConfigModel { TagPath = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.AbLegacy))
|
||||
return new AbLegacyTagConfigModel { Address = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.TwinCAT))
|
||||
return new TwinCATTagConfigModel { SymbolPath = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.FOCAS))
|
||||
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.S7))
|
||||
return new S7TagConfigModel { Address = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||
return WriteSingleKey("attributeRef", address);
|
||||
|
||||
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
|
||||
// "address" key so nothing is lost. Browsable drivers are all handled above.
|
||||
return WriteSingleKey("address", address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
|
||||
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
|
||||
/// separator. Returns null when both are absent (⇒ commit at the device root).
|
||||
/// </summary>
|
||||
/// <param name="prefix">The target TagGroup's device-relative path, or null/blank for the device root.</param>
|
||||
/// <param name="suffix">The mirrored folder sub-path, or null/blank.</param>
|
||||
/// <returns>The combined device-relative group path, or null for the device root.</returns>
|
||||
public static string? CombineGroupPath(string? prefix, string? suffix)
|
||||
{
|
||||
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
|
||||
suffix = string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim();
|
||||
if (prefix is null) return suffix;
|
||||
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
|
||||
}
|
||||
|
||||
private static bool Is(string driverType, string name)
|
||||
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string WriteSingleKey(string key, string value)
|
||||
{
|
||||
var o = new JsonObject { [key] = value };
|
||||
return o.ToJsonString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of node within the v3 Raw project tree (<c>/raw</c>). Determines how the
|
||||
/// renderer styles the row, which context-menu it shows, and which entity (if any) it
|
||||
/// links to. The Raw tree is cluster-rooted and mirrors the RawPath hierarchy:
|
||||
/// Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag.
|
||||
/// </summary>
|
||||
public enum RawNodeKind
|
||||
{
|
||||
/// <summary>Top-level grouping label (a string on cluster rows, not a DB entity).</summary>
|
||||
Enterprise,
|
||||
|
||||
/// <summary>A server cluster — the root of a Raw project tree.</summary>
|
||||
Cluster,
|
||||
|
||||
/// <summary>A driver-organizing <c>RawFolder</c> (nestable under a cluster or another folder).</summary>
|
||||
Folder,
|
||||
|
||||
/// <summary>A <c>DriverInstance</c>.</summary>
|
||||
Driver,
|
||||
|
||||
/// <summary>A <c>Device</c> under a driver (universal in v3 — every driver has ≥1).</summary>
|
||||
Device,
|
||||
|
||||
/// <summary>A tag-organizing <c>TagGroup</c> (nestable under a device or another group).</summary>
|
||||
TagGroup,
|
||||
|
||||
/// <summary>A raw <c>Tag</c> (signal) — a leaf.</summary>
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// View-model for a single node in the Raw project tree. Carries the stable identity,
|
||||
/// display text, per-kind ids, and lazy-load metadata the renderer needs, plus transient UI
|
||||
/// state (expansion/loading) that is never persisted. Detached from EF — safe to hold and
|
||||
/// mutate UI state on after the loading context is disposed. This is the shared contract
|
||||
/// between <see cref="IRawTreeService"/> (which produces nodes) and the <c>RawTree</c>
|
||||
/// component (which renders + lazily expands them).
|
||||
/// </summary>
|
||||
public sealed class RawNode
|
||||
{
|
||||
/// <summary>The kind of node — drives styling, context-menu, and entity linking.</summary>
|
||||
public required RawNodeKind Kind { get; init; }
|
||||
|
||||
/// <summary>Stable per-node key, unique within the tree (e.g. <c>drv:{driverInstanceId}</c>).</summary>
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>Human-readable label shown in the tree (the node's <c>Name</c>, or Enterprise/Cluster label).</summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>Owning cluster id for every node under a cluster (and the cluster itself); null for Enterprise.</summary>
|
||||
public string? ClusterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// This node's own logical entity id (<c>RawFolderId</c>/<c>DriverInstanceId</c>/<c>DeviceId</c>/
|
||||
/// <c>TagGroupId</c>/<c>TagId</c>, or the <c>ClusterId</c> for a cluster); null for Enterprise. The
|
||||
/// child loader keys off <see cref="Kind"/> + this id to fetch the next level.
|
||||
/// </summary>
|
||||
public string? EntityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bound <c>DriverType</c> for <see cref="RawNodeKind.Driver"/> (and propagated to Device/TagGroup/Tag
|
||||
/// nodes beneath it, so tag editors and the browse-enable gate can dispatch without another query);
|
||||
/// null for folders/clusters/enterprise. Use the <c>DriverTypeNames</c> constants when matching.
|
||||
/// </summary>
|
||||
public string? DriverType { get; init; }
|
||||
|
||||
/// <summary>Whether a Driver/Device node is enabled (drives the enable/disable menu label + styling); true otherwise.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Direct child count (for the badge) — for a device, its root tag-groups + root tags (direct children only, not the whole subtree).</summary>
|
||||
public int ChildCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this node's children load lazily on expand. True for every container kind in the Raw tree
|
||||
/// (Cluster/Folder/Driver/Device/TagGroup) — unlike the UNS tree, <c>/raw</c> actually exercises the
|
||||
/// lazy plumbing (per-device tag counts can be large). Tags are leaves (false).
|
||||
/// </summary>
|
||||
public bool HasLazyChildren { get; init; }
|
||||
|
||||
/// <summary>Lazily-materialised children — empty until <see cref="Loaded"/> flips on expand.</summary>
|
||||
public List<RawNode> Children { get; } = new();
|
||||
|
||||
/// <summary>Optimistic-concurrency token for this node's backing row (empty for Enterprise); echoed on mutations.</summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
|
||||
// --- Runtime UI state (not persisted) ---
|
||||
|
||||
/// <summary>Whether the node is currently expanded in the UI.</summary>
|
||||
public bool Expanded { get; set; }
|
||||
|
||||
/// <summary>Whether the node's lazy children have been loaded.</summary>
|
||||
public bool Loaded { get; set; }
|
||||
|
||||
/// <summary>Whether a lazy-load is currently in flight for this node.</summary>
|
||||
public bool Loading { get; set; }
|
||||
|
||||
/// <summary>Last load error message, if any.</summary>
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a Raw-tree rename. Like <see cref="UnsMutationResult"/> it reports success/failure,
|
||||
/// but a rename additionally surfaces <see cref="Warnings"/>: non-blocking advisories the UI shows
|
||||
/// after the rename succeeds (a RawPath segment changed, so downstream references may need attention).
|
||||
/// In this batch the warnings answerable from the schema are wired — a renamed node with historized
|
||||
/// tags beneath it, or raw tags referenced by an equipment (<c>UnsTagReference</c>). Batch 3 adds the
|
||||
/// script-substring scan to the same list. On failure <see cref="Ok"/> is false, <see cref="Error"/>
|
||||
/// carries the operator-facing message, and <see cref="Warnings"/> is empty.
|
||||
/// </summary>
|
||||
/// <param name="Ok">Whether the rename was applied.</param>
|
||||
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
|
||||
/// <param name="Warnings">Non-blocking advisories to show after a successful rename; empty when there are none.</param>
|
||||
public readonly record struct RawRenameResult(bool Ok, string? Error, IReadOnlyList<string> Warnings)
|
||||
{
|
||||
/// <summary>A successful rename carrying zero or more advisory warnings.</summary>
|
||||
public static RawRenameResult Success(IReadOnlyList<string>? warnings = null) =>
|
||||
new(true, null, warnings ?? Array.Empty<string>());
|
||||
|
||||
/// <summary>A failed rename carrying the operator-facing message.</summary>
|
||||
public static RawRenameResult Failure(string error) =>
|
||||
new(false, error, Array.Empty<string>());
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only enumeration helper for the WP5 CSV export path. Reads a device's tag-groups + tags directly
|
||||
/// from the config DB and flattens them (with each tag's reconstructed <c>/</c>-separated
|
||||
/// <see cref="RawCsvExportTag.TagGroupPath"/>) into the export shape. Owned by WP5 — it does NOT extend
|
||||
/// <see cref="IRawTreeService"/> (that prelude is closed); it takes its own
|
||||
/// <see cref="IDbContextFactory{TContext}"/> so export never mutates. Also resolves a tag-group's path +
|
||||
/// owning device (the import modal's target-group prefix seam).
|
||||
/// </summary>
|
||||
/// <param name="dbFactory">The config-DB context factory.</param>
|
||||
public sealed class RawTagCsvExportReader(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// The owning device + reconstructed path for a tag-group (used by the import modal to prepend a
|
||||
/// target-group prefix to every imported row).
|
||||
/// </summary>
|
||||
/// <param name="DeviceId">The group's owning device id.</param>
|
||||
/// <param name="GroupPath">The group's <c>/</c>-separated path under the device (device-relative, no device segment).</param>
|
||||
public readonly record struct TagGroupContext(string DeviceId, string GroupPath);
|
||||
|
||||
/// <summary>Resolves the driver-type string of the device's parent driver, or null when unresolved.</summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The driver-type string, or null.</returns>
|
||||
public async Task<string?> ResolveDriverTypeAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await (from dev in db.Devices.AsNoTracking()
|
||||
join drv in db.DriverInstances.AsNoTracking() on dev.DriverInstanceId equals drv.DriverInstanceId
|
||||
where dev.DeviceId == deviceId
|
||||
select drv.DriverType).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a tag-group's owning device + device-relative path. Walks the <c>ParentTagGroupId</c> chain
|
||||
/// to the device root and joins the segment names with <c>/</c>. Returns null when the group is unknown.
|
||||
/// </summary>
|
||||
/// <param name="tagGroupId">The tag-group's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The owning device + reconstructed path, or null when the group does not exist.</returns>
|
||||
public async Task<TagGroupContext?> ResolveGroupContextAsync(string tagGroupId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var byId = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == group.DeviceId)
|
||||
.ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct);
|
||||
|
||||
var path = BuildGroupPath(tagGroupId, byId);
|
||||
return new TagGroupContext(group.DeviceId, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every tag under a device (across all nested tag-groups), each carrying its
|
||||
/// device-relative group path, in a stable order (group path, then name).
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
/// <returns>The device's tags flattened for export.</returns>
|
||||
public async Task<IReadOnlyList<RawCsvExportTag>> ReadDeviceTagsAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var groups = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == deviceId)
|
||||
.ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct);
|
||||
|
||||
var tags = await db.Tags.AsNoTracking()
|
||||
.Where(t => t.DeviceId == deviceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return tags
|
||||
.Select(t => new RawCsvExportTag(
|
||||
TagGroupPath: t.TagGroupId is null ? null : NullIfEmpty(BuildGroupPath(t.TagGroupId, groups)),
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
AccessLevel: t.AccessLevel,
|
||||
WriteIdempotent: t.WriteIdempotent,
|
||||
PollGroupId: t.PollGroupId,
|
||||
TagConfig: t.TagConfig))
|
||||
.OrderBy(x => x.TagGroupPath ?? "", RawPaths.Comparer)
|
||||
.ThenBy(x => x.Name, RawPaths.Comparer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
|
||||
|
||||
// Joins a group's segment names root→leaf into a device-relative path, following ParentTagGroupId.
|
||||
private static string BuildGroupPath(
|
||||
string? groupId,
|
||||
IReadOnlyDictionary<string, (string Name, string? ParentTagGroupId)> byId)
|
||||
{
|
||||
var segments = new Stack<string>();
|
||||
var guard = 0;
|
||||
while (groupId is not null && byId.TryGetValue(groupId, out var g))
|
||||
{
|
||||
segments.Push(g.Name);
|
||||
groupId = g.ParentTagGroupId;
|
||||
if (++guard > 1024)
|
||||
{
|
||||
break; // defensive: never loop forever on a malformed parent cycle
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(RawPaths.SeparatorString, segments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 <see cref="CsvParser"/> /
|
||||
/// <see cref="CsvWriter"/> and the per-driver <see cref="CsvColumnMap"/> to the service's
|
||||
/// <see cref="RawTagImportRow"/> commit shape: <see cref="Parse"/> turns an uploaded CSV into a
|
||||
/// per-row review model with validation verdicts; <see cref="Export"/> renders a device's tags back to
|
||||
/// the same column shape. No I/O, no DB, no Blazor — unit-testable in isolation.
|
||||
/// <para>
|
||||
/// A per-tag driver <c>TagConfig</c> JSON is assembled in a fixed precedence:
|
||||
/// <c>TagConfigJson</c> (base) ← typed driver columns (override) ← the alarm / historize / array flag
|
||||
/// columns. A typed column therefore <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c>
|
||||
/// fallback; the review model flags when that override happened so the operator sees it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RawTagCsvMapper
|
||||
{
|
||||
// Root TagConfig keys owned by the flag columns — stripped from the export residual so the
|
||||
// TagConfigJson fallback never duplicates a value already carried by a dedicated column.
|
||||
private static readonly string[] FlagKeys =
|
||||
["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"];
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Import
|
||||
|
||||
/// <summary>
|
||||
/// Parses an uploaded CSV into a review model: one <see cref="RawCsvReviewRow"/> per data row with a
|
||||
/// validation verdict, the assembled <see cref="RawTagImportRow"/> (when valid), and the typed vs.
|
||||
/// <c>TagConfigJson</c>-fallback provenance. Intra-batch duplicate names (same effective group + name)
|
||||
/// are flagged on the later row. This is a dry parse — nothing is committed.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The target device's driver-type string (selects the typed column map).</param>
|
||||
/// <param name="csvText">The uploaded CSV document.</param>
|
||||
/// <param name="groupPathPrefix">The target tag-group's path prefix (from the modal's TagGroupId), prepended
|
||||
/// to every row's <c>TagGroupPath</c>; null/blank when importing at the device root.</param>
|
||||
/// <returns>The parse result: header list, per-row review rows, and any fatal (whole-file) error.</returns>
|
||||
public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null)
|
||||
{
|
||||
IReadOnlyList<string[]> raw;
|
||||
try
|
||||
{
|
||||
raw = CsvParser.Parse(csvText);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), $"Malformed CSV: {ex.Message}");
|
||||
}
|
||||
|
||||
if (raw.Count == 0)
|
||||
{
|
||||
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), "The file is empty.");
|
||||
}
|
||||
|
||||
var headers = raw[0];
|
||||
var headerIndex = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
if (!headerIndex.TryAdd(headers[i], i))
|
||||
{
|
||||
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), $"Duplicate header column '{headers[i]}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!headerIndex.ContainsKey("Name"))
|
||||
{
|
||||
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), "Required column 'Name' is missing from the header row.");
|
||||
}
|
||||
|
||||
var map = CsvColumnMap.Resolve(driverType);
|
||||
var reviewed = new List<RawCsvReviewRow>(raw.Count - 1);
|
||||
|
||||
// Skip a wholly-blank trailing/interstitial line (the RFC parser yields [""] for an empty line).
|
||||
for (var r = 1; r < raw.Count; r++)
|
||||
{
|
||||
var cells = raw[r];
|
||||
if (cells.Length == 1 && cells[0].Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix));
|
||||
}
|
||||
|
||||
FlagDuplicates(reviewed);
|
||||
return new RawCsvParseResult(headers, reviewed, null);
|
||||
}
|
||||
|
||||
private static RawCsvReviewRow BuildRow(
|
||||
int rowNumber,
|
||||
string[] headers,
|
||||
IReadOnlyDictionary<string, int> headerIndex,
|
||||
string[] cells,
|
||||
CsvDriverTagMap? map,
|
||||
string? groupPathPrefix)
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (name, idx) in headerIndex)
|
||||
{
|
||||
row[name] = idx < cells.Length ? cells[idx] : "";
|
||||
}
|
||||
|
||||
string Cell(string name) => row.TryGetValue(name, out var v) ? v.Trim() : "";
|
||||
|
||||
var typedUsed = new List<string>();
|
||||
var overrodeFallback = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// --- Common identity/access fields ---
|
||||
var name = Cell("Name");
|
||||
if (RawPaths.ValidateSegment(name) is { } nameErr)
|
||||
{
|
||||
return Invalid(rowNumber, cells, $"Name: {nameErr}");
|
||||
}
|
||||
|
||||
var dataType = Cell("DataType");
|
||||
if (dataType.Length == 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "DataType is required.");
|
||||
}
|
||||
|
||||
var accessRaw = Cell("AccessLevel");
|
||||
var access = accessRaw.Length == 0
|
||||
? TagAccessLevel.Read
|
||||
: CsvValues.ParseEnum<TagAccessLevel>("AccessLevel", accessRaw);
|
||||
|
||||
var writeIdemRaw = Cell("WriteIdempotent");
|
||||
var writeIdempotent = writeIdemRaw.Length != 0 && CsvValues.ParseBool("WriteIdempotent", writeIdemRaw);
|
||||
|
||||
var pollGroup = Cell("PollGroup");
|
||||
var pollGroupId = pollGroup.Length == 0 ? null : pollGroup;
|
||||
|
||||
var groupPath = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath"));
|
||||
|
||||
// --- Assemble the TagConfig JSON: base ← typed ← flags ---
|
||||
var baseJson = Cell(CsvColumnMap.TagConfigJsonColumn);
|
||||
var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid);
|
||||
if (!baseValid)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON.");
|
||||
}
|
||||
|
||||
if (map is not null)
|
||||
{
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv))
|
||||
{
|
||||
typedUsed.Add(col.Header);
|
||||
if (baseKeys.Contains(col.JsonKey))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
// Array flags.
|
||||
var isArray = ParseFlag("IsArray", Cell("IsArray"));
|
||||
uint? arrayLength = null;
|
||||
var arrayLenRaw = Cell("ArrayLength");
|
||||
if (arrayLenRaw.Length != 0)
|
||||
{
|
||||
var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw);
|
||||
if (n < 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "ArrayLength must not be negative.");
|
||||
}
|
||||
|
||||
arrayLength = (uint)n;
|
||||
}
|
||||
|
||||
if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr)
|
||||
{
|
||||
return Invalid(rowNumber, cells, arrErr);
|
||||
}
|
||||
|
||||
json = TagArrayConfig.Set(json, isArray, arrayLength);
|
||||
|
||||
// Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
|
||||
json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva"));
|
||||
|
||||
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
|
||||
|
||||
return new RawCsvReviewRow
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Cells = cells,
|
||||
Headers = headers,
|
||||
Error = null,
|
||||
ImportRow = importRow,
|
||||
TypedColumnsUsed = typedUsed,
|
||||
FallbackKeys = baseKeys,
|
||||
TypedOverrodeFallback = overrodeFallback,
|
||||
EffectiveGroupPath = groupPath ?? "",
|
||||
Name = name,
|
||||
};
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Invalid(rowNumber, cells, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new()
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Cells = cells,
|
||||
Headers = Array.Empty<string>(),
|
||||
Error = error,
|
||||
TypedColumnsUsed = Array.Empty<string>(),
|
||||
FallbackKeys = Array.Empty<string>(),
|
||||
TypedOverrodeFallback = Array.Empty<string>(),
|
||||
};
|
||||
|
||||
// Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row.
|
||||
private static void FlagDuplicates(List<RawCsvReviewRow> rows)
|
||||
{
|
||||
var seen = new HashSet<(string, string)>();
|
||||
foreach (var row in rows.Where(r => r.Ok))
|
||||
{
|
||||
if (!seen.Add((row.EffectiveGroupPath, row.Name)))
|
||||
{
|
||||
var where = row.EffectiveGroupPath.Length == 0 ? "the device root" : $"group '{row.EffectiveGroupPath}'";
|
||||
row.Error = $"Duplicate tag name '{row.Name}' under {where} (already appears earlier in this file).";
|
||||
row.ImportRow = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? CombineGroupPath(string? prefix, string cell)
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(cell) ? null : cell.Trim();
|
||||
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
|
||||
if (prefix is null)
|
||||
{
|
||||
return suffix;
|
||||
}
|
||||
|
||||
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
|
||||
}
|
||||
|
||||
private static bool ParseFlag(string header, string cell)
|
||||
=> cell.Length != 0 && CsvValues.ParseBool(header, cell);
|
||||
|
||||
private static string ApplyAlarm(string json, string alarmType, string severityRaw, string histRaw)
|
||||
{
|
||||
if (alarmType.Length == 0)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
var model = NativeAlarmModel.FromJson(json);
|
||||
model.IsAlarm = true;
|
||||
model.AlarmType = alarmType;
|
||||
model.Severity = severityRaw.Length == 0 ? model.Severity : CsvValues.ParseInt("Alarm.Severity", severityRaw);
|
||||
model.HistorizeToAveva = histRaw.Length == 0 ? null : CsvValues.ParseBool("Alarm.HistorizeToAveva", histRaw);
|
||||
return model.ToJson();
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ReadRootKeys(string json, out bool valid)
|
||||
{
|
||||
JsonNode? node;
|
||||
try
|
||||
{
|
||||
node = JsonNode.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
valid = false;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
valid = false;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
valid = true;
|
||||
return obj.Select(kvp => kvp.Key).ToArray();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Export
|
||||
|
||||
/// <summary>
|
||||
/// Renders a device's tags to a CSV string in the same column shape <see cref="Parse"/> consumes:
|
||||
/// the common columns, the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback
|
||||
/// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is
|
||||
/// exact and non-redundant.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The device's driver-type string (selects the typed column set).</param>
|
||||
/// <param name="tags">The tags to export.</param>
|
||||
/// <returns>The CSV document (CRLF, quote-on-demand).</returns>
|
||||
public static string Export(string driverType, IEnumerable<RawCsvExportTag> tags)
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(driverType);
|
||||
var typedHeaders = map?.Headers ?? Array.Empty<string>();
|
||||
var header = CsvColumnMap.HeadersFor(driverType);
|
||||
|
||||
var rows = new List<string[]> { header.ToArray() };
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
rows.Add(BuildExportRow(tag, map, typedHeaders));
|
||||
}
|
||||
|
||||
return CsvWriter.WriteToString(rows);
|
||||
}
|
||||
|
||||
private static string[] BuildExportRow(RawCsvExportTag tag, CsvDriverTagMap? map, IReadOnlyList<string> typedHeaders)
|
||||
{
|
||||
var json = string.IsNullOrWhiteSpace(tag.TagConfig) ? "{}" : tag.TagConfig;
|
||||
|
||||
var hist = TagHistorizeConfig.Read(json);
|
||||
var array = TagArrayConfig.Read(json);
|
||||
var alarm = NativeAlarmModel.FromJson(json);
|
||||
var typed = map?.ReadTypedColumns(json) ?? new Dictionary<string, string>();
|
||||
|
||||
var cells = new List<string>(CsvColumnMap.CommonLeadingColumns.Count + typedHeaders.Count + 1)
|
||||
{
|
||||
tag.Name,
|
||||
tag.TagGroupPath ?? "",
|
||||
tag.DataType,
|
||||
tag.AccessLevel.ToString(),
|
||||
tag.WriteIdempotent ? "true" : "false",
|
||||
tag.PollGroupId ?? "",
|
||||
hist.IsHistorized ? "true" : "",
|
||||
hist.HistorianTagname,
|
||||
array.IsArray ? "true" : "",
|
||||
array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
|
||||
alarm.IsAlarm ? alarm.AlarmType : "",
|
||||
alarm.IsAlarm ? alarm.Severity.ToString(CultureInfo.InvariantCulture) : "",
|
||||
alarm.IsAlarm ? (CsvValues.FormatNullableBool(alarm.HistorizeToAveva) ?? "") : "",
|
||||
};
|
||||
|
||||
foreach (var h in typedHeaders)
|
||||
{
|
||||
cells.Add(typed.TryGetValue(h, out var v) ? v : "");
|
||||
}
|
||||
|
||||
cells.Add(ComputeResidual(json, map));
|
||||
return cells.ToArray();
|
||||
}
|
||||
|
||||
// The TagConfigJson fallback cell: the tag's root keys minus every key a typed or flag column already
|
||||
// surfaces. Empty object ⇒ blank cell.
|
||||
private static string ComputeResidual(string json, CsvDriverTagMap? map)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
foreach (var key in map?.JsonKeys ?? Array.Empty<string>())
|
||||
{
|
||||
o.Remove(key);
|
||||
}
|
||||
|
||||
foreach (var key in FlagKeys)
|
||||
{
|
||||
o.Remove(key);
|
||||
}
|
||||
|
||||
return o.Count == 0 ? "" : TagConfigJson.Serialize(o);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing
|
||||
/// fields. Produced by <see cref="RawTagCsvExportReader"/> and consumed by <see cref="RawTagCsvMapper.Export"/>.
|
||||
/// </summary>
|
||||
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for a device-root tag.</param>
|
||||
/// <param name="Name">The tag name.</param>
|
||||
/// <param name="DataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="AccessLevel">The tag's access-level baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes are retry-eligible.</param>
|
||||
/// <param name="PollGroupId">The optional poll-group id.</param>
|
||||
/// <param name="TagConfig">The raw per-driver TagConfig JSON blob.</param>
|
||||
public sealed record RawCsvExportTag(
|
||||
string? TagGroupPath,
|
||||
string Name,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and — when valid —
|
||||
/// the assembled <see cref="RawTagImportRow"/> plus the typed vs. <c>TagConfigJson</c>-fallback provenance
|
||||
/// the review grid renders.
|
||||
/// </summary>
|
||||
public sealed class RawCsvReviewRow
|
||||
{
|
||||
/// <summary>The 1-based data-row number in the source file (header is row 0).</summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>The raw cell values as parsed.</summary>
|
||||
public IReadOnlyList<string> Cells { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The header row (for aligning <see cref="Cells"/> in the grid).</summary>
|
||||
public IReadOnlyList<string> Headers { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The validation error, or <c>null</c> when the row is valid.</summary>
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>Whether the row is valid (commit-eligible).</summary>
|
||||
public bool Ok => Error is null;
|
||||
|
||||
/// <summary>The assembled import row when valid; <c>null</c> otherwise.</summary>
|
||||
public RawTagImportRow? ImportRow { get; set; }
|
||||
|
||||
/// <summary>The typed-column headers that supplied a value on this row.</summary>
|
||||
public IReadOnlyList<string> TypedColumnsUsed { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The root keys present in the row's <c>TagConfigJson</c> fallback.</summary>
|
||||
public IReadOnlyCollection<string> FallbackKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The typed columns that overrode a conflicting key carried in <c>TagConfigJson</c>.</summary>
|
||||
public IReadOnlyList<string> TypedOverrodeFallback { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection.</summary>
|
||||
public string EffectiveGroupPath { get; init; } = "";
|
||||
|
||||
/// <summary>The tag name (echoed for duplicate detection + grid display).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of <see cref="RawTagCsvMapper.Parse"/>: the parsed header row, the per-row review model, and
|
||||
/// a fatal (whole-file) error when the document could not be parsed at all (malformed CSV, duplicate/absent
|
||||
/// required header). When <see cref="FatalError"/> is set, <see cref="Rows"/> is empty.
|
||||
/// </summary>
|
||||
/// <param name="Headers">The parsed header row.</param>
|
||||
/// <param name="Rows">The per-row review model.</param>
|
||||
/// <param name="FatalError">A whole-file error, or null when the file parsed.</param>
|
||||
public sealed record RawCsvParseResult(
|
||||
IReadOnlyList<string> Headers,
|
||||
IReadOnlyList<RawCsvReviewRow> Rows,
|
||||
string? FatalError)
|
||||
{
|
||||
/// <summary>Whether the file parsed and every row is valid (commit is unblocked).</summary>
|
||||
public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok);
|
||||
|
||||
/// <summary>The valid rows' assembled import rows, ready for <c>ImportTagsAsync</c>.</summary>
|
||||
public IReadOnlyList<RawTagImportRow> ToImportRows()
|
||||
=> Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for a raw <c>Tag</c> create or update in the
|
||||
/// v3 Raw tree (WP4 manual entry). Identity is the RawPath — the tag's device + optional tag-group +
|
||||
/// name — so there is no <c>TagId</c> here: it is generated on create and never carried in the input.
|
||||
/// Whitespace-only <see cref="PollGroupId"/> collapses to <c>null</c>; <see cref="TagConfig"/> is
|
||||
/// validated for JSON well-formedness by the service (blank collapses to <c>{}</c>).
|
||||
/// </summary>
|
||||
/// <param name="Name">The tag name (a RawPath segment).</param>
|
||||
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
|
||||
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes to this tag are safe to retry.</param>
|
||||
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
|
||||
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
|
||||
public sealed record RawTagInput(
|
||||
string Name,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
bool WriteIdempotent,
|
||||
string? PollGroupId,
|
||||
string TagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// One row of the WP5 CSV import commit. <see cref="TagGroupPath"/> is a <c>/</c>-separated tag-group
|
||||
/// path under the target device (its segments are auto-created as nested <c>TagGroup</c>s, reusing any
|
||||
/// that already exist); <c>null</c>/blank places the tag directly under the device. <see cref="Tag"/>
|
||||
/// carries the tag's editable fields.
|
||||
/// </summary>
|
||||
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for the device root.</param>
|
||||
/// <param name="Tag">The tag's editable fields.</param>
|
||||
public sealed record RawTagImportRow(string? TagGroupPath, RawTagInput Tag);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="IRawTreeService.ImportTagsAsync"/>. The import is all-or-nothing: on any
|
||||
/// per-row validation error nothing is inserted, <see cref="Inserted"/> is <c>0</c>, and
|
||||
/// <see cref="Errors"/> carries the operator-facing per-row messages. On success <see cref="Errors"/>
|
||||
/// is empty and <see cref="Inserted"/> is the number of tags committed.
|
||||
/// </summary>
|
||||
/// <param name="Inserted">The number of tags inserted (0 when any row errored).</param>
|
||||
/// <param name="Errors">The per-row validation errors; empty on success.</param>
|
||||
public sealed record RawTagImportResult(int Inserted, IReadOnlyList<string> Errors);
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, EF-free assembly of the eager top of the Raw project tree (<c>/raw</c>) — Enterprise → Cluster —
|
||||
/// from flat cluster rows. The peer of <see cref="UnsTreeAssembly"/> for the Raw subtree. Everything below
|
||||
/// a cluster loads lazily via <see cref="IRawTreeService.LoadChildrenAsync"/>, so this helper only nests
|
||||
/// clusters under their enterprise label and stamps each cluster with its root-level child count and the
|
||||
/// lazy-load flag. Empty clusters are retained so they stay visible and authorable.
|
||||
/// </summary>
|
||||
public static class RawTreeAssembly
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the Enterprise→Cluster roots. Ordering is deterministic and ordinal at both levels. Each
|
||||
/// cluster is marked <see cref="RawNode.HasLazyChildren"/> and carries the supplied root child count
|
||||
/// (its root folders + cluster-root drivers).
|
||||
/// </summary>
|
||||
/// <param name="clusters">The flat cluster rows to nest under their enterprise label.</param>
|
||||
/// <param name="rootChildCounts">Per-cluster count of root folders + cluster-root drivers (badge).</param>
|
||||
/// <returns>The assembled enterprise root nodes, populated down to (but not through) their clusters.</returns>
|
||||
public static IReadOnlyList<RawNode> BuildRoots(
|
||||
IReadOnlyList<ClusterRow> clusters,
|
||||
IReadOnlyDictionary<string, int> rootChildCounts)
|
||||
{
|
||||
return clusters
|
||||
.GroupBy(c => c.Enterprise, StringComparer.Ordinal)
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal)
|
||||
.Select(entGroup =>
|
||||
{
|
||||
var ent = new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.Enterprise,
|
||||
Key = $"ent:{entGroup.Key}",
|
||||
DisplayName = entGroup.Key,
|
||||
ClusterId = null,
|
||||
EntityId = null,
|
||||
HasLazyChildren = false,
|
||||
};
|
||||
|
||||
var clusterNodes = entGroup
|
||||
.OrderBy(c => c.Name, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.ClusterId, StringComparer.Ordinal)
|
||||
.Select(c => new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.Cluster,
|
||||
Key = $"clu:{c.ClusterId}",
|
||||
DisplayName = string.IsNullOrEmpty(c.Site) ? c.Name : $"{c.Site} ({c.Name})",
|
||||
ClusterId = c.ClusterId,
|
||||
// A cluster's own logical id IS its ClusterId — EntityId mirrors it for uniform navigation.
|
||||
EntityId = c.ClusterId,
|
||||
HasLazyChildren = true,
|
||||
ChildCount = rootChildCounts.GetValueOrDefault(c.ClusterId),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
ent.Children.AddRange(clusterNodes);
|
||||
ent.ChildCount = ent.Children.Count;
|
||||
return ent;
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a <c>Calculation</c> tag's TagConfig JSON — the calc-driver binding
|
||||
/// (<c>scriptId</c> + trigger fields). Mirrors the <c><Driver>TagConfigModel</c> template: camelCase
|
||||
/// JSON keys, enum-name serialisation, and unknown-key preservation across a load→save. Shape per the
|
||||
/// Calculation mini-design: <c>{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }</c>.</summary>
|
||||
public sealed class CalculationTagConfigModel
|
||||
{
|
||||
/// <summary>Logical FK to the shared <c>Script</c> entity that computes this tag. Required.</summary>
|
||||
public string ScriptId { get; set; } = "";
|
||||
|
||||
/// <summary>Re-evaluate whenever a declared dependency value changes. Defaults to <c>true</c>.</summary>
|
||||
public bool ChangeTriggered { get; set; } = true;
|
||||
|
||||
/// <summary>Optional periodic re-evaluation interval in milliseconds; <c>null</c> ⇒ change-trigger only.
|
||||
/// When set it must be ≥ 50 ms (enforced by <see cref="Validate"/>). An absent key stays absent across
|
||||
/// a load→save (so it never leaks a phantom timer).</summary>
|
||||
public int? TimerIntervalMs { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||
/// <returns>The populated <see cref="CalculationTagConfigModel"/>.</returns>
|
||||
public static CalculationTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
int? timer = null;
|
||||
if (o.TryGetPropertyValue("timerIntervalMs", out var tn) && tn is JsonValue tv && tv.TryGetValue<int>(out var ti))
|
||||
{
|
||||
timer = ti;
|
||||
}
|
||||
return new CalculationTagConfigModel
|
||||
{
|
||||
ScriptId = TagConfigJson.GetString(o, "scriptId") ?? "",
|
||||
ChangeTriggered = TagConfigJson.GetBool(o, "changeTriggered", true),
|
||||
TimerIntervalMs = timer,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag. A blank
|
||||
/// <see cref="ScriptId"/> and a null <see cref="TimerIntervalMs"/> remove their keys (never persist an
|
||||
/// empty/phantom value).</summary>
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "scriptId", string.IsNullOrWhiteSpace(ScriptId) ? null : ScriptId);
|
||||
TagConfigJson.Set(_bag, "changeTriggered", ChangeTriggered);
|
||||
TagConfigJson.Set(_bag, "timerIntervalMs", TimerIntervalMs);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validates the calc binding: a script is required, at least one trigger (change or timer)
|
||||
/// must be set, and a set timer must be ≥ 50 ms.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ScriptId))
|
||||
{
|
||||
return "A Calculation tag requires a script.";
|
||||
}
|
||||
if (!ChangeTriggered && TimerIntervalMs is null)
|
||||
{
|
||||
return "A Calculation tag needs at least one trigger: change-triggered and/or a timer interval.";
|
||||
}
|
||||
if (TimerIntervalMs is { } ms && ms < 50)
|
||||
{
|
||||
return "Timer interval must be at least 50 ms.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for one per-driver <b>typed</b> CSV column: its CSV header, the root TagConfig JSON key it
|
||||
/// maps to, and — for the model-backed maps — the name of the property on the driver's
|
||||
/// <c><![CDATA[<Driver>TagConfigModel]]></c> it corresponds to. <see cref="ModelProperty"/> is the anchor
|
||||
/// the CSV column-map reflection guard test asserts against so a driver-model rename can never silently
|
||||
/// drift the CSV surface. It is <c>null</c> for the model-less maps (Galaxy / Calculation), which map
|
||||
/// a header directly onto a JSON key.
|
||||
/// </summary>
|
||||
/// <param name="Header">The CSV column header (PascalCase, e.g. <c>ModbusDataType</c>).</param>
|
||||
/// <param name="JsonKey">The root TagConfig JSON key this column reads/writes (camelCase, e.g. <c>dataType</c>).</param>
|
||||
/// <param name="ModelProperty">The backing model property name, or <c>null</c> for a model-less map.</param>
|
||||
public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
|
||||
/// <c>TagConfig</c> JSON. The map is the inverse pair the WP5 CSV import/export path pivots on:
|
||||
/// <see cref="ApplyTypedColumns"/> folds a row's typed columns onto a base TagConfig (typed column wins
|
||||
/// on conflict); <see cref="ReadTypedColumns"/> projects a stored TagConfig back to its typed cells for
|
||||
/// export. Lives next to the <c><![CDATA[<Driver>TagConfigModel]]></c> types so model + map evolve
|
||||
/// together.
|
||||
/// </summary>
|
||||
public abstract class CsvDriverTagMap
|
||||
{
|
||||
/// <summary>The driver-type string this map serves (a <see cref="DriverTypeNames"/> value).</summary>
|
||||
public abstract string DriverType { get; }
|
||||
|
||||
/// <summary>The backing model type whose scalar surface this map mirrors, or <c>null</c> for a
|
||||
/// model-less (raw-JSON-key) map (Galaxy / Calculation).</summary>
|
||||
public abstract Type? ModelType { get; }
|
||||
|
||||
/// <summary>The ordered typed columns this driver adds to the common CSV column set.</summary>
|
||||
public abstract IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <summary>The typed column headers, in order.</summary>
|
||||
public IReadOnlyList<string> Headers => Columns.Select(c => c.Header).ToArray();
|
||||
|
||||
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual).</summary>
|
||||
public IReadOnlyList<string> JsonKeys => Columns.Select(c => c.JsonKey).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Folds the typed columns present (non-blank) in <paramref name="row"/> onto
|
||||
/// <paramref name="baseJson"/> and returns the merged driver TagConfig JSON. The base is loaded first
|
||||
/// (so its unrecognised keys survive) and each present typed column then overrides its key — so a
|
||||
/// typed column <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c> base.
|
||||
/// </summary>
|
||||
/// <param name="baseJson">The base TagConfig JSON (from the row's <c>TagConfigJson</c> column), or null/blank.</param>
|
||||
/// <param name="row">The CSV row as a header→value map.</param>
|
||||
/// <returns>The merged TagConfig JSON string.</returns>
|
||||
/// <exception cref="FormatException">A typed cell is not a valid value for its column (bad enum / number / bool).</exception>
|
||||
public abstract string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the typed columns out of a stored TagConfig JSON for export — the inverse of
|
||||
/// <see cref="ApplyTypedColumns"/>. A column absent from the JSON is omitted from the result.
|
||||
/// </summary>
|
||||
/// <param name="tagConfigJson">The tag's stored TagConfig JSON, or null/blank.</param>
|
||||
/// <returns>The typed columns present, keyed by CSV header.</returns>
|
||||
public abstract IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson);
|
||||
}
|
||||
|
||||
/// <summary>One typed column of a model-backed map: its metadata plus the read/write pair over the
|
||||
/// concrete <typeparamref name="TModel"/> working model.</summary>
|
||||
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
|
||||
/// <param name="Meta">The column metadata (header / JSON key / model property).</param>
|
||||
/// <param name="Get">Projects the model's value to a cell string, or <c>null</c> to omit the column on export.</param>
|
||||
/// <param name="Set">Parses a non-blank cell onto the model (throws <see cref="FormatException"/> on a bad value).</param>
|
||||
public sealed record CsvModelColumn<TModel>(
|
||||
CsvTypedColumn Meta,
|
||||
Func<TModel, string?> Get,
|
||||
Action<TModel, string> Set);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="CsvDriverTagMap"/> driven by a driver's typed working model. Import loads the base JSON
|
||||
/// through the model's <c>FromJson</c> (preserving unknown keys), applies each present typed cell via its
|
||||
/// <see cref="CsvModelColumn{TModel}.Set"/>, and serialises back through the model's <c>ToJson</c> — so
|
||||
/// the CSV path reuses the exact same preserve-unknown / typed-wins merge the TagModal editor uses.
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
|
||||
public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
{
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DriverType { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Type? ModelType => typeof(TModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var model = _fromJson(baseJson);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
col.Set(model, raw.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return _toJson(model);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
|
||||
{
|
||||
var model = _fromJson(tagConfigJson);
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (col.Get(model) is { } v)
|
||||
{
|
||||
result[col.Meta.Header] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The value shape of a raw-key (model-less) CSV column.</summary>
|
||||
public enum CsvRawKeyKind
|
||||
{
|
||||
/// <summary>A JSON string value.</summary>
|
||||
String,
|
||||
|
||||
/// <summary>A JSON boolean value (parsed case-insensitively from <c>true</c>/<c>false</c>).</summary>
|
||||
Bool,
|
||||
|
||||
/// <summary>A JSON integer value.</summary>
|
||||
Int,
|
||||
}
|
||||
|
||||
/// <summary>Metadata + value-kind for one column of a raw-key map.</summary>
|
||||
/// <param name="Meta">The column metadata (header / JSON key; model property is null).</param>
|
||||
/// <param name="Kind">The JSON value kind the header maps onto.</param>
|
||||
public sealed record CsvRawKeyColumn(CsvTypedColumn Meta, CsvRawKeyKind Kind);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="CsvDriverTagMap"/> for drivers with no typed working model (Galaxy, Calculation): each
|
||||
/// column maps a CSV header directly onto a root TagConfig JSON key, preserving every other key across
|
||||
/// the round-trip.
|
||||
/// </summary>
|
||||
public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
|
||||
{
|
||||
private readonly IReadOnlyList<CsvRawKeyColumn> _columns;
|
||||
|
||||
/// <summary>Builds a raw-key map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="columns">The header→JSON-key columns with their value kinds.</param>
|
||||
public RawKeyCsvDriverTagMap(string driverType, IReadOnlyList<CsvRawKeyColumn> columns)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_columns = columns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DriverType { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Type? ModelType => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(baseJson);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
var trimmed = raw.Trim();
|
||||
o[col.Meta.JsonKey] = col.Kind switch
|
||||
{
|
||||
CsvRawKeyKind.Bool => JsonValue.Create(CsvValues.ParseBool(col.Meta.Header, trimmed)),
|
||||
CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)),
|
||||
_ => JsonValue.Create(trimmed),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(tagConfigJson);
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v)
|
||||
{
|
||||
result[col.Meta.Header] = col.Kind switch
|
||||
{
|
||||
CsvRawKeyKind.Bool => v.TryGetValue<bool>(out var b) ? (b ? "true" : "false") : v.ToString(),
|
||||
CsvRawKeyKind.Int => v.TryGetValue<long>(out var l)
|
||||
? l.ToString(CultureInfo.InvariantCulture)
|
||||
: v.ToString(),
|
||||
_ => v.TryGetValue<string>(out var s) ? s : v.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive value parsers/formatters for typed CSV cells, throwing a uniform
|
||||
/// <see cref="FormatException"/> (naming the column + the offending value) the review grid surfaces.</summary>
|
||||
public static class CsvValues
|
||||
{
|
||||
/// <summary>Parses an enum member name case-insensitively; throws with the legal members listed.</summary>
|
||||
/// <typeparam name="TEnum">The enum type.</typeparam>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed enum value.</returns>
|
||||
/// <exception cref="FormatException">The value is not a member of <typeparamref name="TEnum"/>.</exception>
|
||||
public static TEnum ParseEnum<TEnum>(string header, string value) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(value, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new FormatException(
|
||||
$"Column '{header}': '{value}' is not a valid {typeof(TEnum).Name} " +
|
||||
$"(expected one of: {string.Join(", ", Enum.GetNames<TEnum>())}).");
|
||||
|
||||
/// <summary>Parses an integer (invariant); throws a column-named <see cref="FormatException"/>.</summary>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed integer.</returns>
|
||||
/// <exception cref="FormatException">The value is not an integer.</exception>
|
||||
public static int ParseInt(string header, string value)
|
||||
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)
|
||||
? i
|
||||
: throw new FormatException($"Column '{header}': '{value}' is not a whole number.");
|
||||
|
||||
/// <summary>Parses a boolean case-insensitively (<c>true</c>/<c>false</c>); throws a column-named error.</summary>
|
||||
/// <param name="header">The CSV column header (for the error message).</param>
|
||||
/// <param name="value">The cell value.</param>
|
||||
/// <returns>The parsed boolean.</returns>
|
||||
/// <exception cref="FormatException">The value is not a boolean.</exception>
|
||||
public static bool ParseBool(string header, string value)
|
||||
=> bool.TryParse(value, out var b)
|
||||
? b
|
||||
: throw new FormatException($"Column '{header}': '{value}' is not a boolean (expected true or false).");
|
||||
|
||||
/// <summary>Formats a nullable boolean for export: <c>null</c> ⇒ omitted (null cell), else lowercased.</summary>
|
||||
/// <param name="value">The nullable boolean.</param>
|
||||
/// <returns>The cell string, or <c>null</c> to omit the column.</returns>
|
||||
public static string? FormatNullableBool(bool? value) => value is { } b ? (b ? "true" : "false") : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries,
|
||||
/// plus the per-driver typed <see cref="CsvDriverTagMap"/> resolved by driver type. The single authority
|
||||
/// for "which columns does a device of driver X have".
|
||||
/// </summary>
|
||||
public static class CsvColumnMap
|
||||
{
|
||||
/// <summary>The <c>TagConfigJson</c> fallback column header — the base under which typed columns merge.</summary>
|
||||
public const string TagConfigJsonColumn = "TagConfigJson";
|
||||
|
||||
/// <summary>The common column headers that precede any typed driver columns (in order).</summary>
|
||||
public static IReadOnlyList<string> CommonLeadingColumns { get; } = new[]
|
||||
{
|
||||
"Name",
|
||||
"TagGroupPath",
|
||||
"DataType",
|
||||
"AccessLevel",
|
||||
"WriteIdempotent",
|
||||
"PollGroup",
|
||||
"IsHistorized",
|
||||
"HistorianTagname",
|
||||
"IsArray",
|
||||
"ArrayLength",
|
||||
"Alarm.AlarmType",
|
||||
"Alarm.Severity",
|
||||
"Alarm.HistorizeToAveva",
|
||||
};
|
||||
|
||||
/// <summary>All common columns (leading columns + the trailing <c>TagConfigJson</c> fallback).</summary>
|
||||
public static IReadOnlyList<string> CommonColumns { get; } =
|
||||
[.. CommonLeadingColumns, TagConfigJsonColumn];
|
||||
|
||||
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
|
||||
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
|
||||
public const string CalculationDriverType = "Calculation";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
|
||||
|
||||
/// <summary>All registered driver maps.</summary>
|
||||
public static IReadOnlyCollection<CsvDriverTagMap> All => (IReadOnlyCollection<CsvDriverTagMap>)Maps.Values;
|
||||
|
||||
/// <summary>Resolves the typed map for a driver type, or <c>null</c> when the driver has no typed
|
||||
/// columns (only the common set applies).</summary>
|
||||
/// <param name="driverType">The driver-type string.</param>
|
||||
/// <returns>The map, or <c>null</c>.</returns>
|
||||
public static CsvDriverTagMap? Resolve(string? driverType)
|
||||
=> driverType is not null && Maps.TryGetValue(driverType, out var m) ? m : null;
|
||||
|
||||
/// <summary>The full ordered header row for a device of the given driver type: the leading common
|
||||
/// columns, then the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback.</summary>
|
||||
/// <param name="driverType">The device's driver-type string.</param>
|
||||
/// <returns>The ordered CSV header names.</returns>
|
||||
public static IReadOnlyList<string> HeadersFor(string? driverType)
|
||||
{
|
||||
var typed = Resolve(driverType)?.Headers ?? Array.Empty<string>();
|
||||
return [.. CommonLeadingColumns, .. typed, TagConfigJsonColumn];
|
||||
}
|
||||
|
||||
private static Dictionary<string, CsvDriverTagMap> BuildMaps()
|
||||
{
|
||||
var maps = new CsvDriverTagMap[]
|
||||
{
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
DriverTypeNames.S7, S7TagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
S7Col("Address", "address", nameof(S7TagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
S7Col("S7DataType", "dataType", nameof(S7TagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<S7DataType>("S7DataType", s)),
|
||||
S7Col("StringLength", "stringLength", nameof(S7TagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
S7Col("Writable", "writable", nameof(S7TagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<AbCipTagConfigModel>(
|
||||
DriverTypeNames.AbCip, AbCipTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
AbCipCol("TagPath", "tagPath", nameof(AbCipTagConfigModel.TagPath),
|
||||
m => m.TagPath, (m, s) => m.TagPath = s),
|
||||
AbCipCol("AbCipDataType", "dataType", nameof(AbCipTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<AbCipDataType>("AbCipDataType", s)),
|
||||
AbCipCol("Writable", "writable", nameof(AbCipTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<AbLegacyTagConfigModel>(
|
||||
DriverTypeNames.AbLegacy, AbLegacyTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
AbLegacyCol("Address", "address", nameof(AbLegacyTagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
AbLegacyCol("AbLegacyDataType", "dataType", nameof(AbLegacyTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<AbLegacyDataType>("AbLegacyDataType", s)),
|
||||
AbLegacyCol("Writable", "writable", nameof(AbLegacyTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<TwinCATTagConfigModel>(
|
||||
DriverTypeNames.TwinCAT, TwinCATTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
TwinCatCol("SymbolPath", "symbolPath", nameof(TwinCATTagConfigModel.SymbolPath),
|
||||
m => m.SymbolPath, (m, s) => m.SymbolPath = s),
|
||||
TwinCatCol("TwinCATDataType", "dataType", nameof(TwinCATTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<TwinCATDataType>("TwinCATDataType", s)),
|
||||
TwinCatCol("Writable", "writable", nameof(TwinCATTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<FocasTagConfigModel>(
|
||||
DriverTypeNames.FOCAS, FocasTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
FocasCol("Address", "address", nameof(FocasTagConfigModel.Address),
|
||||
m => m.Address, (m, s) => m.Address = s),
|
||||
FocasCol("FocasDataType", "dataType", nameof(FocasTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<FocasDataType>("FocasDataType", s)),
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<OpcUaClientTagConfigModel>(
|
||||
DriverTypeNames.OpcUaClient, OpcUaClientTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
OpcUaCol("NodeId", "nodeId", nameof(OpcUaClientTagConfigModel.NodeId),
|
||||
m => m.NodeId, (m, s) => m.NodeId = s),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.Galaxy, new[]
|
||||
{
|
||||
// Galaxy's driver reference is the PascalCase FullName key (tag_name.AttributeName).
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("AttributeRef", "FullName", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(CalculationDriverType, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ScriptId", "scriptId", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
|
||||
}),
|
||||
};
|
||||
|
||||
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Per-driver column factories — one per model so the Get/Set lambdas stay strongly typed.
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<AbCipTagConfigModel> AbCipCol(
|
||||
string h, string k, string p, Func<AbCipTagConfigModel, string?> get, Action<AbCipTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<AbLegacyTagConfigModel> AbLegacyCol(
|
||||
string h, string k, string p, Func<AbLegacyTagConfigModel, string?> get, Action<AbLegacyTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<TwinCATTagConfigModel> TwinCatCol(
|
||||
string h, string k, string p, Func<TwinCATTagConfigModel, string?> get, Action<TwinCATTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<FocasTagConfigModel> FocasCol(
|
||||
string h, string k, string p, Func<FocasTagConfigModel, string?> get, Action<FocasTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<OpcUaClientTagConfigModel> OpcUaCol(
|
||||
string h, string k, string p, Func<OpcUaClientTagConfigModel, string?> get, Action<OpcUaClientTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
|
||||
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
|
||||
/// raw-JSON textarea in the TagModal.
|
||||
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component. Drivers absent
|
||||
/// from the map fall back to the generic raw-JSON textarea in the TagModal. Keys are the canonical
|
||||
/// <see cref="DriverTypeNames"/> constants
|
||||
/// (case-insensitive) so this map can never drift from the authoritative factory names.
|
||||
/// </summary>
|
||||
public static class TagConfigEditorMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Modbus"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
["S7"] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
|
||||
["AbCip"] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
|
||||
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
|
||||
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
|
||||
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
[DriverTypeNames.Modbus] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
[DriverTypeNames.S7] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
|
||||
[DriverTypeNames.AbCip] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
|
||||
[DriverTypeNames.AbLegacy] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
|
||||
[DriverTypeNames.TwinCAT] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
|
||||
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,20 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// <see cref="TagConfigEditorMap"/>). Each entry parses the tag's config JSON into the driver's typed
|
||||
/// model and runs its <c>Validate()</c>. Returns an error string to block the TagModal save, or null
|
||||
/// when the config is valid — or when no typed validator is registered (unmapped drivers use the raw
|
||||
/// textarea and are validated server-side at deploy).
|
||||
/// textarea and are validated server-side at deploy). Keys are the canonical
|
||||
/// <see cref="DriverTypeNames"/> constants (case-insensitive) so this map can never drift.
|
||||
/// </summary>
|
||||
public static class TagConfigValidator
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Func<string?, string?>> Validators =
|
||||
new Dictionary<string, Func<string?, string?>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Modbus"] = j => ModbusTagConfigModel.FromJson(j).Validate(),
|
||||
["S7"] = j => S7TagConfigModel.FromJson(j).Validate(),
|
||||
["AbCip"] = j => AbCipTagConfigModel.FromJson(j).Validate(),
|
||||
["AbLegacy"] = j => AbLegacyTagConfigModel.FromJson(j).Validate(),
|
||||
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
|
||||
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Modbus] = j => ModbusTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.S7] = j => S7TagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.AbCip] = j => AbCipTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.AbLegacy] = j => AbLegacyTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.TwinCAT] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
-6
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
@@ -20,12 +21,12 @@ public static class EquipmentTagConfigInspector
|
||||
private static readonly IReadOnlyDictionary<string, Func<string, IReadOnlyList<string>>> Inspectors =
|
||||
new Dictionary<string, Func<string, IReadOnlyList<string>>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Modbus"] = ModbusTagDefinitionFactory.Inspect,
|
||||
["S7"] = S7TagDefinitionFactory.Inspect,
|
||||
["AbCip"] = AbCipTagDefinitionFactory.Inspect,
|
||||
["AbLegacy"] = AbLegacyTagDefinitionFactory.Inspect,
|
||||
["TwinCat"] = TwinCATTagDefinitionFactory.Inspect,
|
||||
["Focas"] = FocasTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.Modbus] = ModbusTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.S7] = S7TagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.AbCip] = AbCipTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
|
||||
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
|
||||
};
|
||||
|
||||
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
|
||||
|
||||
@@ -16,6 +16,7 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
|
||||
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -44,7 +45,10 @@ public static class DriverFactoryBootstrap
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
var loggerFactory = sp.GetService<ILoggerFactory>();
|
||||
Register(registry, loggerFactory);
|
||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||
Register(registry, loggerFactory, scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -113,6 +117,7 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -123,10 +128,14 @@ public static class DriverFactoryBootstrap
|
||||
/// handles platform/role-dependent stubbing (e.g. Galaxy on macOS), so registering a
|
||||
/// factory here doesn't mean it always runs in production.
|
||||
/// </summary>
|
||||
private static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory)
|
||||
private static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -141,6 +142,13 @@ public static class DeploymentArtifact
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
// scriptId → SourceCode, from the artifact's Scripts array — the same join the VirtualTag plan
|
||||
// builder uses. A calc tag's TagConfig carries a scriptId (not the source); the calc driver is
|
||||
// host-blind and can't resolve it, so we inject the resolved `scriptSource` into the delivered
|
||||
// TagConfig here (the deploy-side counterpart of the Calculation driver). Tags with no scriptId
|
||||
// are untouched.
|
||||
var scriptSourceById = BuildScriptSourceMap(root);
|
||||
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
@@ -152,6 +160,7 @@ public static class DeploymentArtifact
|
||||
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() ?? "{}" : "{}";
|
||||
tagConfig = InjectScriptSource(tagConfig, scriptSourceById);
|
||||
// WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent).
|
||||
var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl)
|
||||
&& wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean();
|
||||
@@ -168,6 +177,47 @@ public static class DeploymentArtifact
|
||||
return byDriver;
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||
{
|
||||
var byId = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Scripts"))
|
||||
{
|
||||
var sid = ReadString(el, "ScriptId");
|
||||
if (string.IsNullOrWhiteSpace(sid)) continue;
|
||||
byId[sid!] = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <paramref name="tagConfig"/> carries a <c>scriptId</c> (a calc tag), inject the resolved
|
||||
/// <c>scriptSource</c> so the host-blind Calculation driver can compile + evaluate it. Identity for
|
||||
/// tags with no <c>scriptId</c> (every other driver's tags), an unresolvable id, or malformed JSON —
|
||||
/// so no existing tag's blob changes. The pre-existing <c>scriptId</c>/trigger fields are preserved.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON.</param>
|
||||
/// <param name="scriptSourceById">The <c>scriptId → SourceCode</c> map from the artifact.</param>
|
||||
/// <returns>The TagConfig with <c>scriptSource</c> injected, or the original when nothing to inject.</returns>
|
||||
private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary<string, string> scriptSourceById)
|
||||
{
|
||||
if (scriptSourceById.Count == 0 || string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
JsonNode? node;
|
||||
try { node = JsonNode.Parse(tagConfig); }
|
||||
catch (JsonException) { return tagConfig; }
|
||||
if (node is not JsonObject obj) return tagConfig;
|
||||
|
||||
var scriptId = obj.TryGetPropertyValue("scriptId", out var sidNode) && sidNode is JsonValue sv
|
||||
&& sv.TryGetValue<string>(out var sid) ? sid : null;
|
||||
if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source))
|
||||
return tagConfig;
|
||||
|
||||
obj["scriptSource"] = source;
|
||||
return obj.ToJsonString();
|
||||
}
|
||||
|
||||
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
|
||||
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
|
||||
@@ -98,6 +98,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Per-driver <see cref="DependencyConsumerMuxAdapter"/> children — one for each spawned driver
|
||||
/// that implements <see cref="IDependencyConsumer"/> (e.g. the Calculation pseudo-driver). Each registers
|
||||
/// the driver's declared dependency refs on the per-node <see cref="_dependencyMux"/> and forwards value
|
||||
/// changes into the driver. Spawned in <see cref="SpawnChild"/>, torn down with the driver in
|
||||
/// <see cref="StopDependencyAdapter"/>, and re-registered on every apply (refs change when tags/scripts
|
||||
/// change). Empty when no mux is wired (the dev/None path) or no dependency-consuming driver is hosted.</summary>
|
||||
private readonly Dictionary<string, IActorRef> _depConsumerAdapters = new(StringComparer.Ordinal);
|
||||
|
||||
// Monotonic counter feeding the child actor-name suffix (see ActorNameFor / SpawnChild). Single-
|
||||
// threaded actor, so a plain increment is safe; it only ever grows, guaranteeing a unique name per
|
||||
// spawn so a restart's respawn never collides with the still-terminating old child.
|
||||
@@ -551,6 +559,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -580,6 +589,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -1254,6 +1264,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
||||
}
|
||||
@@ -1372,6 +1385,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
foreach (var id in plan.ToStop) StopChild(id);
|
||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||
|
||||
// v3 (WP7): a dependency-consumer driver's declared dependency refs change when its tags/scripts are
|
||||
// edited — the adapter must re-register the NEW set on the mux. Newly-spawned adapters register in
|
||||
// PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished
|
||||
// reinitialising (DriverInstanceActor.DeltaApplied → HandleDeltaApplied). That notification is
|
||||
// sequenced AFTER ReinitializeAsync rebuilds DependencyRefs — a synchronous re-register HERE would
|
||||
// re-read the STALE ref set (ApplyDelta runs asynchronously on the child), so it is deliberately NOT
|
||||
// done inline.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1736,10 +1757,33 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
|
||||
|
||||
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
|
||||
// child that registers its dependency refs on the per-node dependency mux and forwards changes
|
||||
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
|
||||
// can't flow, matching the VirtualTag dev path).
|
||||
if (!stub && driver is IDependencyConsumer consumer && _dependencyMux is not null)
|
||||
{
|
||||
var adapter = Context.ActorOf(
|
||||
ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.Props(consumer, _dependencyMux),
|
||||
"depmux-" + actorName);
|
||||
_depConsumerAdapters[spec.DriverInstanceId] = adapter;
|
||||
_log.Info("DriverHost {Node}: wired dependency-consumer mux adapter for {Id} ({Count} ref(s))",
|
||||
_localNode, spec.DriverInstanceId, consumer.DependencyRefs.Count);
|
||||
}
|
||||
|
||||
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
|
||||
}
|
||||
|
||||
/// <summary>Stops + forgets the dependency-consumer mux adapter for a driver (if any). Called wherever a
|
||||
/// driver child is stopped/respawned so a stale adapter never keeps a dead driver's refs registered.</summary>
|
||||
private void StopDependencyAdapter(string driverInstanceId)
|
||||
{
|
||||
if (_depConsumerAdapters.Remove(driverInstanceId, out var adapter))
|
||||
Context.Stop(adapter);
|
||||
}
|
||||
|
||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||
{
|
||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||
@@ -1749,9 +1793,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
|
||||
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
|
||||
/// mux adapter so a changed dependency-ref set (calc tags/scripts edited during the redeploy) is
|
||||
/// adopted on the mux. This runs AFTER reinit — closing the ordering hazard where a synchronous
|
||||
/// re-register at ApplyDelta dispatch would re-read the driver's stale
|
||||
/// <see cref="IDependencyConsumer.DependencyRefs"/>. A Tell (no blocking); a no-op for a driver with no
|
||||
/// adapter (not an <see cref="IDependencyConsumer"/>, or no mux wired on this node).
|
||||
/// </summary>
|
||||
private void HandleDeltaApplied(DriverInstanceActor.DeltaApplied msg)
|
||||
{
|
||||
if (_depConsumerAdapters.TryGetValue(msg.DriverInstanceId, out var adapter))
|
||||
adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
|
||||
}
|
||||
|
||||
private void StopChild(string driverInstanceId)
|
||||
{
|
||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||
StopDependencyAdapter(driverInstanceId);
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(driverInstanceId);
|
||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||
@@ -1805,6 +1865,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
|
||||
|
||||
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
|
||||
StopDependencyAdapter(msg.DriverInstanceId);
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(msg.DriverInstanceId);
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public sealed record DisconnectObserved(string Reason);
|
||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
/// <summary>
|
||||
/// Sent to the parent (<see cref="DriverHostActor"/>) AFTER an <see cref="ApplyDelta"/> has fully
|
||||
/// re-initialised the driver (i.e. <see cref="IDriver.ReinitializeAsync"/> completed). The host uses
|
||||
/// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
|
||||
/// <see cref="IDependencyConsumer.DependencyRefs"/> during reinit (the Calculation driver re-binds its
|
||||
/// calc tags), so the re-register MUST happen after reinit — NOT synchronously alongside the ApplyDelta
|
||||
/// dispatch, which would re-read the STALE ref set. Emitted only on a successful apply.
|
||||
/// </summary>
|
||||
/// <param name="DriverInstanceId">The instance whose adapter should be re-registered.</param>
|
||||
public sealed record DeltaApplied(string DriverInstanceId);
|
||||
public sealed record WriteAttribute(string TagId, object Value);
|
||||
public sealed record WriteAttributeResult(bool Success, string? Reason);
|
||||
/// <summary>
|
||||
@@ -566,6 +576,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
|
||||
_currentConfigJson = msg.DriverConfigJson;
|
||||
// The driver may have rebuilt its dependency-consumer interest during reinit (the Calculation
|
||||
// driver re-binds its calc tags + dependency refs). Notify the parent so it re-registers this
|
||||
// driver's mux adapter AFTER reinit completed — a synchronous re-register at ApplyDelta-dispatch
|
||||
// time would re-read the driver's STALE DependencyRefs. Parent Tell (fire-and-forget); the
|
||||
// continuation resumes on the actor context (no ConfigureAwait) so Context.Parent is live.
|
||||
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
|
||||
replyTo.Tell(new ApplyResult(true, null, msg.Correlation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a driver that implements <see cref="IDependencyConsumer"/> onto the per-node
|
||||
/// <see cref="DependencyMuxActor"/>. Spawned as a child of <c>DriverHostActor</c> for each such
|
||||
/// driver: on start (and on every apply via <see cref="ReRegister"/>) it registers the driver's
|
||||
/// declared <see cref="IDependencyConsumer.DependencyRefs"/> with the mux, and forwards each
|
||||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> the mux fans out into
|
||||
/// <see cref="IDependencyConsumer.OnDependencyValue"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the exact seam a <c>VirtualTagActor</c> uses to receive its inputs, generalised so a
|
||||
/// host-blind driver (the <c>Calculation</c> pseudo-driver) can read other tags' live values with
|
||||
/// no cross-driver plumbing. The mux fans out value + timestamp only (quality is stripped at the
|
||||
/// mux boundary, same as for virtual tags), so the forwarded status code is <c>Good</c> (0).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DependencyConsumerMuxAdapter : ReceiveActor
|
||||
{
|
||||
/// <summary>Sent by the host after an apply so the adapter re-reads the driver's (possibly changed)
|
||||
/// dependency refs and re-registers them with the mux.</summary>
|
||||
public sealed record ReRegister;
|
||||
|
||||
private readonly IDependencyConsumer _consumer;
|
||||
private readonly IActorRef _mux;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>Creates props for a <see cref="DependencyConsumerMuxAdapter"/>.</summary>
|
||||
/// <param name="consumer">The dependency-consuming driver to feed.</param>
|
||||
/// <param name="mux">The per-node dependency mux to register interest with.</param>
|
||||
/// <returns>Props for the adapter.</returns>
|
||||
public static Props Props(IDependencyConsumer consumer, IActorRef mux) =>
|
||||
Akka.Actor.Props.Create(() => new DependencyConsumerMuxAdapter(consumer, mux));
|
||||
|
||||
/// <summary>Initializes a new <see cref="DependencyConsumerMuxAdapter"/>.</summary>
|
||||
/// <param name="consumer">The dependency-consuming driver to feed.</param>
|
||||
/// <param name="mux">The per-node dependency mux to register interest with.</param>
|
||||
public DependencyConsumerMuxAdapter(IDependencyConsumer consumer, IActorRef mux)
|
||||
{
|
||||
_consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
_mux = mux ?? throw new ArgumentNullException(nameof(mux));
|
||||
|
||||
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyValueChanged);
|
||||
Receive<ReRegister>(_ => RegisterInterest());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart() => RegisterInterest();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop() => _mux.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||
|
||||
private void RegisterInterest()
|
||||
{
|
||||
var refs = _consumer.DependencyRefs.ToList();
|
||||
// A RegisterInterest replaces the prior interest set for this subscriber (mux is idempotent on
|
||||
// re-register), so re-registering after an apply cleanly adopts an added/removed ref.
|
||||
_mux.Tell(new DependencyMuxActor.RegisterInterest(refs, Self));
|
||||
_log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count);
|
||||
}
|
||||
|
||||
private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
|
||||
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative RFC 4180 read corpus for <see cref="CsvParser"/>. Every edge the RFC pins —
|
||||
/// quoting, embedded delimiters/newlines, escaped quotes, space preservation, empty vs. absent
|
||||
/// fields, trailing-newline handling, CRLF/LF/CR terminators — plus this parser's declared
|
||||
/// <b>strict</b> malformed-input and <b>faithful</b> empty-line policies, lives here.
|
||||
/// </summary>
|
||||
public sealed class CsvParserTests
|
||||
{
|
||||
// ---- simple rows ----
|
||||
|
||||
[Fact]
|
||||
public void Parses_simple_rows()
|
||||
{
|
||||
var rows = CsvParser.Parse("a,b,c\nd,e,f");
|
||||
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0].ShouldBe(new[] { "a", "b", "c" });
|
||||
rows[1].ShouldBe(new[] { "d", "e", "f" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_field_single_row()
|
||||
{
|
||||
CsvParser.Parse("hello").ShouldBe(new[] { new[] { "hello" } });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_yields_no_rows()
|
||||
{
|
||||
CsvParser.Parse("").Count.ShouldBe(0);
|
||||
CsvParser.Parse((string?)null).Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- empty vs. absent fields ----
|
||||
|
||||
[Fact]
|
||||
public void Middle_empty_field_is_preserved()
|
||||
{
|
||||
CsvParser.Parse("a,,c").Single().ShouldBe(new[] { "a", "", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trailing_comma_yields_trailing_empty_field()
|
||||
{
|
||||
CsvParser.Parse("a,").Single().ShouldBe(new[] { "a", "" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leading_comma_yields_leading_empty_field()
|
||||
{
|
||||
CsvParser.Parse(",a").Single().ShouldBe(new[] { "", "a" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_empty_fields()
|
||||
{
|
||||
CsvParser.Parse(",,").Single().ShouldBe(new[] { "", "", "" });
|
||||
}
|
||||
|
||||
// ---- quoting ----
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_embedded_comma()
|
||||
{
|
||||
CsvParser.Parse("\"a,b\",c").Single().ShouldBe(new[] { "a,b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_embedded_lf()
|
||||
{
|
||||
CsvParser.Parse("\"line1\nline2\",b").Single().ShouldBe(new[] { "line1\nline2", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_embedded_crlf()
|
||||
{
|
||||
CsvParser.Parse("\"line1\r\nline2\",b").Single().ShouldBe(new[] { "line1\r\nline2", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escaped_quote_becomes_single_quote()
|
||||
{
|
||||
// "She said ""hi""" -> She said "hi"
|
||||
CsvParser.Parse("\"She said \"\"hi\"\"\"").Single().ShouldBe(new[] { "She said \"hi\"" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_quoted_field_is_empty_string()
|
||||
{
|
||||
CsvParser.Parse("\"\",\"\"").Single().ShouldBe(new[] { "", "" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_containing_only_escaped_quotes()
|
||||
{
|
||||
// """" -> a single literal double-quote
|
||||
CsvParser.Parse("\"\"\"\"").Single().ShouldBe(new[] { "\"" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_at_end_of_row_then_more_rows()
|
||||
{
|
||||
var rows = CsvParser.Parse("\"a,b\"\nc,d");
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0].ShouldBe(new[] { "a,b" });
|
||||
rows[1].ShouldBe(new[] { "c", "d" });
|
||||
}
|
||||
|
||||
// ---- space preservation (RFC 4180 §2.4) ----
|
||||
|
||||
[Fact]
|
||||
public void Spaces_outside_quotes_are_preserved()
|
||||
{
|
||||
CsvParser.Parse(" a , b ").Single().ShouldBe(new[] { " a ", " b " });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spaces_inside_quotes_are_preserved()
|
||||
{
|
||||
CsvParser.Parse("\" padded \"").Single().ShouldBe(new[] { " padded " });
|
||||
}
|
||||
|
||||
// ---- line endings ----
|
||||
|
||||
[Fact]
|
||||
public void Lf_line_endings()
|
||||
{
|
||||
CsvParser.Parse("a\nb\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Crlf_line_endings()
|
||||
{
|
||||
CsvParser.Parse("a\r\nb\r\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bare_cr_line_endings()
|
||||
{
|
||||
CsvParser.Parse("a\rb\rc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
// ---- trailing newline: present vs. absent ----
|
||||
|
||||
[Fact]
|
||||
public void Trailing_lf_does_not_add_phantom_row()
|
||||
{
|
||||
CsvParser.Parse("a,b\n").Count.ShouldBe(1);
|
||||
CsvParser.Parse("a,b\n").Single().ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trailing_crlf_does_not_add_phantom_row()
|
||||
{
|
||||
CsvParser.Parse("a,b\r\n").Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_trailing_newline_keeps_last_row()
|
||||
{
|
||||
var withNl = CsvParser.Parse("a\nb\n");
|
||||
var withoutNl = CsvParser.Parse("a\nb");
|
||||
withNl.Count.ShouldBe(2);
|
||||
withoutNl.Count.ShouldBe(2);
|
||||
withNl[1].ShouldBe(withoutNl[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_trailing_newline_no_phantom_row()
|
||||
{
|
||||
var rows = CsvParser.Parse("\"a\"\n");
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].ShouldBe(new[] { "a" });
|
||||
}
|
||||
|
||||
// ---- empty-line policy (faithful RFC: empty line == one empty field) ----
|
||||
|
||||
[Fact]
|
||||
public void Empty_line_is_a_single_empty_field()
|
||||
{
|
||||
// a\n\nb -> ["a"], [""], ["b"] — the blank line is NOT dropped.
|
||||
var rows = CsvParser.Parse("a\n\nb");
|
||||
rows.Count.ShouldBe(3);
|
||||
rows[0].ShouldBe(new[] { "a" });
|
||||
rows[1].ShouldBe(new[] { "" });
|
||||
rows[2].ShouldBe(new[] { "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Callers_can_filter_blank_lines()
|
||||
{
|
||||
var rows = CsvParser.Parse("a\n\nb")
|
||||
.Where(r => r.Length > 1 || r[0].Length > 0)
|
||||
.ToList();
|
||||
rows.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- streaming overload ----
|
||||
|
||||
[Fact]
|
||||
public void Streams_over_a_textreader()
|
||||
{
|
||||
using var reader = new StringReader("x,y\nz,w");
|
||||
var rows = CsvParser.Parse(reader).ToList();
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[1].ShouldBe(new[] { "z", "w" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_delimiter_semicolon()
|
||||
{
|
||||
CsvParser.Parse("a;b;c", ';').Single().ShouldBe(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_delimiter_leaves_comma_literal()
|
||||
{
|
||||
CsvParser.Parse("a,b;c", ';').Single().ShouldBe(new[] { "a,b", "c" });
|
||||
}
|
||||
|
||||
// ---- strict malformed-input policy ----
|
||||
|
||||
[Fact]
|
||||
public void Unterminated_quote_throws()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("\"unclosed"));
|
||||
ex.Message.ShouldContain("Unterminated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quote_inside_unquoted_field_throws()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("ab\"c"));
|
||||
ex.Message.ShouldContain("unquoted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Character_after_closing_quote_throws()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("\"ab\"c"));
|
||||
ex.Message.ShouldContain("after closing quote");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_message_carries_position()
|
||||
{
|
||||
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("a,b\nab\"c"));
|
||||
ex.Message.ShouldContain("line 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delimiter_may_not_be_a_quote()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => CsvParser.Parse("a,b", '"').ToString());
|
||||
}
|
||||
|
||||
// ---- ParseWithHeader ----
|
||||
|
||||
[Fact]
|
||||
public void ParseWithHeader_maps_rows_by_name()
|
||||
{
|
||||
var rows = CsvParser.ParseWithHeader("name,age\nalice,30\nbob,40");
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0]["name"].ShouldBe("alice");
|
||||
rows[0]["age"].ShouldBe("30");
|
||||
rows[1]["name"].ShouldBe("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWithHeader_empty_document_yields_no_rows()
|
||||
{
|
||||
CsvParser.ParseWithHeader("").Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWithHeader_header_only_yields_no_rows()
|
||||
{
|
||||
CsvParser.ParseWithHeader("name,age").Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWithHeader_duplicate_header_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => CsvParser.ParseWithHeader("name,name\nx,y"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
|
||||
|
||||
/// <summary>
|
||||
/// The required round-trip property: for arbitrary field content, <c>Parse(Write(rows)) == rows</c>.
|
||||
/// This is what makes the writer a true inverse of the parser and pins the quote-on-demand rules to
|
||||
/// the parser's grammar. The corpus deliberately includes every tricky character.
|
||||
/// </summary>
|
||||
public sealed class CsvRoundTripTests
|
||||
{
|
||||
public static IEnumerable<object[]> Tables()
|
||||
{
|
||||
// Each case is a jagged table of fields.
|
||||
yield return new object[] { new[] { new[] { "a", "b", "c" } } };
|
||||
yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } };
|
||||
// NOTE: a table whose FINAL row is a single empty field ([[""]], or e.g. [["a"],[""]]) is deliberately
|
||||
// excluded — that trailing lone-empty-field row serialises to nothing after the preceding row's
|
||||
// terminator, so the parser's no-phantom-trailing-row rule drops it on the way back. This is the only
|
||||
// non-round-trippable shape; a single-empty-field row anywhere but last (see the interior [""] case
|
||||
// below), ",,", and blank interior lines all round-trip exactly.
|
||||
yield return new object[] { new[] { new[] { "", "", "" } } };
|
||||
yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } };
|
||||
yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter
|
||||
yield return new object[] { new[] { new[] { "he said \"hi\"" } } }; // embedded quote
|
||||
yield return new object[] { new[] { new[] { "line1\nline2" } } }; // embedded LF
|
||||
yield return new object[] { new[] { new[] { "line1\r\nline2" } } }; // embedded CRLF
|
||||
yield return new object[] { new[] { new[] { "line1\rline2" } } }; // embedded CR
|
||||
yield return new object[] { new[] { new[] { " leading", "trailing " } } }; // spaces
|
||||
yield return new object[] { new[] { new[] { " ", "\t" } } }; // whitespace-only
|
||||
yield return new object[] { new[] { new[] { "\"", "\"\"", ",", "\n" } } }; // every special alone
|
||||
yield return new object[] { new[] { new[] { "mix,\"quote\"\nand\r\nnewlines" } } };
|
||||
yield return new object[]
|
||||
{
|
||||
new[]
|
||||
{
|
||||
new[] { "id", "name", "note" },
|
||||
new[] { "1", "alice", "says \"hi, there\"" },
|
||||
new[] { "2", "bob", "multi\nline\nnote" },
|
||||
new[] { "3", "", "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Tables))]
|
||||
public void RoundTrips_with_default_crlf(string[][] rows)
|
||||
{
|
||||
var csv = CsvWriter.WriteToString(rows);
|
||||
var parsed = CsvParser.Parse(csv);
|
||||
|
||||
ShouldMatch(parsed, rows);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Tables))]
|
||||
public void RoundTrips_with_lf_newline(string[][] rows)
|
||||
{
|
||||
var csv = CsvWriter.WriteToString(rows, newline: "\n");
|
||||
var parsed = CsvParser.Parse(csv);
|
||||
|
||||
ShouldMatch(parsed, rows);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Tables))]
|
||||
public void RoundTrips_with_quote_all_fields(string[][] rows)
|
||||
{
|
||||
var csv = CsvWriter.WriteToString(rows, quoteAllFields: true);
|
||||
var parsed = CsvParser.Parse(csv);
|
||||
|
||||
ShouldMatch(parsed, rows);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Tables))]
|
||||
public void RoundTrips_with_semicolon_delimiter(string[][] rows)
|
||||
{
|
||||
var csv = CsvWriter.WriteToString(rows, delimiter: ';');
|
||||
var parsed = CsvParser.Parse(csv, ';');
|
||||
|
||||
ShouldMatch(parsed, rows);
|
||||
}
|
||||
|
||||
private static void ShouldMatch(IReadOnlyList<string[]> parsed, string[][] expected)
|
||||
{
|
||||
parsed.Count.ShouldBe(expected.Length);
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
parsed[i].ShouldBe(expected[i], $"row {i} mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user