From c8a38bc57bdf531b226dda39ae1d8e7e2333d551 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 22:23:22 -0400 Subject: [PATCH] =?UTF-8?q?DiffViewer=20refactor=20=E2=80=94=206-section?= =?UTF-8?q?=20plugin=20pattern=20+=201000-row=20cap.=20Closes=20task=20#15?= =?UTF-8?q?6=20(Phase=206.4=20Stream=20C).=20Replaces=20the=20flat=20singl?= =?UTF-8?q?e-table=20rendering=20that=20mixed=20Namespace/DriverInstance/E?= =?UTF-8?q?quipment/Tag=20rows=20into=20one=20untyped=20list=20with=20a=20?= =?UTF-8?q?per-section-card=20layout=20that=20makes=20draft=20review=20act?= =?UTF-8?q?ually=20scannable=20on=20non-trivial=20diffs.=20New=20DiffSecti?= =?UTF-8?q?on.razor=20reusable=20component=20encapsulates=20the=20per-sect?= =?UTF-8?q?ion=20rendering=20=E2=80=94=20card=20header=20shows=20Title=20+?= =?UTF-8?q?=20Description=20+=20a=20three-badge=20summary=20(+added=20/=20?= =?UTF-8?q?=E2=88=92removed=20/=20~modified=20plus=20a=20"no=20changes"=20?= =?UTF-8?q?grey=20badge=20when=20the=20section=20is=20empty)=20so=20operat?= =?UTF-8?q?ors=20can=20glance=20at=20a=20six-card=20page=20and=20see=20wha?= =?UTF-8?q?t=20areas=20of=20the=20draft=20actually=20shifted=20before=20dr?= =?UTF-8?q?illing=20into=20any=20one=20table.=20Hard=20row-cap=20at=20Defa?= =?UTF-8?q?ultRowCap=3D1000=20per=20section=20lives=20inside=20the=20compo?= =?UTF-8?q?nent=20so=20a=20pathological=20draft=20(e.g.=2020k=20tags=20chu?= =?UTF-8?q?rned=20by=20a=20block=20rebuild)=20can't=20freeze=20the=20brows?= =?UTF-8?q?er=20on=20render=20=E2=80=94=20excess=20rows=20are=20silently?= =?UTF-8?q?=20dropped=20with=20a=20yellow=20warning=20banner=20that=20surf?= =?UTF-8?q?aces=20"Showing=20the=20first=201000=20of=20N=20rows"=20+=20a?= =?UTF-8?q?=20pointer=20to=20run=20sp=5FComputeGenerationDiff=20directly?= =?UTF-8?q?=20for=20the=20full=20set.=20Body=20max-height:=20400px=20+=20o?= =?UTF-8?q?verflow-y:=20auto=20gives=20each=20section=20its=20own=20scroll?= =?UTF-8?q?=20region=20so=20one=20big=20section=20doesn't=20push=20the=20o?= =?UTF-8?q?thers=20off=20screen.=20DiffViewer.razor=20refactored=20to=20a?= =?UTF-8?q?=20static=20Sections=20table=20driving=20a=20single=20foreach?= =?UTF-8?q?=20that=20instantiates=20one=20DiffSection=20per=20known=20Tabl?= =?UTF-8?q?eName.=20Sections=20listed=20in=20author-order=20(Namespace=20?= =?UTF-8?q?=E2=86=92=20DriverInstance=20=E2=86=92=20Equipment=20=E2=86=92?= =?UTF-8?q?=20Tag=20=E2=86=92=20UnsLine=20=E2=86=92=20NodeAcl)=20=E2=80=94?= =?UTF-8?q?=20six=20entries=20matching=20the=20task=20acceptance=20criteri?= =?UTF-8?q?on.=20The=20first=20four=20correspond=20to=20what=20sp=5FComput?= =?UTF-8?q?eGenerationDiff=20currently=20emits;=20the=20last=20two=20(UnsL?= =?UTF-8?q?ine=20+=20NodeAcl)=20render=20as=20empty=20"no=20changes"=20car?= =?UTF-8?q?ds=20today=20+=20will=20light=20up=20when=20the=20proc=20is=20e?= =?UTF-8?q?xtended=20(tracked=20in=20task=20#196=20for=20NodeAcl;=20UnsLin?= =?UTF-8?q?e=20proc=20extension=20is=20a=20natural=20follow-up=20since=20U?= =?UTF-8?q?nsImpactAnalyzer=20already=20tracks=20UNS=20moves).=20RowsFor(t?= =?UTF-8?q?ableName)=20replaces=20the=20prior=20flat=20table=20=E2=80=94?= =?UTF-8?q?=20each=20section=20filters=20the=20overall=20DiffRow=20list=20?= =?UTF-8?q?by=20its=20TableName=20so=20the=20proc=20output=20format=20stay?= =?UTF-8?q?s=20stable.=20Header-bar=20summary=20at=20the=20top=20of=20the?= =?UTF-8?q?=20page=20now=20reads=20"N=20rows=20across=20M=20of=206=20secti?= =?UTF-8?q?ons"=20so=20operators=20see=20overall=20change=20weight=20at=20?= =?UTF-8?q?a=20glance=20before=20scanning.=20Two=20Razor-specific=20fixes?= =?UTF-8?q?=20landed=20along=20the=20way:=20loop=20variable=20renamed=20fr?= =?UTF-8?q?om=20`section`=20to=20`sec`=20because=20`@section`=20collides?= =?UTF-8?q?=20with=20the=20Razor=20section=20directive=20+=20trips=20RZ200?= =?UTF-8?q?5;=20helper=20method=20renamed=20from=20Group=20to=20RowsFor=20?= =?UTF-8?q?because=20the=20Razor=20generator=20gets=20confused=20by=20a=20?= =?UTF-8?q?parameter-flowing=20method=20whose=20name=20clashes=20with=20LI?= =?UTF-8?q?NQ's=20Group=20extension=20(the=20source-gen=20output=20referen?= =?UTF-8?q?ced=20TypeCheck=20with=20no=20argument).=20Admin=20project?= =?UTF-8?q?=20builds=200=20errors;=20Admin.Tests=20suite=2076/76=20(unchan?= =?UTF-8?q?ged=20=E2=80=94=20the=20refactor=20is=20structural=20+=20no=20s?= =?UTF-8?q?ervice-layer=20logic=20changed,=20so=20the=20existing=20DraftVa?= =?UTF-8?q?lidator=20+=20EquipmentService=20+=20AdminServicesIntegrationTe?= =?UTF-8?q?sts=20cover=20the=20consuming=20paths).=20No=20bUnit=20in=20thi?= =?UTF-8?q?s=20project=20so=20the=20cap=20behavior=20isn't=20unit-tested?= =?UTF-8?q?=20at=20the=20component=20level;=20DiffSection.OnParametersSet?= =?UTF-8?q?=20is=20small=20+=20deterministic=20(int=20counts=20+=20Take(Ro?= =?UTF-8?q?wCap))=20+=20reviewed=20before=20ship.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Pages/Clusters/DiffSection.razor | 90 +++++++++++++++++++ .../Pages/Clusters/DiffViewer.razor | 56 +++++++----- 2 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor new file mode 100644 index 0000000..bb0207d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor @@ -0,0 +1,90 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services + +@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps + output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the + Blazor render; overflow banner tells operator how many rows were hidden. *@ + +
+
+
+ @Title + @Description +
+
+ @if (_added > 0) { +@_added } + @if (_removed > 0) { −@_removed } + @if (_modified > 0) { ~@_modified } + @if (_total == 0) { no changes } +
+
+ @if (_total == 0) + { +
No changes in this section.
+ } + else + { + @if (_total > RowCap) + { +
+ Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class + diffs. Inspect the remainder via the SQL sp_ComputeGenerationDiff directly. +
+ } +
+ + + + + + @foreach (var r in _visibleRows) + { + + + + + } + +
LogicalIdChange
@r.LogicalId + @switch (r.ChangeKind) + { + case "Added": @r.ChangeKind break; + case "Removed": @r.ChangeKind break; + case "Modified": @r.ChangeKind break; + default: @r.ChangeKind break; + } +
+
+ } +
+ +@code { + /// Default row-cap per section — matches task #156's acceptance criterion. + public const int DefaultRowCap = 1000; + + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string Description { get; set; } = string.Empty; + [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; + [Parameter] public int RowCap { get; set; } = DefaultRowCap; + + private int _total; + private int _added; + private int _removed; + private int _modified; + private List _visibleRows = []; + + protected override void OnParametersSet() + { + _total = Rows.Count; + _added = 0; _removed = 0; _modified = 0; + foreach (var r in Rows) + { + switch (r.ChangeKind) + { + case "Added": _added++; break; + case "Removed": _removed++; break; + case "Modified": _modified++; break; + } + } + _visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor index a9633c0..71b891d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor @@ -28,36 +28,44 @@ else if (_rows.Count == 0) } else { - - - - @foreach (var r in _rows) - { - - - - - - } - -
TableLogicalIdChangeKind
@r.TableName@r.LogicalId - @switch (r.ChangeKind) - { - case "Added": @r.ChangeKind break; - case "Removed": @r.ChangeKind break; - case "Modified": @r.ChangeKind break; - default: @r.ChangeKind break; - } -
+

+ @_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections. + Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts. +

+ + @foreach (var sec in Sections) + { + + } } @code { [Parameter] public string ClusterId { get; set; } = string.Empty; [Parameter] public long GenerationId { get; set; } + /// + /// Ordered section definitions — each maps a TableName emitted by + /// sp_ComputeGenerationDiff to a human label + description. The proc currently + /// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as + /// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156 + /// follow-up). Six sections total matches the task #156 target. + /// + private static readonly IReadOnlyList Sections = new[] + { + new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"), + new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"), + new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"), + new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"), + new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"), + new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"), + }; + private List? _rows; private string _fromLabel = "(empty)"; private string? _error; + private int _sectionsWithChanges; protected override async Task OnParametersSetAsync() { @@ -67,7 +75,13 @@ else var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published); _fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}"; _rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None); + _sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName)); } catch (Exception ex) { _error = ex.Message; } } + + private IReadOnlyList RowsFor(string tableName) => + _rows?.Where(r => r.TableName == tableName).ToList() ?? []; + + private sealed record SectionDef(string TableName, string Title, string Description); }