diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs
index d97e0d91..a75b6e77 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs
@@ -75,4 +75,19 @@ public class DeploymentManagerOptions
///
///
public TimeSpan TerminalDeploymentRecordRetention { get; set; } = TimeSpan.FromDays(365);
+
+ ///
+ /// How long the Central UI pending-updates snapshot stays fresh; one fleet
+ /// flatten per TTL while any Deployer/Administrator circuit is open.
+ ///
+ ///
+ /// The snapshot is a whole-fleet read (every site's artifact hash plus every
+ /// deployed instance's staleness), so it is held behind a single-flight TTL
+ /// cache: however many circuits poll, and however often, the fleet is evaluated
+ /// at most once per this window — and not at all when nobody is looking. Must be
+ /// positive; zero would make every poll a cache miss and re-flatten the fleet per
+ /// circuit, which is precisely the cost the cache exists to avoid.
+ ///
+ ///
+ public TimeSpan PendingUpdatesCacheTtl { get; set; } = TimeSpan.FromSeconds(60);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptionsValidator.cs
index 41a83060..6c6ca50e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptionsValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptionsValidator.cs
@@ -43,6 +43,12 @@ public sealed class DeploymentManagerOptionsValidator : OptionsValidatorBase TimeSpan.Zero,
+ $"ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl must be a positive duration " +
+ $"(was {options.PendingUpdatesCacheTtl}); it is the freshness window of the Central UI " +
+ $"pending-updates snapshot, and a non-positive value would re-flatten the whole fleet on " +
+ $"every poll of every open circuit.");
+
builder.RequireThat(options.TerminalDeploymentRecordRetention >= TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:TerminalDeploymentRecordRetention must not be negative " +
$"(was {options.TerminalDeploymentRecordRetention}); use TimeSpan.Zero to disable purging of " +
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesEvaluator.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesEvaluator.cs
new file mode 100644
index 00000000..b7a102fc
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/IPendingUpdatesEvaluator.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
+
+///
+/// Computes the fleet-wide "pending updates" aggregate — which sites are running
+/// out-of-date system-wide artifacts and how many deployed instances have drifted
+/// from their deployed configuration.
+///
+///
+/// Scoped, and deliberately uncached at this seam: caching belongs to the Central
+/// UI's single-flight TTL cache, which runs this evaluator in its own DI scope so
+/// the fleet is flattened at most once per TTL regardless of how many circuits are
+/// watching. Callers that resolve the evaluator directly pay a full fleet flatten
+/// per call.
+///
+///
+public interface IPendingUpdatesEvaluator
+{
+ ///
+ /// Evaluates every site and every deployed instance, returning one snapshot.
+ ///
+ /// Cancellation token, honoured between instances.
+ /// A snapshot of the fleet's pending updates as of now.
+ Task EvaluateAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesEvaluator.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesEvaluator.cs
new file mode 100644
index 00000000..db8a7cea
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesEvaluator.cs
@@ -0,0 +1,172 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
+
+///
+/// Default : one pass over the fleet that
+/// answers both halves of "is anything waiting to be deployed?" — per-site artifact
+/// staleness and per-instance configuration staleness.
+///
+///
+/// How it stays affordable. Both halves are batch reads, not per-entity loops.
+/// hoists
+/// the global artifact queries once for the whole fleet, and
+/// runs every flatten
+/// through ONE so a template chain shared by N
+/// instances is walked once. It is still a whole-fleet flatten, which is why the
+/// caller caches it behind a TTL rather than calling it per request.
+///
+///
+///
+/// Exceptions propagate. Nothing is swallowed and no partial snapshot is
+/// invented: the caller's single-flight cache discards a faulted flight and the UI
+/// renders unavailable. Degrading a failed read to "up to date" is the one
+/// answer that must never be manufactured — it would silently hide a fleet waiting
+/// on a deploy.
+///
+///
+public sealed class PendingUpdatesEvaluator : IPendingUpdatesEvaluator
+{
+ private readonly ISiteRepository _siteRepository;
+ private readonly ITemplateEngineRepository _templateRepository;
+ private readonly ArtifactDeploymentService _artifactDeploymentService;
+ private readonly DeploymentService _deploymentService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new evaluator.
+ ///
+ /// Repository the site list is read from.
+ /// Repository the instance list is read from.
+ /// Computes each site's current artifact revision hash.
+ /// Evaluates instance staleness in one batched flatten session.
+ /// Logger instance.
+ public PendingUpdatesEvaluator(
+ ISiteRepository siteRepository,
+ ITemplateEngineRepository templateRepository,
+ ArtifactDeploymentService artifactDeploymentService,
+ DeploymentService deploymentService,
+ ILogger logger)
+ {
+ _siteRepository = siteRepository;
+ _templateRepository = templateRepository;
+ _artifactDeploymentService = artifactDeploymentService;
+ _deploymentService = deploymentService;
+ _logger = logger;
+ }
+
+ ///
+ public async Task EvaluateAsync(CancellationToken cancellationToken = default)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ var sites = await _siteRepository.GetAllSitesAsync(cancellationToken);
+ var instances = await _templateRepository.GetAllInstancesAsync(cancellationToken);
+
+ var currentArtifactHashes =
+ await _artifactDeploymentService.ComputeCurrentArtifactHashesAsync(sites, cancellationToken);
+
+ // Only instances the operator has actually deployed can be stale; a
+ // NotDeployed instance has nothing to compare against, and flattening it
+ // would be pure cost on every TTL expiry.
+ var deployedInstances = instances.Where(i => i.State != InstanceState.NotDeployed).ToList();
+ var staleness = await _deploymentService.GetInstanceStalenessAsync(
+ deployedInstances.Select(i => i.Id).ToList(), cancellationToken);
+
+ var rows = new List(sites.Count);
+ foreach (var site in sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ var (deployed, stale, unknown) = TallyInstances(site.Id, deployedInstances, staleness);
+ rows.Add(new SitePendingUpdates(
+ site.Id,
+ site.SiteIdentifier,
+ site.Name,
+ ResolveArtifactState(site, currentArtifactHashes),
+ site.ArtifactsDeployedAt,
+ deployed,
+ stale,
+ unknown));
+ }
+
+ var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows);
+
+ // One line per evaluation — this runs at most once per cache TTL, so it is
+ // the cheapest record of what the fleet looked like and what the sweep cost.
+ _logger.LogInformation(
+ "Pending-updates snapshot: {SiteCount} site(s), {DeployedInstanceCount} deployed instance(s), " +
+ "{StaleInstanceCount} stale instance(s), {StaleArtifactSiteCount} site(s) with out-of-date " +
+ "artifacts, in {ElapsedMs} ms",
+ rows.Count,
+ rows.Sum(r => r.DeployedInstanceCount),
+ snapshot.StaleInstanceCount,
+ snapshot.StaleArtifactSiteCount,
+ stopwatch.ElapsedMilliseconds);
+
+ return snapshot;
+ }
+
+ ///
+ /// Decides a site's artifact state by comparing the hash stamped by its last
+ /// successful artifact deploy against the hash the current definitions would
+ /// produce. A site with no stamp has never had artifacts deployed, which is
+ /// , NOT out of date. A site
+ /// missing from the computed map (it appeared between the two reads) is reported
+ /// out of date, the side that prompts an operator to look rather than hiding it.
+ ///
+ /// The site to classify.
+ /// Site id to the hash a deploy would send right now.
+ /// The site's artifact deployment state.
+ private static ArtifactDeploymentState ResolveArtifactState(
+ Site site,
+ IReadOnlyDictionary currentArtifactHashes)
+ {
+ if (site.ArtifactRevisionHash is null)
+ return ArtifactDeploymentState.NeverDeployed;
+
+ return currentArtifactHashes.TryGetValue(site.Id, out var currentHash)
+ && string.Equals(site.ArtifactRevisionHash, currentHash, StringComparison.Ordinal)
+ ? ArtifactDeploymentState.Current
+ : ArtifactDeploymentState.OutOfDate;
+ }
+
+ ///
+ /// Counts one site's deployed, stale and un-evaluatable instances. Only instances
+ /// present in the staleness result are counted — the batch OMITS an instance with
+ /// no deployed snapshot, which is exactly the "nothing to be stale against"
+ /// exclusion the counts need.
+ ///
+ /// Site whose instances are being tallied.
+ /// All instances in a state other than NotDeployed.
+ /// Instance id to stale (true) / current (false) / unknown (null).
+ /// The deployed, stale and unknown counts for the site.
+ private static (int Deployed, int Stale, int Unknown) TallyInstances(
+ int siteId,
+ IReadOnlyList deployedInstances,
+ IReadOnlyDictionary staleness)
+ {
+ var deployed = 0;
+ var stale = 0;
+ var unknown = 0;
+
+ foreach (var instance in deployedInstances)
+ {
+ if (instance.SiteId != siteId)
+ continue;
+ if (!staleness.TryGetValue(instance.Id, out var isStale))
+ continue;
+
+ deployed++;
+ if (isStale == true)
+ stale++;
+ else if (isStale is null)
+ unknown++;
+ }
+
+ return (deployed, stale, unknown);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesSnapshot.cs
new file mode 100644
index 00000000..c7df8701
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/PendingUpdates/PendingUpdatesSnapshot.cs
@@ -0,0 +1,127 @@
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
+
+///
+/// State of a site's system-wide artifact set (shared scripts, external systems,
+/// database connections, data connections) relative to the definitions central holds
+/// right now.
+///
+public enum ArtifactDeploymentState
+{
+ ///
+ /// Artifacts have never been deployed to this site (the site row carries no
+ /// revision hash). Deliberately NOT a pending update: nothing has drifted, the
+ /// operator simply has not deployed yet. Surfaced on the Sites page so a
+ /// freshly-created site is obvious, never counted in the fleet totals or
+ /// escalated by the rail indicator.
+ ///
+ NeverDeployed,
+
+ ///
+ /// The hash stamped by the last successful artifact deploy equals the hash the
+ /// current definitions would produce — nothing to deploy.
+ ///
+ Current,
+
+ ///
+ /// The current definitions hash differently from what was last deployed, so the
+ /// site is running stale artifacts and a redeploy is pending.
+ ///
+ OutOfDate
+}
+
+///
+/// One site's row in a : its artifact state plus
+/// the instance staleness tallies that back the Sites-page badge and the rail
+/// indicator's breakdown.
+///
+/// Database identifier of the site.
+/// Machine-readable site identifier (as used in routing).
+/// Human-readable site display name.
+/// Whether this site's system-wide artifacts are current.
+///
+/// UTC instant of the last successful artifact deploy to this site, or
+/// when artifacts have never been deployed. Rendered as the
+/// badge tooltip.
+///
+///
+/// Instances at this site that HAVE a deployed configuration snapshot — the
+/// denominator of "N of M instances out of date". An instance the operator has never
+/// deployed has nothing to be stale against and is not counted here.
+///
+///
+/// Deployed instances at this site whose current flatten differs from what is
+/// deployed, i.e. a redeploy is pending.
+///
+///
+/// Deployed instances at this site whose staleness could NOT be evaluated (their
+/// flatten failed — a missing template, a broken binding). Never counted as stale:
+/// the fleet totals must not invent a pending update out of a read failure.
+///
+public sealed record SitePendingUpdates(
+ int SiteId,
+ string SiteIdentifier,
+ string SiteName,
+ ArtifactDeploymentState ArtifactState,
+ DateTimeOffset? ArtifactsDeployedAt,
+ int DeployedInstanceCount,
+ int StaleInstanceCount,
+ int UnknownInstanceCount);
+
+///
+/// One fleet-wide answer to "is anything waiting to be deployed?" — every site's
+/// artifact state and instance staleness tallies as of a single instant.
+///
+///
+/// Computed by and held behind the Central
+/// UI's single-flight TTL cache, so however many circuits are watching, the fleet is
+/// flattened at most once per TTL. An immutable value: a scoped view is taken with
+/// rather than by mutating the snapshot.
+///
+///
+///
+/// UTC instant the snapshot was computed. Rendered as the "as of HH:mm UTC" caption
+/// and preserved verbatim through — a scoped view is the same
+/// observation, not a newer one.
+///
+/// Per-site rows, ordered by site name (ordinal, ignore-case).
+public sealed record PendingUpdatesSnapshot(
+ DateTimeOffset ComputedAt,
+ IReadOnlyList Sites)
+{
+ ///
+ /// Total instances across the snapshot's sites whose deployed configuration has
+ /// drifted from their current flatten. Instances whose staleness could not be
+ /// evaluated are excluded.
+ ///
+ public int StaleInstanceCount => Sites.Sum(s => s.StaleInstanceCount);
+
+ ///
+ /// Number of sites running out-of-date system-wide artifacts.
+ /// is deliberately NOT counted
+ /// — the operator has not deployed yet, which is not a pending update.
+ ///
+ public int StaleArtifactSiteCount =>
+ Sites.Count(s => s.ArtifactState == ArtifactDeploymentState.OutOfDate);
+
+ ///
+ /// Whether anything in this snapshot is waiting to be deployed — the single
+ /// predicate the rail indicator switches on.
+ ///
+ public bool HasPendingUpdates => StaleInstanceCount > 0 || StaleArtifactSiteCount > 0;
+
+ ///
+ /// Returns a copy of this snapshot keeping only the named sites, preserving
+ /// . Used to narrow the fleet snapshot to a site-scoped
+ /// operator's permitted sites, so the computed totals never include a site the
+ /// user cannot see.
+ ///
+ /// Database identifiers of the sites to keep.
+ /// A snapshot over the retained sites, in the same order.
+ public PendingUpdatesSnapshot ForSites(IReadOnlySet siteIds)
+ {
+ ArgumentNullException.ThrowIfNull(siteIds);
+ return new PendingUpdatesSnapshot(
+ ComputedAt,
+ Sites.Where(s => siteIds.Contains(s.SiteId)).ToList());
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
index 7e3405d9..929e480e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -69,6 +70,13 @@ public static class ServiceCollectionExtensions
// flattened config has drifted from their deployed snapshot. Scoped so it
// shares the per-request DbContext with the in-flight import.
services.AddScoped();
+
+ // Fleet-wide "is anything waiting to be deployed?" aggregate behind the
+ // Central UI rail indicator and the Sites-page artifact badges. Scoped because
+ // it reads through the same per-request DbContext as the services it composes;
+ // the Central UI's singleton single-flight cache runs it in its own DI scope so
+ // the fleet is flattened at most once per cache TTL.
+ services.AddScoped();
return services;
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs
index a4ddd503..430a86ab 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs
@@ -72,4 +72,26 @@ public class DeploymentManagerOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage);
}
+
+ [Fact]
+ public void ZeroPendingUpdatesCacheTtl_IsRejected()
+ {
+ // A zero TTL makes every Deployer/Administrator circuit's poll a cache miss,
+ // so the whole fleet is re-flattened on each one — the exact cost the cache
+ // exists to avoid.
+ var result = Validate(new DeploymentManagerOptions { PendingUpdatesCacheTtl = TimeSpan.Zero });
+ Assert.True(result.Failed);
+ Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage);
+ }
+
+ [Fact]
+ public void NegativePendingUpdatesCacheTtl_IsRejected()
+ {
+ var result = Validate(new DeploymentManagerOptions
+ {
+ PendingUpdatesCacheTtl = TimeSpan.FromSeconds(-1)
+ });
+ Assert.True(result.Failed);
+ Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
new file mode 100644
index 00000000..4becd79a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
@@ -0,0 +1,415 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
+using ZB.MOM.WW.ScadaBridge.Communication;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
+
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
+
+///
+/// Coverage for — the single fleet-wide read
+/// behind the Central UI rail indicator and the Sites page badges.
+///
+///
+/// The evaluator is built over the REAL and
+/// (substituting only their repository and flattening
+/// seams, as the sibling suites do), because the two properties worth pinning are
+/// exactly the ones a mocked collaborator would paper over: that the artifact state
+/// is decided by comparing the site's STORED hash against a hash recomputed from the
+/// current definitions, and that only instances the operator has actually deployed
+/// are ever handed to the staleness batch.
+///
+///
+public class PendingUpdatesEvaluatorTests
+{
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly ITemplateEngineRepository _templateRepo = Substitute.For();
+ private readonly IExternalSystemRepository _externalSystemRepo = Substitute.For();
+ private readonly INotificationRepository _notificationRepo = Substitute.For();
+ private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For();
+ private readonly IFlatteningPipeline _pipeline = Substitute.For();
+ private readonly IAuditService _audit = Substitute.For();
+ private readonly RevisionHashService _hasher = new();
+ private readonly ArtifactRevisionHashService _artifactHasher = new();
+
+ private readonly ArtifactDeploymentService _artifactService;
+ private readonly DeploymentService _deploymentService;
+ private readonly PendingUpdatesEvaluator _evaluator;
+
+ ///
+ /// Builds the evaluator over the real services; every repository and the
+ /// flattening pipeline are substituted.
+ ///
+ public PendingUpdatesEvaluatorTests()
+ {
+ _pipeline.CreateSession().Returns(new FlattenSession(new TemplateGraphWatermark()));
+
+ var comms = new CommunicationService(
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+
+ _artifactService = new ArtifactDeploymentService(
+ _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
+ comms, _audit,
+ Options.Create(new DeploymentManagerOptions()),
+ NullLogger.Instance,
+ _artifactHasher);
+
+ _deploymentService = new DeploymentService(
+ _deploymentRepo,
+ _siteRepo,
+ _pipeline,
+ comms,
+ new OperationLockManager(),
+ _audit,
+ new DiffService(),
+ _hasher,
+ new DeploymentStatusNotifier(NullLogger.Instance),
+ Substitute.For(),
+ Options.Create(new DeploymentManagerOptions()),
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+
+ _evaluator = new PendingUpdatesEvaluator(
+ _siteRepo, _templateRepo, _artifactService, _deploymentService,
+ NullLogger.Instance);
+ }
+
+ // ── arrange helpers ──────────────────────────────────────────────────────
+
+ /// Stubs the site list read.
+ private void ArrangeSites(params Site[] sites) =>
+ _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(sites);
+
+ /// Stubs the instance list read.
+ private void ArrangeInstances(params Instance[] instances) =>
+ _templateRepo.GetAllInstancesAsync(Arg.Any()).Returns(instances);
+
+ /// Builds an instance in the given lifecycle state at the given site.
+ private static Instance MakeInstance(int id, int siteId, InstanceState state) =>
+ new($"Inst{id}") { Id = id, SiteId = siteId, State = state };
+
+ /// Builds a one-attribute flattened config with the given scalar value.
+ private static FlattenedConfiguration ScalarConfig(string value) => new()
+ {
+ InstanceUniqueName = "TestInst",
+ Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }]
+ };
+
+ /// Stubs a successful flatten of at the given hash.
+ private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
+ _pipeline.FlattenAndValidateAsync(
+ instanceId, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Result.Success(
+ new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
+
+ /// Stubs a FAILED flatten of — the "unknown" leg.
+ private void ArrangeFlattenFailure(int instanceId) =>
+ _pipeline.FlattenAndValidateAsync(
+ instanceId, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Result.Failure("template missing"));
+
+ /// Stubs the (InstanceId, RevisionHash) projection read.
+ private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
+ _deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any())
+ .Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
+
+ /// Stubs the fat snapshot read for .
+ private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
+ _deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any())
+ .Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
+ {
+ InstanceId = instanceId,
+ DeployedAt = DateTimeOffset.UtcNow
+ });
+
+ ///
+ /// Asks the artifact service what the site's hash WOULD be right now — the value a
+ /// successful deploy would have stamped, i.e. what makes a site read Current.
+ ///
+ private async Task CurrentArtifactHashAsync(Site site) =>
+ (await _artifactService.ComputeCurrentArtifactHashesAsync([site]))[site.Id];
+
+ // ── artifact state ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ ArrangeSites(site);
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ var row = Assert.Single(snapshot.Sites);
+ Assert.Equal(ArtifactDeploymentState.NeverDeployed, row.ArtifactState);
+ Assert.Null(row.ArtifactsDeployedAt);
+ // NeverDeployed is the operator not having deployed yet, not a pending
+ // update: it is surfaced on the Sites page but never counted or nagged about.
+ Assert.Equal(0, snapshot.StaleArtifactSiteCount);
+ Assert.False(snapshot.HasPendingUpdates);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ var deployedAt = DateTimeOffset.UtcNow.AddHours(-2);
+ site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
+ site.ArtifactsDeployedAt = deployedAt;
+ ArrangeSites(site);
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ var row = Assert.Single(snapshot.Sites);
+ Assert.Equal(ArtifactDeploymentState.Current, row.ArtifactState);
+ Assert.Equal(deployedAt, row.ArtifactsDeployedAt);
+ Assert.Equal(0, snapshot.StaleArtifactSiteCount);
+ Assert.False(snapshot.HasPendingUpdates);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ site.ArtifactRevisionHash = "a-hash-from-an-older-artifact-set";
+ site.ArtifactsDeployedAt = DateTimeOffset.UtcNow.AddDays(-3);
+ ArrangeSites(site);
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ var row = Assert.Single(snapshot.Sites);
+ Assert.Equal(ArtifactDeploymentState.OutOfDate, row.ArtifactState);
+ Assert.Equal(1, snapshot.StaleArtifactSiteCount);
+ Assert.True(snapshot.HasPendingUpdates);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow()
+ {
+ var site = new Site("Alpha Plant", "site-a") { Id = 7 };
+ ArrangeSites(site);
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ var row = Assert.Single(snapshot.Sites);
+ Assert.Equal(7, row.SiteId);
+ Assert.Equal("site-a", row.SiteIdentifier);
+ Assert.Equal("Alpha Plant", row.SiteName);
+ }
+
+ // ── instance counts ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ ArrangeSites(site);
+ // 1 = deployed + stale, 2 = deployed but un-evaluatable, 3 = never deployed.
+ ArrangeInstances(
+ MakeInstance(1, 1, InstanceState.Enabled),
+ MakeInstance(2, 1, InstanceState.Disabled),
+ MakeInstance(3, 1, InstanceState.NotDeployed));
+
+ var deployedConfig = ScalarConfig("5");
+ var deployedHash = _hasher.ComputeHash(deployedConfig);
+ ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
+ ArrangeFlatten(1, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
+ ArrangeSnapshot(1, deployedConfig, deployedHash);
+ ArrangeFlattenFailure(2);
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ var row = Assert.Single(snapshot.Sites);
+ Assert.Equal(2, row.DeployedInstanceCount);
+ Assert.Equal(1, row.StaleInstanceCount);
+ Assert.Equal(1, row.UnknownInstanceCount);
+ Assert.Equal(1, snapshot.StaleInstanceCount);
+ Assert.True(snapshot.HasPendingUpdates);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ ArrangeSites(site);
+ ArrangeInstances(
+ MakeInstance(1, 1, InstanceState.Enabled),
+ MakeInstance(2, 1, InstanceState.NotDeployed));
+
+ var config = ScalarConfig("5");
+ var hash = _hasher.ComputeHash(config);
+ // Instance 2 deliberately HAS a leftover deployed-hash row (an undeploy that
+ // left the snapshot behind), so the staleness batch would happily evaluate it
+ // if its id were passed — the filter under test is the State gate, not the
+ // batch's own omission of instances with no snapshot.
+ ArrangeDeployedHashes((1, hash), (2, hash));
+ ArrangeFlatten(1, config, hash);
+ ArrangeFlatten(2, config, hash);
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ // A NotDeployed instance has nothing to be stale against; flattening it would
+ // be pure cost on every TTL expiry, so it must never reach the batch.
+ await _pipeline.Received(1).FlattenAndValidateAsync(
+ 1, Arg.Any(), Arg.Any(), Arg.Any());
+ await _pipeline.DidNotReceive().FlattenAndValidateAsync(
+ 2, Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite()
+ {
+ var alpha = new Site("Alpha", "site-a") { Id = 1 };
+ var bravo = new Site("Bravo", "site-b") { Id = 2 };
+ ArrangeSites(alpha, bravo);
+ ArrangeInstances(
+ MakeInstance(1, 1, InstanceState.Enabled),
+ MakeInstance(2, 2, InstanceState.Enabled));
+
+ var deployedConfig = ScalarConfig("5");
+ var deployedHash = _hasher.ComputeHash(deployedConfig);
+ ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
+ ArrangeFlatten(1, deployedConfig, deployedHash);
+ ArrangeFlatten(2, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
+ ArrangeSnapshot(2, deployedConfig, deployedHash);
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ Assert.Equal(0, snapshot.Sites.Single(s => s.SiteId == 1).StaleInstanceCount);
+ Assert.Equal(1, snapshot.Sites.Single(s => s.SiteId == 2).StaleInstanceCount);
+ Assert.Equal(1, snapshot.StaleInstanceCount);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates()
+ {
+ var site = new Site("Alpha", "site-a") { Id = 1 };
+ site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
+ ArrangeSites(site);
+ ArrangeInstances(MakeInstance(1, 1, InstanceState.Enabled));
+
+ var config = ScalarConfig("5");
+ var hash = _hasher.ComputeHash(config);
+ ArrangeDeployedHashes((1, hash));
+ ArrangeFlatten(1, config, hash);
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
+ Assert.Equal(0, snapshot.StaleInstanceCount);
+ Assert.Equal(0, snapshot.StaleArtifactSiteCount);
+ Assert.False(snapshot.HasPendingUpdates);
+ }
+
+ // ── ordering, ComputedAt, ForSites ───────────────────────────────────────
+
+ [Fact]
+ public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase()
+ {
+ ArrangeSites(
+ new Site("charlie", "site-c") { Id = 3 },
+ new Site("Alpha", "site-a") { Id = 1 },
+ new Site("bravo", "site-b") { Id = 2 });
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName));
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_StampsComputedAt()
+ {
+ var before = DateTimeOffset.UtcNow;
+ ArrangeSites(new Site("Alpha", "site-a") { Id = 1 });
+ ArrangeInstances();
+
+ var snapshot = await _evaluator.EvaluateAsync();
+
+ Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_ExceptionsPropagate()
+ {
+ _siteRepo.GetAllSitesAsync(Arg.Any())
+ .Returns>(_ => throw new InvalidOperationException("db down"));
+
+ // The caller's single-flight cache discards a faulted flight and the UI
+ // renders "unavailable"; swallowing here would make a broken fleet read
+ // "up to date", which is the one answer that must never be invented.
+ var ex = await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync());
+ Assert.Equal("db down", ex.Message);
+ }
+
+ [Fact]
+ public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt()
+ {
+ var computedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
+ var snapshot = new PendingUpdatesSnapshot(computedAt,
+ [
+ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0),
+ new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.Current, null, 1, 1, 0),
+ new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.OutOfDate, null, 0, 0, 0)
+ ]);
+
+ var scoped = snapshot.ForSites(new HashSet { 2 });
+
+ Assert.Equal(computedAt, scoped.ComputedAt);
+ var row = Assert.Single(scoped.Sites);
+ Assert.Equal(2, row.SiteId);
+ // The totals are recomputed over the kept sites, so a site-scoped user is
+ // never shown a count that includes a site they cannot see.
+ Assert.Equal(1, scoped.StaleInstanceCount);
+ Assert.Equal(0, scoped.StaleArtifactSiteCount);
+ Assert.True(scoped.HasPendingUpdates);
+ }
+
+ [Fact]
+ public void ForSites_EmptyScope_YieldsNoPendingUpdates()
+ {
+ var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
+ [
+ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0)
+ ]);
+
+ var scoped = snapshot.ForSites(new HashSet());
+
+ Assert.Empty(scoped.Sites);
+ Assert.False(scoped.HasPendingUpdates);
+ }
+
+ [Fact]
+ public void Snapshot_Totals_SumAcrossSites()
+ {
+ var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
+ [
+ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 4, 2, 1),
+ new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.NeverDeployed, null, 0, 0, 0),
+ new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.Current, null, 3, 3, 0)
+ ]);
+
+ Assert.Equal(5, snapshot.StaleInstanceCount);
+ // NeverDeployed is deliberately not counted alongside OutOfDate.
+ Assert.Equal(1, snapshot.StaleArtifactSiteCount);
+ Assert.True(snapshot.HasPendingUpdates);
+ }
+}