feat(templateengine+centralui): resolve follow-ups #1/#2 — inherited-member propagation & resync

Derived templates store IsInherited placeholder rows mirroring inherited
members, but a base member added/changed/removed AFTER a child was derived
never reached the child — leaving the editor's editable tabs incomplete (#1)
and stored rows drifted from the resolved set (#2).

Fix (one order-independent reconcile, two entry points):
- Auto-propagation: every attribute/alarm/script add/update/delete now
  reconciles the template's derived subtree (TemplateService.ReconcileDescendantsAsync),
  hooked into all member-mutating paths incl. native-alarm-source CRUD in the
  ManagementActor.
- Resync: ResyncInheritedMembersAsync repairs a template + its subtree on
  demand — materialize missing placeholders, re-sync drifted ones, remove
  orphans, across attributes/alarms/scripts/native sources. Exposed as
  management ResyncInheritedMembersCommand (Designer-gated, audited) → CLI
  `template resync-members` → a Resync button on the editor's staleness banner.

Reconcile drives off TemplateInheritanceResolver (same precedence + HiLo merge
as deploy), only ever touches IsInherited placeholders (never an authored
override), and matches the staleness comparison keys so the banner clears.
BuildDerivedTemplate now also materializes native-source placeholders at
compose time (previously omitted → any inherited native source was perpetually
stale).

Tests: +8 TemplateServiceTests (materialize / drift-update / orphan-remove /
override-untouched / base-cascade / multi-type / direct-propagate / end-to-end
add) + 1 ManagementService test fix (native-source add resolves TemplateService).
Affected suites green: TemplateEngine 446, ManagementService 230, CentralUI 866,
CLI 333, Transport 127, ConfigurationDatabase 307; full solution builds 0/0.

Docs: Component-TemplateEngine.md "Inherited-Member Propagation & Resync";
CLI README `template resync-members`; known-issues tracker #1/#2 resolved.
This commit is contained in:
Joseph Doherty
2026-06-24 15:51:26 -04:00
parent b3f6833b36
commit 2b5949320c
11 changed files with 873 additions and 19 deletions
@@ -288,18 +288,29 @@
</div>
}
@* M9-T26b: read-only base-changed banner. Informational only — surfaced
when the freshly-resolved inherited set differs from this template's
stored copy (a multi-level inherited member, or a base member added
after this template was created). No action button: a deploy already
resolves fresh, so this is purely an authoring heads-up. *@
@* Base-changed banner (follow-up #1/#2). Surfaced when the freshly-resolved
inherited set differs from this template's stored copy (a base member
added/changed/removed after this template was created, or a multi-level
inherited member). The Resync action materializes the missing inherited
rows, re-syncs drifted ones, and removes orphans on this template and its
derived subtree — so the editable tabs below become complete and the
banner clears. (A deploy already resolves fresh regardless.) *@
@if (_resolved?.Staleness.IsStale == true)
{
<div class="alert alert-info py-2 mb-3" role="status">
<i class="bi bi-info-circle me-1"></i>
<strong>Base template changed</strong> —
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
The effective set above reflects the live base; a deploy resolves fresh.
<div class="alert alert-info d-flex align-items-center justify-content-between py-2 mb-3" role="status">
<div>
<i class="bi bi-info-circle me-1"></i>
<strong>Base template changed</strong> —
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
Resync to bring this template's stored members in line with the base.
</div>
<button class="btn btn-sm btn-primary ms-3 text-nowrap" @onclick="ResyncInheritedMembers" disabled="@_resyncing">
@if (_resyncing)
{
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
Resync inherited members
</button>
</div>
}
@@ -944,6 +955,38 @@
else _toast.ShowError(result.Error);
}
private bool _resyncing;
// Follow-up #1/#2: materialize/refresh this template's (and its subtree's)
// inherited member rows so the editable tabs are complete and the
// base-changed banner clears. In-process TemplateService call (the page is
// already RequireDesign-gated, matching the management command's role gate).
private async Task ResyncInheritedMembers()
{
if (_resyncing) return;
_resyncing = true;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.ResyncInheritedMembersAsync(Id, user);
if (result.IsSuccess)
{
var r = result.Value;
_toast.ShowSuccess(
$"Resynced inherited members — {r.MembersAdded} added, {r.MembersUpdated} updated, {r.MembersRemoved} removed across {r.TemplatesChanged} template(s).");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
finally
{
_resyncing = false;
}
}
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{