Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor
Joseph Doherty 482d5f5637 feat: restyle Admin UI with the technical-light design system
Adopt the technical-light design system across the Admin web UI:

- Vendor theme.css + IBM Plex woff2 fonts into wwwroot; include
  theme.css globally after Bootstrap.
- Rebuild MainLayout: top app-bar (brand mark, breadcrumb, connection
  pill) + hairline-ruled side rail with accent-bordered active link.
- Convert all 33 pages to the component catalog — tables to
  panel + data-table (num/mono columns), KPI cards to agg-grid,
  detail blocks to metric-card/kv rows, badges to chips, alerts to
  panel notice, headings to page-title/panel-head, .rise reveals.
- Buttons/forms stay on Bootstrap; theme.css restyles them via
  --bs-* overrides. View-specific layout lives in app.css; all
  colour/type comes from theme.css tokens.

Also fix a pre-existing /fleet 500: the node-state query ordered on
a property of a constructed FleetNodeRow record, which EF Core
cannot translate. Order the join's columns before projecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:20:09 -04:00

193 lines
8.5 KiB
Plaintext

@using System.Text.Json
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject DriverInstanceService DriverSvc
@inject NamespaceService NsSvc
<div class="d-flex justify-content-between mb-3">
<h4 class="panel-head">DriverInstances</h4>
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
</div>
@if (_drivers is null) { <p>Loading…</p> }
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
else
{
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Configured drivers</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
<tbody>
@foreach (var d in _drivers)
{
<tr>
<td><span class="mono">@d.DriverInstanceId</span></td>
<td>@d.Name</td>
<td>
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
{
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
}
else
{
@d.DriverType
}
</td>
<td><span class="mono">@d.NamespaceId</span></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (_showForm && _namespaces is not null)
{
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Add driver</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="_name"/>
</div>
<div class="col-md-3">
<label class="form-label">DriverType</label>
<select class="form-select form-select-sm" @bind="_type">
<option>Galaxy</option>
<option>Modbus</option>
<option>AbCip</option>
<option>AbLegacy</option>
<option>S7</option>
<option>Focas</option>
<option>OpcUaClient</option>
</select>
<div class="form-text">Type string must match the driver's registered factory name; this dropdown wraps the canonical names.</div>
</div>
<div class="col-md-6">
<label class="form-label">Namespace</label>
<select class="form-select form-select-sm" @bind="_nsId">
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
</select>
</div>
<div class="col-12">
@if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase))
{
@* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back
for driver types that haven't yet shipped a typed editor. *@
<label class="form-label">Modbus options (typed editor)</label>
<ModbusOptionsEditor Model="_modbusOptions"/>
}
else
{
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_config"></textarea>
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
}
</div>
</div>
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</section>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private List<DriverInstance>? _drivers;
private List<Namespace>? _namespaces;
private bool _showForm;
private string _name = string.Empty;
private string _type = "Modbus";
private string _nsId = string.Empty;
private string _config = "{}";
private string? _error;
// #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions
// defaults so an unedited form produces config equivalent to the historical
// pre-typed-editor wire output. Serialised to _config on Save when type=Modbus.
private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new();
private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true };
protected override async Task OnParametersSetAsync() => await ReloadAsync();
private async Task ReloadAsync()
{
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
_nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty;
}
private async Task SaveAsync()
{
_error = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId))
{
_error = "Name and Namespace are required";
return;
}
try
{
// #147 — for Modbus drivers serialize the typed editor model into the DriverConfig
// JSON column. Other driver types still use the raw textarea contents until each
// ships its own typed editor (decision #94 — per-driver schema validation arrives
// per driver phase).
var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)
? SerializeModbusOptions(_modbusOptions)
: _config;
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None);
_name = string.Empty; _config = "{}";
_modbusOptions = new();
_showForm = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
}
/// <summary>
/// Maps the view-model field names onto the JSON shape <c>ModbusDriverFactoryExtensions</c>
/// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the
/// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would
/// emit the .NET-native names instead.
/// </summary>
private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) =>
JsonSerializer.Serialize(new
{
host = m.Host,
port = m.Port,
unitId = m.UnitId,
family = m.Family.ToString(),
melsecSubFamily = m.MelsecSubFamily.ToString(),
keepAlive = new
{
enabled = m.KeepAliveEnabled,
timeMs = m.KeepAliveTimeSec * 1000,
intervalMs = m.KeepAliveIntervalSec * 1000,
retryCount = m.KeepAliveRetryCount,
},
reconnect = new
{
initialDelayMs = m.ReconnectInitialDelayMs,
maxDelayMs = m.ReconnectMaxDelayMs,
backoffMultiplier = m.ReconnectBackoffMultiplier,
},
maxRegistersPerRead = m.MaxRegistersPerRead,
maxRegistersPerWrite = m.MaxRegistersPerWrite,
maxCoilsPerRead = m.MaxCoilsPerRead,
maxReadGap = m.MaxReadGap,
useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites,
useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites,
writeOnChangeOnly = m.WriteOnChangeOnly,
tags = Array.Empty<object>(),
}, ModbusJsonOptions);
}