fix(admin): resolve Low code-review findings (Admin-010,011,012)

- Admin-010: vendor Bootstrap 5.3.3 (CSS + JS bundle + maps + provenance
  README) under wwwroot/lib/bootstrap and reference local paths from
  App.razor — Admin no longer pulls Bootstrap from jsDelivr.
- Admin-011: swap FleetStatusPoller's three plain dictionaries for
  ConcurrentDictionary so ResetCache can't race a poll tick.
- Admin-012: drop the EquipmentId column from EquipmentCsvImporter (per
  admin-ui.md — equipment id is system-derived from EquipmentUuid);
  EquipmentImportBatchService and the textarea placeholder updated to
  match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:24:07 -04:00
parent 3f01a24b45
commit 2b33b64a58
16 changed files with 355 additions and 48 deletions

View File

@@ -6,14 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>OtOpcUa Admin</title>
<base href="/"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
@* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md
"Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@
<link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="theme.css"/>
<link rel="stylesheet" href="app.css"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -79,7 +79,7 @@
<div class="mt-3">
<label class="form-label">CSV content (paste or uploaded)</label>
<textarea class="form-control mono" rows="8" @bind="_csvText"
placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentId,…"/>
placeholder="# OtOpcUaCsv v1&#10;ZTag,MachineCode,SAPID,EquipmentUuid,Name,…"/>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>

View File

@@ -15,8 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// columns must all be present. The version bump handshake lets future shapes parse
/// without ambiguity — v2 files go through a different parser variant.</para>
///
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
/// <para><b>Required columns</b> per decision #117 + admin-ui.md "Equipment CSV import"
/// (Admin-012 — revised after adversarial review finding #4): ZTag, MachineCode, SAPID,
/// EquipmentUuid, Name, UnsAreaName, UnsLineName. <b>No <c>EquipmentId</c> column</b> —
/// it is system-derived as <c>'EQ-' + first 12 hex chars of EquipmentUuid</c> via
/// <see cref="ZB.MOM.WW.OtOpcUa.Configuration.Validation.DraftValidator.DeriveEquipmentId"/>
/// and is never accepted from the CSV: operator-supplied values would mint duplicate
/// equipment identity on typos.</para>
///
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
@@ -32,7 +37,8 @@ public static class EquipmentCsvImporter
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
{
"ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
// Admin-012: no EquipmentId — derived from EquipmentUuid at finalise time.
"ZTag", "MachineCode", "SAPID", "EquipmentUuid",
"Name", "UnsAreaName", "UnsLineName",
};
@@ -136,7 +142,6 @@ public static class EquipmentCsvImporter
ZTag = cells[colIndex["ZTag"]],
MachineCode = cells[colIndex["MachineCode"]],
SAPID = cells[colIndex["SAPID"]],
EquipmentId = cells[colIndex["EquipmentId"]],
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
Name = cells[colIndex["Name"]],
UnsAreaName = cells[colIndex["UnsAreaName"]],
@@ -157,7 +162,6 @@ public static class EquipmentCsvImporter
"ZTag" => row.ZTag,
"MachineCode" => row.MachineCode,
"SAPID" => row.SAPID,
"EquipmentId" => row.EquipmentId,
"EquipmentUuid" => row.EquipmentUuid,
"Name" => row.Name,
"UnsAreaName" => row.UnsAreaName,
@@ -225,11 +229,11 @@ public static class EquipmentCsvImporter
/// <summary>One parsed equipment row with required + optional fields.</summary>
public sealed class EquipmentCsvRow
{
// Required (decision #117)
// Required (decision #117). Admin-012: no EquipmentId here — derived from
// EquipmentUuid at finalise time so a CSV typo cannot create a duplicate identity.
public required string ZTag { get; init; }
public required string MachineCode { get; init; }
public required string SAPID { get; init; }
public required string EquipmentId { get; init; }
public required string EquipmentUuid { get; init; }
public required string Name { get; init; }
public required string UnsAreaName { get; init; }

View File

@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
@@ -65,6 +66,14 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
foreach (var row in acceptedRows)
{
// Admin-012: EquipmentId is not on the CSV row — derive it from EquipmentUuid
// so the staging row carries the canonical 'EQ-' + first-12-hex form. Rows
// with an unparseable UUID get an empty placeholder; the same finalise path
// that re-derives the UUID (FinaliseBatchAsync) will overwrite it.
var derivedEquipmentId = Guid.TryParse(row.EquipmentUuid, out var parsedUuid)
? DraftValidator.DeriveEquipmentId(parsedUuid)
: string.Empty;
db.EquipmentImportRows.Add(new EquipmentImportRow
{
Id = Guid.NewGuid(),
@@ -73,7 +82,7 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
ZTag = row.ZTag,
MachineCode = row.MachineCode,
SAPID = row.SAPID,
EquipmentId = row.EquipmentId,
EquipmentId = derivedEquipmentId,
EquipmentUuid = row.EquipmentUuid,
Name = row.Name,
UnsAreaName = row.UnsAreaName,
@@ -178,11 +187,16 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
{
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
// Admin-012: EquipmentId is always derived from the UUID that actually lands in
// the Equipment row. Using the staging row's pre-derived value would diverge
// when the staged UUID was blank and we just generated a fresh one above.
var derivedEquipmentId = DraftValidator.DeriveEquipmentId(equipmentUuid);
db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(),
GenerationId = generationId,
EquipmentId = row.EquipmentId,
EquipmentId = derivedEquipmentId,
EquipmentUuid = equipmentUuid,
DriverInstanceId = driverInstanceIdForRows,
UnsLineId = unsLineIdForRows,

View File

@@ -0,0 +1,16 @@
# Bootstrap 5.3.3 — vendored copy
Per `docs/v2/admin-ui.md` "Tech Stack" the Admin UI vendors Bootstrap 5 here so
the app has no public-CDN dependency — air-gapped fleet deployments must work
without internet egress.
| File | Source |
|------|--------|
| `css/bootstrap.min.css` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css |
| `css/bootstrap.min.css.map` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css.map |
| `js/bootstrap.bundle.min.js` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js |
| `js/bootstrap.bundle.min.js.map` | https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js.map |
Bootstrap is MIT-licensed (https://github.com/twbs/bootstrap/blob/main/LICENSE).
To upgrade, re-download all four files at the matching version and bump this
table.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long