diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor
index 6c29a82..d1e0612 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor
@@ -2,6 +2,13 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject UnsService UnsSvc
+
@@ -14,11 +21,20 @@
else
{
- | AreaId | Name |
+ | AreaId | Name | (drop target) |
@foreach (var a in _areas)
{
- @a.UnsAreaId | @a.Name |
+ OnAreaDragOver(e, a.UnsAreaId)"
+ @ondragover:preventDefault
+ @ondragleave="() => _hoverAreaId = null"
+ @ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
+ @ondrop:preventDefault>
+ @a.UnsAreaId |
+ @a.Name |
+ drop here |
+
}
@@ -35,6 +51,7 @@
}
+
UNS Lines
@@ -50,7 +67,14 @@
@foreach (var l in _lines)
{
- @l.UnsLineId | @l.UnsAreaId | @l.Name |
+ _dragLineId = l.UnsLineId"
+ @ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
+ style="cursor: grab;">
+ @l.UnsLineId |
+ @l.UnsAreaId |
+ @l.Name |
+
}
@@ -75,6 +99,64 @@
+@* Preview / confirm modal for a pending drag-drop move *@
+@if (_pendingPreview is not null)
+{
+
+
+
+
+
+
@_pendingPreview.HumanReadableSummary
+
+ Equipment re-homed: @_pendingPreview.AffectedEquipmentCount.
+ Tags re-parented: @_pendingPreview.AffectedTagCount.
+
+ @if (_pendingPreview.CascadeWarnings.Count > 0)
+ {
+
+
+ @foreach (var w in _pendingPreview.CascadeWarnings) { - @w
}
+
+
+ }
+
+
+
+
+
+}
+
+@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
+@if (_conflictMessage is not null)
+{
+
+
+
+
+
+
@_conflictMessage
+
+ Concurrency guard per DraftRevisionToken prevented overwriting the peer
+ operator's edit. Reload the tab + redo the move on the current draft state.
+
+
+
+
+
+
+}
+
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
@@ -87,6 +169,13 @@
private string _newLineName = string.Empty;
private string _newLineAreaId = string.Empty;
+ private string? _dragLineId;
+ private string? _hoverAreaId;
+ private UnsImpactPreview? _pendingPreview;
+ private UnsMoveOperation? _pendingMove;
+ private bool _committing;
+ private string? _conflictMessage;
+
protected override async Task OnParametersSetAsync() => await ReloadAsync();
private async Task ReloadAsync()
@@ -112,4 +201,72 @@
_showLineForm = false;
await ReloadAsync();
}
+
+ private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
+
+ private async Task OnLineDroppedAsync(string targetAreaId)
+ {
+ var lineId = _dragLineId;
+ _hoverAreaId = null;
+ _dragLineId = null;
+ if (string.IsNullOrWhiteSpace(lineId)) return;
+
+ var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
+ if (line is null || line.UnsAreaId == targetAreaId) return;
+
+ var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.LineMove,
+ SourceClusterId: ClusterId,
+ TargetClusterId: ClusterId,
+ SourceLineId: lineId,
+ TargetAreaId: targetAreaId);
+ try
+ {
+ _pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
+ _pendingMove = move;
+ }
+ catch (Exception ex)
+ {
+ _conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
+ }
+ }
+
+ private void CancelMove()
+ {
+ _pendingPreview = null;
+ _pendingMove = null;
+ }
+
+ private async Task ConfirmMoveAsync()
+ {
+ if (_pendingPreview is null || _pendingMove is null) return;
+ _committing = true;
+ try
+ {
+ await UnsSvc.MoveLineAsync(
+ GenerationId,
+ _pendingPreview.RevisionToken,
+ _pendingMove.SourceLineId!,
+ _pendingMove.TargetAreaId!,
+ CancellationToken.None);
+
+ _pendingPreview = null;
+ _pendingMove = null;
+ await ReloadAsync();
+ }
+ catch (DraftRevisionConflictException ex)
+ {
+ _pendingPreview = null;
+ _pendingMove = null;
+ _conflictMessage = ex.Message;
+ }
+ finally { _committing = false; }
+ }
+
+ private async Task ReloadAfterConflict()
+ {
+ _conflictMessage = null;
+ await ReloadAsync();
+ }
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
index c66ff17..c71a14c 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
await db.SaveChangesAsync(ct);
return line;
}
+
+ ///
+ /// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
+ /// and counts equipment + tags per line. Returns the snapshot plus a deterministic
+ /// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
+ /// stable across processes + changes whenever any row is added / modified / deleted.
+ ///
+ public async Task
LoadSnapshotAsync(long generationId, CancellationToken ct)
+ {
+ var areas = await db.UnsAreas.AsNoTracking()
+ .Where(a => a.GenerationId == generationId)
+ .OrderBy(a => a.UnsAreaId)
+ .ToListAsync(ct);
+
+ var lines = await db.UnsLines.AsNoTracking()
+ .Where(l => l.GenerationId == generationId)
+ .OrderBy(l => l.UnsLineId)
+ .ToListAsync(ct);
+
+ var equipmentCounts = await db.Equipment.AsNoTracking()
+ .Where(e => e.GenerationId == generationId)
+ .GroupBy(e => e.UnsLineId)
+ .Select(g => new { LineId = g.Key, Count = g.Count() })
+ .ToListAsync(ct);
+ var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
+
+ var lineSummaries = lines.Select(l =>
+ new UnsLineSummary(
+ LineId: l.UnsLineId,
+ Name: l.Name,
+ EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
+ TagCount: 0)).ToList();
+
+ var areaSummaries = areas.Select(a =>
+ new UnsAreaSummary(
+ AreaId: a.UnsAreaId,
+ Name: a.Name,
+ LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
+ .Select(l => l.UnsLineId).ToList())).ToList();
+
+ return new UnsTreeSnapshot
+ {
+ DraftGenerationId = generationId,
+ RevisionToken = ComputeRevisionToken(areas, lines),
+ Areas = areaSummaries,
+ Lines = lineSummaries,
+ };
+ }
+
+ ///
+ /// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
+ /// the revision token it observed at preview time — a mismatch raises
+ /// so the UI can show the 409 concurrent-edit
+ /// modal instead of silently overwriting a peer's work.
+ ///
+ public async Task MoveLineAsync(
+ long generationId,
+ DraftRevisionToken expected,
+ string lineId,
+ string targetAreaId,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(expected);
+ ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
+
+ var supportsTx = db.Database.IsRelational();
+ Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
+ if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ var areas = await db.UnsAreas
+ .Where(a => a.GenerationId == generationId)
+ .OrderBy(a => a.UnsAreaId)
+ .ToListAsync(ct);
+
+ var lines = await db.UnsLines
+ .Where(l => l.GenerationId == generationId)
+ .OrderBy(l => l.UnsLineId)
+ .ToListAsync(ct);
+
+ var current = ComputeRevisionToken(areas, lines);
+ if (!current.Matches(expected))
+ throw new DraftRevisionConflictException(
+ $"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
+ "Refresh + redo the move.");
+
+ var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
+
+ if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
+ throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
+
+ if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
+ return; // no-op drop — same area
+
+ line.UnsAreaId = targetAreaId;
+ await db.SaveChangesAsync(ct);
+ if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
+ throw;
+ }
+ finally
+ {
+ if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList areas, IReadOnlyList lines)
+ {
+ var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
+ foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
+ sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
+ foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
+ sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
+
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
+ return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
+ }
}
+
+/// Thrown when a UNS move's expected revision token no longer matches the live draft
+/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
+/// "refresh required" modal in the Admin UI.
+public sealed class DraftRevisionConflictException(string message) : Exception(message);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
new file mode 100644
index 0000000..45415f5
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
@@ -0,0 +1,130 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Services;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class UnsServiceMoveTests
+{
+ [Fact]
+ public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
+ {
+ using var ctx = NewContext();
+ Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
+ lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
+ equipmentLines: new[] { "line-a", "line-a", "line-b" });
+ var svc = new UnsService(ctx);
+
+ var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+
+ snap.Areas.Count.ShouldBe(2);
+ snap.Lines.Count.ShouldBe(3);
+ snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
+ snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
+ snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
+ {
+ using var ctx = NewContext();
+ Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
+ var svc = new UnsService(ctx);
+
+ var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+ var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+
+ second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
+ {
+ using var ctx = NewContext();
+ Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
+ var svc = new UnsService(ctx);
+
+ var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+ await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
+ var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+
+ after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
+ {
+ using var ctx = NewContext();
+ Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
+ lines: new[] { ("line-a", "area-1") });
+ var svc = new UnsService(ctx);
+
+ var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
+ await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
+
+ var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
+ moved.UnsAreaId.ShouldBe("area-2");
+ }
+
+ [Fact]
+ public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
+ {
+ using var ctx = NewContext();
+ Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
+ lines: new[] { ("line-a", "area-1") });
+ var svc = new UnsService(ctx);
+
+ // Simulate a peer operator's concurrent edit between our preview + commit.
+ var stale = new DraftRevisionToken("0000000000000000");
+
+ await Should.ThrowAsync(() =>
+ svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
+
+ var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
+ row.UnsAreaId.ShouldBe("area-1");
+ }
+
+ private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
+ IEnumerable areas,
+ IEnumerable<(string line, string area)> lines,
+ IEnumerable? equipmentLines = null)
+ {
+ foreach (var a in areas)
+ {
+ ctx.UnsAreas.Add(new UnsArea
+ {
+ GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
+ });
+ }
+ foreach (var (line, area) in lines)
+ {
+ ctx.UnsLines.Add(new UnsLine
+ {
+ GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
+ });
+ }
+ foreach (var lineId in equipmentLines ?? [])
+ {
+ ctx.Equipment.Add(new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
+ EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
+ EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
+ UnsLineId = lineId, Name = "x", MachineCode = "m",
+ });
+ }
+ ctx.SaveChanges();
+ }
+
+ private static OtOpcUaConfigDbContext NewContext()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ return new OtOpcUaConfigDbContext(opts);
+ }
+}