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:
@@ -29,10 +29,29 @@ public static class TemplateCommands
|
||||
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
|
||||
var cmd = new Command("resync-members")
|
||||
{
|
||||
Description = "Resync inherited members onto a template and its derived subtree " +
|
||||
"(materialize missing, re-sync drifted, remove orphaned)"
|
||||
};
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ResyncInheritedMembersCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all templates" };
|
||||
|
||||
@@ -403,6 +403,18 @@ scadabridge --url <url> template composition delete --id <int>
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Composition ID to remove |
|
||||
|
||||
#### `template resync-members`
|
||||
|
||||
Reconcile a template's stored *inherited* member rows (and those of its whole derived subtree) with the resolved effective inherited set: materialize missing inherited placeholders (e.g. base members added after a derived template was created), re-sync drifted ones to the live base value, and remove orphaned ones. After a resync the template editor's member tabs are complete and the "Base template changed" banner clears. Authored overrides are never touched. Base member changes auto-propagate going forward; this command repairs templates that drifted before that was in place. Returns the per-subtree change counts (templates changed, members added/updated/removed).
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template resync-members --id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Template ID (its derived subtree is included) |
|
||||
|
||||
---
|
||||
|
||||
### `instance` — Manage instances
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -105,6 +105,23 @@ public sealed record ResolvedTemplateMemberInfo
|
||||
public string? EffectiveTriggerConfiguration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a <see cref="ResyncInheritedMembersCommand"/>: how many derived
|
||||
/// templates were brought into sync and how many inherited member rows were
|
||||
/// added / updated / removed across the reconciled subtree.
|
||||
/// </summary>
|
||||
public sealed record ResyncInheritedMembersResult
|
||||
{
|
||||
/// <summary>Number of templates in the subtree that had at least one inherited-row change.</summary>
|
||||
public int TemplatesChanged { get; init; }
|
||||
/// <summary>Inherited placeholder rows materialized (members missing from the stored set).</summary>
|
||||
public int MembersAdded { get; init; }
|
||||
/// <summary>Inherited placeholder rows re-synced to the live effective value.</summary>
|
||||
public int MembersUpdated { get; init; }
|
||||
/// <summary>Orphaned inherited placeholder rows removed (the base no longer defines them).</summary>
|
||||
public int MembersRemoved { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness summary comparing a template's STORED member rows against the
|
||||
/// freshly-resolved inherited member set.
|
||||
|
||||
@@ -17,6 +17,18 @@ public record ValidateTemplateCommand(int TemplateId);
|
||||
/// </summary>
|
||||
public record GetResolvedTemplateMembersCommand(int TemplateId);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles a template's STORED inherited member rows with the resolved
|
||||
/// effective inherited set, for the template and its whole derived subtree:
|
||||
/// materializes missing inherited placeholders (e.g. base members added after a
|
||||
/// derived template was created), re-syncs drifted placeholders to the live
|
||||
/// effective value, and removes orphaned inherited rows. After a resync the
|
||||
/// editor's editable member tabs are complete and the "base changed" staleness
|
||||
/// banner clears. Designer-gated, audited. Response:
|
||||
/// <see cref="ResyncInheritedMembersResult"/>.
|
||||
/// </summary>
|
||||
public record ResyncInheritedMembersCommand(int TemplateId);
|
||||
|
||||
// Template member operations
|
||||
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
|
||||
@@ -200,6 +200,7 @@ public class ManagementActor : ReceiveActor
|
||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
|
||||
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
|
||||
or ResyncInheritedMembersCommand
|
||||
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
|
||||
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
|
||||
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
||||
@@ -253,6 +254,7 @@ public class ManagementActor : ReceiveActor
|
||||
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
|
||||
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
|
||||
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
|
||||
ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),
|
||||
|
||||
// Template members
|
||||
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
|
||||
@@ -261,9 +263,9 @@ public class ManagementActor : ReceiveActor
|
||||
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
|
||||
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
|
||||
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
|
||||
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd),
|
||||
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
|
||||
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
|
||||
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
|
||||
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
|
||||
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
|
||||
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
|
||||
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
|
||||
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
|
||||
@@ -645,6 +647,20 @@ public class ManagementActor : ReceiveActor
|
||||
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resync inherited members (follow-up #1/#2): reconciles a template's stored
|
||||
/// inherited rows (and its derived subtree's) with the resolved effective set —
|
||||
/// materializing missing placeholders, re-syncing drifted ones, and removing
|
||||
/// orphans — so the editor's editable tabs are complete and the staleness
|
||||
/// banner clears. Designer-gated; the service owns the audit row.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Template folder handlers
|
||||
// ========================================================================
|
||||
@@ -2226,7 +2242,7 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
|
||||
|
||||
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd)
|
||||
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var source = new TemplateNativeAlarmSource(cmd.Name)
|
||||
@@ -2240,10 +2256,13 @@ public class ManagementActor : ReceiveActor
|
||||
};
|
||||
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
||||
await repo.SaveChangesAsync();
|
||||
// Propagate the new source to derived descendants (#1/#2). Native-source
|
||||
// CRUD lives here (not TemplateService), so call the propagation directly.
|
||||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
|
||||
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
|
||||
@@ -2256,14 +2275,21 @@ public class ManagementActor : ReceiveActor
|
||||
source.IsLocked = cmd.IsLocked;
|
||||
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
||||
await repo.SaveChangesAsync();
|
||||
// Propagate the changed source to derived descendants (#1/#2).
|
||||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
|
||||
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
// Capture the owning template before delete so descendants can be reconciled.
|
||||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
|
||||
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
||||
await repo.SaveChangesAsync();
|
||||
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||
if (source != null)
|
||||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||||
return cmd.NativeAlarmSourceId;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -302,6 +303,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the new member to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(attribute);
|
||||
}
|
||||
|
||||
@@ -396,6 +400,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(existing);
|
||||
}
|
||||
|
||||
@@ -431,6 +438,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
@@ -526,6 +536,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the new member to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(alarm);
|
||||
}
|
||||
|
||||
@@ -608,6 +621,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(existing);
|
||||
}
|
||||
|
||||
@@ -642,6 +658,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
@@ -687,6 +706,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the new member to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(script);
|
||||
}
|
||||
|
||||
@@ -770,6 +792,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(existing);
|
||||
}
|
||||
|
||||
@@ -804,6 +829,9 @@ public class TemplateService
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||
await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
@@ -1090,9 +1118,450 @@ public class TemplateService
|
||||
});
|
||||
}
|
||||
|
||||
// Native alarm sources follow the same inherit/override/lock model as the
|
||||
// other member types (TemplateNativeAlarmSource), and the inheritance
|
||||
// resolver + staleness summary count them — so materialize their inherited
|
||||
// placeholders here too, or a freshly-composed derived template reports
|
||||
// stale for every inherited native source (follow-up #1/#2).
|
||||
foreach (var src in baseTemplate.NativeAlarmSources)
|
||||
{
|
||||
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
|
||||
{
|
||||
Description = src.Description,
|
||||
ConnectionName = src.ConnectionName,
|
||||
SourceReference = src.SourceReference,
|
||||
ConditionFilter = src.ConditionFilter,
|
||||
IsLocked = src.IsLocked,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
}
|
||||
|
||||
return derived;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Inherited-member propagation & resync (follow-up #1/#2)
|
||||
// ========================================================================
|
||||
//
|
||||
// A derived template stores IsInherited placeholder rows mirroring the
|
||||
// members it inherits. BuildDerivedTemplate materializes them at compose time,
|
||||
// but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
|
||||
// existed never reached the children — so derived row-sets drifted incomplete
|
||||
// (the editor's editable tabs missed inherited members; #1) and stored rows
|
||||
// diverged from the resolved set (#2). Two mechanisms keep them in sync:
|
||||
// • ReconcileDescendantsAsync — called automatically after every base member
|
||||
// mutation, propagates the change to the whole derived subtree.
|
||||
// • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
|
||||
// that repairs an already-stale template and its subtree.
|
||||
// Both delegate to one order-independent reconcile that only ever touches
|
||||
// IsInherited placeholder rows (never an authored override), which the
|
||||
// inheritance resolver ignores when picking winners — so reconciling one
|
||||
// template never changes what another resolves.
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the stored inherited member rows of a template and its entire
|
||||
/// derived subtree against the resolved effective inherited set: materializes
|
||||
/// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
|
||||
/// This is the operator-facing repair action behind the CLI
|
||||
/// <c>template resync-members</c> command and the editor's "Resync" button.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template to resync (its subtree is included).</param>
|
||||
/// <param name="user">Username for the audit row.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result carrying the per-subtree change counts.</returns>
|
||||
public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
|
||||
int templateId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
|
||||
var byId = BuildTemplateLookup(all);
|
||||
if (!byId.TryGetValue(templateId, out var root))
|
||||
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Reconcile the target itself AND its subtree: resyncing a base repairs
|
||||
// every derivation; resyncing a leaf repairs just it.
|
||||
var targets = new List<int> { templateId };
|
||||
targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));
|
||||
|
||||
var total = new ReconcileCounts();
|
||||
var changed = 0;
|
||||
foreach (var id in targets)
|
||||
{
|
||||
var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
|
||||
if (counts.Any) changed++;
|
||||
total.Add(counts);
|
||||
}
|
||||
|
||||
if (total.Any)
|
||||
{
|
||||
await _auditService.LogAsync(
|
||||
user, "Resync", "Template", templateId.ToString(), root.Name,
|
||||
new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
|
||||
cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
|
||||
{
|
||||
TemplatesChanged = changed,
|
||||
MembersAdded = total.Added,
|
||||
MembersUpdated = total.Updated,
|
||||
MembersRemoved = total.Removed
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a base member change to every derived descendant by reconciling
|
||||
/// their inherited rows. Called automatically after a base member is added,
|
||||
/// updated, or deleted. The base template itself is NOT reconciled — it is the
|
||||
/// author of the change. A no-op (and a single cheap query) when the template
|
||||
/// has no descendants, which is the common case.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template whose member set changed.</param>
|
||||
/// <param name="user">Username for the audit row.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ReconcileDescendantsAsync(
|
||||
int templateId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
|
||||
if (descendants.Count == 0)
|
||||
return;
|
||||
|
||||
var byId = BuildTemplateLookup(all);
|
||||
var total = new ReconcileCounts();
|
||||
foreach (var id in descendants)
|
||||
total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));
|
||||
|
||||
if (total.Any)
|
||||
{
|
||||
var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
|
||||
await _auditService.LogAsync(
|
||||
user, "Resync", "Template", templateId.ToString(), name,
|
||||
new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
|
||||
cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles ONE template's stored IsInherited rows against its resolved
|
||||
/// effective inherited set (the same resolver the editor preview + staleness
|
||||
/// banner use). Adds missing placeholders, re-syncs drifted ones, removes
|
||||
/// orphans. Authored override rows (<c>IsInherited == false</c>) are never
|
||||
/// touched. Repository writes are queued; the caller owns SaveChanges.
|
||||
/// </summary>
|
||||
private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
|
||||
int templateId,
|
||||
IReadOnlyList<Template> all,
|
||||
Dictionary<int, Template> byId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = new ReconcileCounts();
|
||||
if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
|
||||
return counts; // root templates inherit nothing
|
||||
|
||||
var resolved = TemplateInheritanceResolver.Resolve(templateId, all);
|
||||
|
||||
await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
|
||||
await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
|
||||
await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
|
||||
await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
|
||||
return counts;
|
||||
}
|
||||
|
||||
private async Task ReconcileAttributesAsync(
|
||||
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||
{
|
||||
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in resolved)
|
||||
{
|
||||
if (!m.IsInherited) continue; // own / override rows are authoritative
|
||||
inherited.Add(m.Name);
|
||||
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||
var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
|
||||
if (win == null) continue;
|
||||
|
||||
var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
|
||||
if (existing == null)
|
||||
{
|
||||
await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
|
||||
{
|
||||
TemplateId = child.Id,
|
||||
Value = m.EffectiveValue,
|
||||
DataType = win.DataType,
|
||||
ElementDataType = win.ElementDataType,
|
||||
Description = win.Description,
|
||||
DataSourceReference = win.DataSourceReference,
|
||||
IsLocked = win.IsLocked,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
}, ct);
|
||||
counts.Added++;
|
||||
}
|
||||
else if (existing.IsInherited && (
|
||||
existing.Value != m.EffectiveValue ||
|
||||
existing.DataType != win.DataType ||
|
||||
existing.ElementDataType != win.ElementDataType ||
|
||||
existing.Description != win.Description ||
|
||||
existing.DataSourceReference != win.DataSourceReference ||
|
||||
existing.IsLocked != win.IsLocked))
|
||||
{
|
||||
existing.Value = m.EffectiveValue;
|
||||
existing.DataType = win.DataType;
|
||||
existing.ElementDataType = win.ElementDataType;
|
||||
existing.Description = win.Description;
|
||||
existing.DataSourceReference = win.DataSourceReference;
|
||||
existing.IsLocked = win.IsLocked;
|
||||
await _repository.UpdateTemplateAttributeAsync(existing, ct);
|
||||
counts.Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
|
||||
{
|
||||
await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
|
||||
counts.Removed++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileAlarmsAsync(
|
||||
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||
{
|
||||
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in resolved)
|
||||
{
|
||||
if (!m.IsInherited) continue;
|
||||
inherited.Add(m.Name);
|
||||
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||
var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
|
||||
if (win == null) continue;
|
||||
|
||||
// EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
|
||||
// (mirrors the flattener), so the placeholder matches what a deploy and
|
||||
// the staleness comparison produce.
|
||||
var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
|
||||
if (existing == null)
|
||||
{
|
||||
await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
|
||||
{
|
||||
TemplateId = child.Id,
|
||||
Description = win.Description,
|
||||
PriorityLevel = win.PriorityLevel,
|
||||
IsLocked = win.IsLocked,
|
||||
TriggerType = win.TriggerType,
|
||||
TriggerConfiguration = m.EffectiveTriggerConfiguration,
|
||||
OnTriggerScriptId = win.OnTriggerScriptId,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
}, ct);
|
||||
counts.Added++;
|
||||
}
|
||||
else if (existing.IsInherited && (
|
||||
existing.PriorityLevel != win.PriorityLevel ||
|
||||
existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
|
||||
existing.Description != win.Description ||
|
||||
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
|
||||
existing.TriggerType != win.TriggerType ||
|
||||
existing.IsLocked != win.IsLocked))
|
||||
{
|
||||
existing.PriorityLevel = win.PriorityLevel;
|
||||
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
|
||||
existing.Description = win.Description;
|
||||
existing.OnTriggerScriptId = win.OnTriggerScriptId;
|
||||
existing.TriggerType = win.TriggerType;
|
||||
existing.IsLocked = win.IsLocked;
|
||||
await _repository.UpdateTemplateAlarmAsync(existing, ct);
|
||||
counts.Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
|
||||
{
|
||||
await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
|
||||
counts.Removed++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileScriptsAsync(
|
||||
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||
{
|
||||
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in resolved)
|
||||
{
|
||||
if (!m.IsInherited) continue;
|
||||
inherited.Add(m.Name);
|
||||
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||
var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
|
||||
if (win == null) continue;
|
||||
|
||||
var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
|
||||
if (existing == null)
|
||||
{
|
||||
await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
|
||||
{
|
||||
TemplateId = child.Id,
|
||||
IsLocked = win.IsLocked,
|
||||
TriggerType = win.TriggerType,
|
||||
TriggerConfiguration = win.TriggerConfiguration,
|
||||
ParameterDefinitions = win.ParameterDefinitions,
|
||||
ReturnDefinition = win.ReturnDefinition,
|
||||
MinTimeBetweenRuns = win.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
}, ct);
|
||||
counts.Added++;
|
||||
}
|
||||
else if (existing.IsInherited && (
|
||||
existing.Code != win.Code ||
|
||||
existing.TriggerType != win.TriggerType ||
|
||||
existing.TriggerConfiguration != win.TriggerConfiguration ||
|
||||
existing.ParameterDefinitions != win.ParameterDefinitions ||
|
||||
existing.ReturnDefinition != win.ReturnDefinition ||
|
||||
existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
|
||||
existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
|
||||
existing.IsLocked != win.IsLocked))
|
||||
{
|
||||
existing.Code = win.Code;
|
||||
existing.TriggerType = win.TriggerType;
|
||||
existing.TriggerConfiguration = win.TriggerConfiguration;
|
||||
existing.ParameterDefinitions = win.ParameterDefinitions;
|
||||
existing.ReturnDefinition = win.ReturnDefinition;
|
||||
existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
|
||||
existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
|
||||
existing.IsLocked = win.IsLocked;
|
||||
await _repository.UpdateTemplateScriptAsync(existing, ct);
|
||||
counts.Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
|
||||
{
|
||||
await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
|
||||
counts.Removed++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileNativeSourcesAsync(
|
||||
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||
{
|
||||
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in resolved)
|
||||
{
|
||||
if (!m.IsInherited) continue;
|
||||
inherited.Add(m.Name);
|
||||
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||
var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
|
||||
if (win == null) continue;
|
||||
|
||||
var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
|
||||
if (existing == null)
|
||||
{
|
||||
await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
|
||||
{
|
||||
TemplateId = child.Id,
|
||||
Description = win.Description,
|
||||
ConnectionName = win.ConnectionName,
|
||||
SourceReference = win.SourceReference,
|
||||
ConditionFilter = win.ConditionFilter,
|
||||
IsLocked = win.IsLocked,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
}, ct);
|
||||
counts.Added++;
|
||||
}
|
||||
else if (existing.IsInherited && (
|
||||
existing.ConnectionName != win.ConnectionName ||
|
||||
existing.SourceReference != win.SourceReference ||
|
||||
existing.ConditionFilter != win.ConditionFilter ||
|
||||
existing.Description != win.Description ||
|
||||
existing.IsLocked != win.IsLocked))
|
||||
{
|
||||
existing.ConnectionName = win.ConnectionName;
|
||||
existing.SourceReference = win.SourceReference;
|
||||
existing.ConditionFilter = win.ConditionFilter;
|
||||
existing.Description = win.Description;
|
||||
existing.IsLocked = win.IsLocked;
|
||||
await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
|
||||
counts.Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
|
||||
{
|
||||
await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
|
||||
counts.Removed++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breadth-first list of every template that (transitively) derives from
|
||||
/// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
|
||||
/// </summary>
|
||||
private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
|
||||
{
|
||||
if (all is null) return new List<int>();
|
||||
var childrenByParent = new Dictionary<int, List<int>>();
|
||||
foreach (var t in all)
|
||||
{
|
||||
if (t.ParentTemplateId is { } pid)
|
||||
{
|
||||
if (!childrenByParent.TryGetValue(pid, out var list))
|
||||
childrenByParent[pid] = list = new List<int>();
|
||||
list.Add(t.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
var seen = new HashSet<int> { rootId };
|
||||
var queue = new Queue<int>();
|
||||
if (childrenByParent.TryGetValue(rootId, out var direct))
|
||||
foreach (var c in direct) queue.Enqueue(c);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (!seen.Add(id)) continue;
|
||||
result.Add(id);
|
||||
if (childrenByParent.TryGetValue(id, out var kids))
|
||||
foreach (var c in kids) queue.Enqueue(c);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
|
||||
{
|
||||
var byId = new Dictionary<int, Template>(all.Count);
|
||||
foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
|
||||
return byId;
|
||||
}
|
||||
|
||||
/// <summary>Running tally of inherited-row changes during a reconcile.</summary>
|
||||
private sealed class ReconcileCounts
|
||||
{
|
||||
public int Added;
|
||||
public int Updated;
|
||||
public int Removed;
|
||||
public bool Any => Added > 0 || Updated > 0 || Removed > 0;
|
||||
|
||||
public void Add(ReconcileCounts other)
|
||||
{
|
||||
Added += other.Added;
|
||||
Updated += other.Updated;
|
||||
Removed += other.Removed;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user