feat(adminui): isHistorized + historianTagname as first-class Tag fields

This commit is contained in:
Joseph Doherty
2026-06-16 16:44:46 -04:00
parent c98625fd9f
commit c00a547191
9 changed files with 310 additions and 136 deletions
@@ -6,17 +6,6 @@
placeholder="Reactor1.Temperature"
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">The AVEVA Historian tagname the driver reads against.</div></div>
<div class="col-md-12">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
</div>
</div>
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
</div>
@code {
@@ -6,17 +6,6 @@
placeholder="nsu=urn:server:ns;s=Line3.Temp"
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.</div></div>
<div class="col-md-12">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" checked="@_m.IsHistorized"
@onchange="@(e => Update(() => _m.IsHistorized = e.Value is true))" />
<label class="form-check-label">Historized (expose OPC UA HistoryRead)</label>
</div>
</div>
<div class="col-md-12"><label class="form-label">Historian tagname override (optional)</label>
<input type="text" class="form-control form-control-sm mono" value="@_m.HistorianTagname"
@onchange="@(e => Update(() => _m.HistorianTagname = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">Blank defaults the historian tagname to the FullName above.</div></div>
</div>
@code {
@@ -136,6 +136,39 @@
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
`isHistorized` / `historianTagname`, read by Phase7Composer.ExtractTagHistorize),
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
composes with the typed editor's driver-specific fields (both preserve unknown keys).
Shown for EVERY driver once one is picked. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">History</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-historize"
checked="@_historizeState.IsHistorized"
@onchange="OnHistorizeChanged" />
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
</div>
<div class="form-text">
When checked, the server serves OPC UA HistoryRead over this tag's value
from the configured historian.
</div>
@if (_historizeState.IsHistorized)
{
<div class="mt-2">
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
value="@_historizeState.HistorianTagname"
@onchange="OnHistorianTagnameChanged" />
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
</div>
}
</div>
}
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
@@ -211,6 +244,11 @@
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
@@ -238,6 +276,8 @@
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
@@ -255,14 +295,32 @@
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
};
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
private string? _nativeAlarmSource;
private NativeAlarmModel NativeAlarm
{
get
{
if (_nativeAlarmSource != _form.TagConfig)
{
_nativeAlarmSource = _form.TagConfig;
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
}
return _nativeAlarm;
}
}
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm;
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false;
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
@@ -271,11 +329,28 @@
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
var isChecked = e.Value is bool b && b;
model.HistorizeToAveva = isChecked ? null : false;
model.HistorizeToAveva = e.Value is true ? null : false;
_form.TagConfig = model.ToJson();
}
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
private void OnHistorizeChanged(ChangeEventArgs e)
{
var isHistorized = e.Value is true;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
private void OnHistorianTagnameChanged(ChangeEventArgs e)
{
var tagname = e.Value?.ToString() ?? string.Empty;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -301,6 +376,8 @@
_showGalaxyPicker = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
@@ -4,26 +4,20 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The
/// tag binds to a historian tag by its full reference (<c>FullName</c> — the historian tagname/source
/// the driver reads against), plus the optional driver-agnostic server-side HistoryRead intent
/// (<c>isHistorized</c> / <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
/// the driver reads against). Preserves unrecognised JSON keys across a load→save.</summary>
/// <remarks>
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
/// <c>Phase7Composer.ExtractTagHistorize</c>.
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
/// </remarks>
public sealed class HistorianWonderwareTagConfigModel
{
/// <summary>Historian tagname/source the tag binds to (the driver-side full reference). Required.</summary>
public string FullName { get; set; } = "";
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
public bool IsHistorized { get; set; }
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
public string HistorianTagname { get; set; } = "";
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -35,24 +29,17 @@ public sealed class HistorianWonderwareTagConfigModel
return new HistorianWonderwareTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
/// preserved unknown keys.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
// Drop isHistorized when false so the persisted blob stays minimal and matches the
// composer's "absent ⇒ false" convention; same for a blank historianTagname override.
TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
TagConfigJson.Set(_bag, "historianTagname",
string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
return TagConfigJson.Serialize(_bag);
}
@@ -4,27 +4,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
/// is bound to the upstream OPC UA server node by its full reference (<c>FullName</c> — the persisted
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against),
/// plus the optional driver-agnostic server-side HistoryRead intent (<c>isHistorized</c> /
/// <c>historianTagname</c>). Preserves unrecognised JSON keys across a load→save.</summary>
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
/// Preserves unrecognised JSON keys across a load→save.</summary>
/// <remarks>
/// The <c>FullName</c> key is intentionally PascalCase: the deploy-time composer + node walker
/// (<c>Phase7Composer.ExtractTagFullName</c>, <c>EquipmentNodeWalker</c>) read it via a
/// case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// casing. The history keys (<c>isHistorized</c> / <c>historianTagname</c>) are camelCase to match
/// <c>Phase7Composer.ExtractTagHistorize</c>.
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
/// </remarks>
public sealed class OpcUaClientTagConfigModel
{
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
public string FullName { get; set; } = "";
/// <summary>Whether the server exposes OPC UA HistoryRead over this tag's variable node.</summary>
public bool IsHistorized { get; set; }
/// <summary>Optional historian tagname override; blank means the historian tagname defaults to <see cref="FullName"/>.</summary>
public string HistorianTagname { get; set; } = "";
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
@@ -36,24 +30,17 @@ public sealed class OpcUaClientTagConfigModel
return new OpcUaClientTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
IsHistorized = TagConfigJson.GetBool(o, "isHistorized"),
HistorianTagname = TagConfigJson.GetString(o, "historianTagname") ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); the history keys are
/// written camelCase and dropped when default (absent <c>isHistorized</c> ⇒ false at the composer;
/// blank <c>historianTagname</c> ⇒ defaults to <c>FullName</c>).</summary>
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
/// preserved unknown keys.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
// Drop isHistorized when false so the persisted blob stays minimal and matches the
// composer's "absent ⇒ false" convention; same for a blank historianTagname override.
TagConfigJson.Set(_bag, "isHistorized", IsHistorized ? true : null);
TagConfigJson.Set(_bag, "historianTagname",
string.IsNullOrWhiteSpace(HistorianTagname) ? null : HistorianTagname.Trim());
return TagConfigJson.Serialize(_bag);
}
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default) and
/// <c>historianTagname</c> (camelCase string override — omit when blank). These map to what the server's
/// <c>Phase7Composer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>).
///
/// <para>
/// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed
/// editor reads/writes its driver-specific fields on that same JSON, and the modal's "Historize this tag"
/// checkbox + "Historian tagname (override)" textbox read/write these two keys through this helper. They
/// COMPOSE because both sides preserve every unrecognised key (delegated to
/// <see cref="TagConfigJson.ParseOrNew"/> / <see cref="TagConfigJson.Set"/>), so neither clobbers the
/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model.
/// </para>
/// </summary>
public static class TagHistorizeConfig
{
/// <summary>The two history-intent values parsed from a TagConfig JSON's root.</summary>
/// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param>
/// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param>
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname);
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
public static HistorizeState Read(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new HistorizeState(
TagConfigJson.GetBool(o, "isHistorized"),
TagConfigJson.GetString(o, "historianTagname") ?? "");
}
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
/// (driver-specific or unknown) key. <c>isHistorized</c> is dropped when false (absent ⇒ false at the
/// composer); <c>historianTagname</c> is dropped when null/blank (absent ⇒ defaults to FullName) and
/// trimmed otherwise.</summary>
public static string Set(string? json, bool isHistorized, string? historianTagname)
{
var o = TagConfigJson.ParseOrNew(json);
TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
TagConfigJson.Set(o, "historianTagname",
string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
return TagConfigJson.Serialize(o);
}
}