feat(adminui): editable S7 tag list via CollectionEditor

This commit is contained in:
Joseph Doherty
2026-05-29 09:37:12 -04:00
parent a5a0d06dbe
commit 244949caa3
2 changed files with 194 additions and 51 deletions
@@ -145,23 +145,38 @@ else
</div>
</section>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Tag list editor coming in a follow-up phase. To add/remove tags, edit the JSON directly in the raw driver config via the generic editor, or deploy via the import tooling.
@* Tags *@
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".11s"
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
Validate="S7TagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<S7DataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String length</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
<div class="form-text">Only for String type. Max 254.</div></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
@if (_form.TagsJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -196,6 +211,9 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<S7TagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -226,6 +244,7 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags.Select(S7TagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -236,7 +255,7 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var opts = _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -307,7 +326,8 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
@@ -315,6 +335,53 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — S7TagDefinition is an immutable record.
public sealed class S7TagRow
{
public string Name { get; set; } = "";
public string Address { get; set; } = "";
public S7DataType DataType { get; set; } = S7DataType.Int16;
public bool Writable { get; set; } = true;
public int StringLength { get; set; } = 254;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private S7TagDefinition? _source;
public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static S7TagRow FromDefinition(S7TagDefinition d) => new()
{
Name = d.Name, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, StringLength = d.StringLength,
_source = d,
};
public S7TagDefinition ToDefinition()
{
var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
StringLength = StringLength,
};
}
public static string? ValidateRow(S7TagRow row, IReadOnlyList<S7TagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collection (Tags) is kept on the component (_tags) and passed in on ToOptions().
public sealed class FormModel
{
// Connection
@@ -331,43 +398,25 @@ else
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Tags JSON view (read-only)
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged from original options)
private IReadOnlyList<S7TagDefinition> _tags = [];
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(S7DriverOptions o)
public static FormModel FromOptions(S7DriverOptions o) => new()
{
string? tagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags,
new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true,
});
return new FormModel
{
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,
TagsJson = tagsJson,
_tags = o.Tags,
};
}
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()
public S7DriverOptions ToOptions(IReadOnlyList<S7TagDefinition> tags) => new()
{
Host = Host,
Port = Port,
@@ -382,7 +431,7 @@ else
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Tags = _tags,
Tags = tags,
};
}
}