diff --git a/Darling/Darling.Tests/ViewerOverviewExplainsItselfTests.cs b/Darling/Darling.Tests/ViewerOverviewExplainsItselfTests.cs
index df5b00389d..37e4ce220c 100644
--- a/Darling/Darling.Tests/ViewerOverviewExplainsItselfTests.cs
+++ b/Darling/Darling.Tests/ViewerOverviewExplainsItselfTests.cs
@@ -185,7 +185,7 @@ public void TheCardsTooltip_EndsWithHowToActOnIt()
/// Two rounds on #2429 each surfaced a different contradicting pair (IsOnline null with no
/// awaiting marker, then IsOnline true WITH one). Guarding those two would have left the third to
/// be found the same way, so the renderings now share one discriminant
- /// () and this walks the whole product to prove no pair is left.
+ /// () and this walks the whole product to prove no pair is left.
///
[Fact]
public void TheCardsTooltip_AgreesWithTheStatusWord_ForEveryCombinationOfTheFreshnessFlags()
@@ -265,27 +265,33 @@ cannot leave this test passing over a smaller surface than it claims to cover. *
[Fact]
public void TheCardStatus_IsTheOnlyPlaceTheFreshnessFlagsAreRead()
{
- Assert.Equal(ServerCardStatus.Online, Healthy().CardStatus);
- Assert.Equal(ServerCardStatus.Stale, Stale().CardStatus);
- Assert.Equal(ServerCardStatus.Offline, Offline().CardStatus);
- Assert.Equal(ServerCardStatus.AwaitingFirstCollection, Awaiting().CardStatus);
- Assert.Equal(ServerCardStatus.Unknown, UnknownStatus().CardStatus);
+ Assert.Equal(ServerCollectionStatus.Online, Healthy().CardStatus);
+ Assert.Equal(ServerCollectionStatus.Stale, Stale().CardStatus);
+ Assert.Equal(ServerCollectionStatus.Offline, Offline().CardStatus);
+ Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection, Awaiting().CardStatus);
+ Assert.Equal(ServerCollectionStatus.Unknown, UnknownStatus().CardStatus);
/* The pair the second review round found: awaiting set alongside an online card. The status word has
always ignored the marker there, and now so does everything downstream of it. */
var onlineAndAwaiting = Healthy();
onlineAndAwaiting.AwaitingFirstCollection = true;
- Assert.Equal(ServerCardStatus.Online, onlineAndAwaiting.CardStatus);
+ Assert.Equal(ServerCollectionStatus.Online, onlineAndAwaiting.CardStatus);
Assert.Equal("Online", onlineAndAwaiting.StatusDisplay);
Assert.DoesNotContain("Awaiting", onlineAndAwaiting.StatusTooltip, StringComparison.Ordinal);
Assert.DoesNotContain("Awaiting", FleetRollup.BuildReason(onlineAndAwaiting), StringComparison.Ordinal);
- /* The source pin: nothing but CardStatus may branch on the flag pair. */
+ /* The source pin: nothing but CardStatus may branch on the flag triple, and since #2473 the ladder
+ itself is not written here either — the card RENDERS PerformanceMonitor.Common's one copy, which
+ the sidebar row and the service's two status surfaces also render. The syntax-agnostic half of that
+ claim (nobody re-spells the words anywhere) is ViewerSidebarDotRendersTheCardStatusTests'. */
var overview = ReadRepoFile(Path.Combine(
"Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.Overview.cs"));
- Assert.Contains("public ServerCardStatus CardStatus => IsOnline switch", overview, StringComparison.Ordinal);
- Assert.Contains("public string StatusDisplay => CardStatus switch", overview, StringComparison.Ordinal);
+ Assert.Contains("public ServerCollectionStatus CardStatus =>", overview, StringComparison.Ordinal);
+ Assert.Contains(
+ "ServerCollectionStatusRules.Classify(IsOnline, HasCollectorErrors, AwaitingFirstCollection);",
+ overview, StringComparison.Ordinal);
+ Assert.Contains("public string StatusDisplay => CardStatus.Word();", overview, StringComparison.Ordinal);
Assert.Contains("public SolidColorBrush StatusBrush => MakeBrush(CardStatus switch", overview, StringComparison.Ordinal);
}
diff --git a/Darling/Darling.Tests/ViewerServerChromeTests.cs b/Darling/Darling.Tests/ViewerServerChromeTests.cs
index 8f1acbb4f6..3a7687a07e 100644
--- a/Darling/Darling.Tests/ViewerServerChromeTests.cs
+++ b/Darling/Darling.Tests/ViewerServerChromeTests.cs
@@ -68,17 +68,21 @@ public void ApplyFreshness_OldCollection_IsOffline()
}
[Fact]
- public void ApplyFreshness_NeverCollected_IsUnknownDot_NotOffline()
+ public void ApplyFreshness_NeverCollected_IsAwaitingFirstCollection_NotOffline()
{
- /* Never-collected = the service hasn't reached the server yet (bootstrap). The sidebar dot
- goes grey Unknown, never the red Offline — a queued server is not a dead one (24-server
- field incident, 2026-07-17). */
+ /* Never-collected = the service hasn't reached the server yet (bootstrap). Never the red Offline —
+ a queued server is not a dead one (24-server field incident, 2026-07-17).
+
+ This asserted the grey "Unknown" dot until #2473, and the assertion was the defect written down:
+ the Overview card said amber "Awaiting first collection" for the same server, off the same
+ freshness call, one panel over. The dot now says what the card says. */
var server = Server();
server.ApplyFreshness(null, DateTime.UtcNow);
Assert.Null(server.IsOnline);
- Assert.Equal("Unknown", server.DotStatus);
+ Assert.True(server.AwaitingFirstCollection);
+ Assert.Equal("Awaiting first collection", server.DotStatus);
}
[Fact]
diff --git a/Darling/Darling.Tests/ViewerSidebarDotRendersTheCardStatusTests.cs b/Darling/Darling.Tests/ViewerSidebarDotRendersTheCardStatusTests.cs
new file mode 100644
index 0000000000..f6dac88d58
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerSidebarDotRendersTheCardStatusTests.cs
@@ -0,0 +1,652 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #2473: the sidebar row's status dot stops deriving its own copy of the collection-status ladder.
+///
+/// What was wrong, and it was not merely duplication. DarlingServer.ApplyFreshness and
+/// ServerSummaryItem.ApplyFreshness start from the SAME
+/// call and then each threw the discriminant away in
+/// favour of flags — the card kept three of them, the sidebar row kept two. So for a registered-but-never-
+/// collected server the card said amber "Awaiting first collection" and the dot beside it went grey
+/// "Unknown", on the same screen, about the same server. The dot is the thing a reader points at first, and
+/// it was silently giving the pre-#2429 answer.
+///
+/// What is pinned here. That the two surfaces agree BY CONSTRUCTION rather than by observation —
+/// both render — that the dot says what it means, that every state
+/// the card paints has a dot colour to match, and that the words are written in exactly one place, scanned
+/// without caring what syntax a copy is written in.
+///
+/// Why the scan is shaped the way it is. #2470 landed the Lite half of this and its pin had to be
+/// widened twice: the copy it existed to forbid sat in a DIFFERENT FILE and was written as if
+/// statements, so a scan for one literal in one file missed it on both axes at once. The scan below walks
+/// three source trees, strips comments with a real lexer rather than a line prefix, and forbids the words
+/// themselves — an if chain, a switch, a dictionary and a ternary all have to spell them.
+///
+public sealed class ViewerSidebarDotRendersTheCardStatusTests
+{
+ private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
+
+ /// The four freshness inputs a real refresh can produce, as (last collection, what it means).
+ public static TheoryData FreshnessAgesMinutes => new() { 0, 5, 30 };
+
+ private static DarlingServer Dot(DateTime? lastCollectionUtc)
+ {
+ var server = new DarlingServer(1, "SQL2022", "Prod", true, 16);
+ server.ApplyFreshness(lastCollectionUtc, Now);
+ return server;
+ }
+
+ private static ServerSummaryItem Card(DateTime? lastCollectionUtc)
+ {
+ var card = new ServerSummaryItem { ServerName = "SQL2022", ServerId = 1, LastCollectionTime = lastCollectionUtc };
+ card.ApplyFreshness(Now);
+ return card;
+ }
+
+ ///
+ /// THE DEFECT. Handed the same last-collection instant, the sidebar row and the Overview card land on the
+ /// same state and show the same word. Swept over every freshness band rather than the happy one, because
+ /// the band that drifted is the one nobody looks at during a steady-state day — a fleet only ever has
+ /// never-collected servers while it is bootstrapping, which is exactly when someone is watching the
+ /// sidebar to see whether the bootstrap is working.
+ ///
+ [Fact]
+ public void TheDot_AndTheCard_RenderOneLadder()
+ {
+ var inputs = new DateTime?[]
+ {
+ Now, // Fresh
+ Now.AddMinutes(-5), // Stale
+ Now.AddMinutes(-30), // Offline
+ null, // NeverCollected — the one that disagreed
+ };
+
+ var seen = new HashSet();
+
+ foreach (var lastCollection in inputs)
+ {
+ var dot = Dot(lastCollection);
+ var card = Card(lastCollection);
+ var where = $"last collection {lastCollection?.ToString("O") ?? "never"}";
+
+ Assert.Equal(card.CardStatus, dot.CardStatus);
+ Assert.Equal(card.StatusDisplay, dot.DotStatus);
+
+ /* And the flags themselves, because they are what WPF binds — the card border and the offline
+ overlay read them directly, so two surfaces agreeing on the word while disagreeing on
+ IsOnline would still paint differently. */
+ Assert.Equal(card.IsOnline, dot.IsOnline);
+ Assert.Equal(card.HasCollectorErrors, dot.HasCollectorErrors);
+ Assert.Equal(card.AwaitingFirstCollection, dot.AwaitingFirstCollection);
+
+ Assert.True(seen.Add(dot.CardStatus), $"two inputs produced the same state; {where}");
+ }
+
+ /* The sweep really did reach four distinct states, so a future change that collapses one of them
+ cannot leave this passing over a smaller surface than it claims. */
+ Assert.Equal(4, seen.Count);
+ Assert.Contains(ServerCollectionStatus.AwaitingFirstCollection, seen);
+ }
+
+ ///
+ /// The dot's words are the card's words, including the fifth one the dot never had. "Unknown" is
+ /// unreachable through ApplyFreshness and stays a named state anyway: a row constructed but not yet
+ /// refreshed is in it, which is what the viewer shows for the first second after a store read.
+ ///
+ [Fact]
+ public void TheDotWords_AreTheCardsWords()
+ {
+ Assert.Equal("Online", Dot(Now).DotStatus);
+ Assert.Equal("Warning", Dot(Now.AddMinutes(-5)).DotStatus);
+ Assert.Equal("Offline", Dot(Now.AddMinutes(-30)).DotStatus);
+ Assert.Equal("Awaiting first collection", Dot(null).DotStatus);
+ Assert.Equal("Unknown", new DarlingServer(1, "SQL2022", "Prod", true, 16).DotStatus);
+ }
+
+ ///
+ /// Every state the card paints has a dot colour that MATCHES it, keyed off the enum rather than a list
+ /// someone remembered to extend. This is the assertion that would have caught the defect on its own: the
+ /// dot had triggers for three of five states and the two without one fell through to the muted grey
+ /// default, which paints a wrong colour and fails nothing.
+ ///
+ /// The dot paints from the theme dictionaries and the card from its own dark-theme hexes, so the
+ /// two cannot be compared as colours. They are compared as SEVERITY: the card's hex says which family the
+ /// state belongs to, and the trigger must reach for that family's brush key. Amber is deliberately shared
+ /// by two states here — that is the card's own choice and the reason the dot needed a tooltip.
+ ///
+ /// Unknown is asserted to have NO trigger. Grey is the honest paint for a server whose
+ /// freshness was never classified, and it is the style's default, so an arm would be redundant — but
+ /// "redundant" and "forgotten" look identical in XAML, which is the whole story of this issue. Saying
+ /// which one it is here makes the next reader's edit a decision.
+ ///
+ [Fact]
+ public void EveryStateTheCardPaints_HasADotColourToMatch()
+ {
+ /* The card's palette, read off the card rather than retyped, mapped to the brush family it means. */
+ var family = new Dictionary(StringComparer.Ordinal)
+ {
+ ["#FF81C784"] = "SuccessBrush",
+ ["#FFFFD54F"] = "WarningBrush",
+ ["#FFE57373"] = "ErrorBrush",
+ ["#FF888888"] = "ForegroundMutedBrush",
+ };
+
+ var triggers = SidebarDotTriggers();
+
+ foreach (var status in Enum.GetValues())
+ {
+ var word = status.Word();
+ var cardColour = CardWith(status).StatusBrush.Color.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ Assert.True(family.ContainsKey(cardColour), $"the card paints {status} an unrecognised {cardColour}");
+ var expected = family[cardColour];
+
+ if (expected == "ForegroundMutedBrush")
+ {
+ Assert.False(triggers.ContainsKey(word),
+ $"'{word}' is the muted default and must not also have a trigger");
+ continue;
+ }
+
+ Assert.True(triggers.ContainsKey(word),
+ $"the sidebar dot has no DataTrigger for '{word}' — it will paint the muted default and fail nothing");
+ Assert.Equal(expected, triggers[word]);
+ }
+
+ /* And no trigger for a word the enum cannot produce: a stale arm left behind after a rename paints
+ nothing and reads as coverage. */
+ var words = Enum.GetValues().Select(s => s.Word()).ToHashSet(StringComparer.Ordinal);
+ foreach (var painted in triggers.Keys)
+ {
+ Assert.Contains(painted, words);
+ }
+ }
+
+ ///
+ /// The dot carries the tooltip, and it opens on the sentence the card's vocabulary uses. Removing the
+ /// attribute compiles perfectly clean and silently returns the sidebar to a coloured circle nobody can
+ /// interrogate, which is why this reads XAML — no assertion about a C# object can reach an element's
+ /// attributes.
+ ///
+ [Fact]
+ public void TheSidebarDot_IsBoundToItsTooltip()
+ {
+ var xaml = ReadRepoFile(Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml"));
+
+ var at = xaml.IndexOf("{Binding Server.DotStatus}", StringComparison.Ordinal);
+ Assert.True(at > 0, "the sidebar status dot is gone — find where it moved before editing this test");
+
+ var open = xaml.LastIndexOf(" 0, "the sidebar status dot is no longer an Ellipse");
+
+ var element = xaml[open..xaml.IndexOf('>', open)];
+ Assert.Contains("ToolTip=\"{Binding Server.DotTooltip}\"", element, StringComparison.Ordinal);
+ }
+
+ ///
+ /// What the tooltip says. The first line is the shared headline, so a reader moving between the dot and
+ /// the card meets one vocabulary; it opens on the same word the dot itself shows, so the tooltip can
+ /// never explain a different state than the colour is painting.
+ ///
+ [Fact]
+ public void TheDotTooltip_ExplainsTheStateItIsPainting()
+ {
+ foreach (var lastCollection in new DateTime?[] { Now, Now.AddMinutes(-5), Now.AddMinutes(-30), null })
+ {
+ var dot = Dot(lastCollection);
+ var lines = dot.DotTooltip.Split('\n');
+
+ Assert.Equal(dot.CardStatus.Headline(), lines[0]);
+ Assert.StartsWith(dot.DotStatus, lines[0], StringComparison.Ordinal);
+
+ /* And it ends on the gesture THIS surface supports. ServerList_MouseDoubleClick opens the tab;
+ a single click only selects, so naming one would be naming a no-op. */
+ Assert.Equal("Double-click the row to open this server's tab", lines[^1]);
+ }
+
+ var xaml = ReadRepoFile(Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml"));
+ Assert.Contains("MouseDoubleClick=\"ServerList_MouseDoubleClick\"", xaml, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The separation #2429 spent four review rounds on, held at the classifier's signature. In Darling every
+ /// one of these words is a COLLECTION answer — there is no live ping — and the conflation that issue
+ /// untangled was one amber word standing for a stale collection AND for a metric breach with nothing
+ /// telling them apart. So a server whose metrics are on fire but whose collection is current is
+ /// Online, and its severity lives on the card's rows and border where a reader can see which axis
+ /// they are looking at.
+ ///
+ /// takes three flags and no metric, so folding a
+ /// severity back in cannot be done without changing a signature this test names — the failure mode being
+ /// guarded is a plausible, well-meant edit, not a typo. Lite holds the mirror-image line: #2457 kept
+ /// collection freshness out of a word that reports a connection check there.
+ ///
+ [Fact]
+ public void TheStatusWord_CannotBandMetricsEvenByAccident()
+ {
+ var onFire = new ServerSummaryItem
+ {
+ ServerName = "SQL2022",
+ ServerId = 1,
+ LastCollectionTime = Now,
+ CpuPercent = 99,
+ DeadlockCount = 12,
+ FailedCollectorCount = 3,
+ };
+ onFire.ApplyFreshness(Now);
+
+ Assert.Equal(ServerCollectionStatus.Online, onFire.CardStatus);
+ Assert.Equal("Online", onFire.StatusDisplay);
+
+ /* The severity is not lost — it is reported on the axis it belongs to. */
+ Assert.Equal(HealthSeverity.Critical, onFire.OverallMetricSeverity);
+
+ var rules = ReadRepoFile(Path.Combine("PerformanceMonitor.Common", "ServerHealthBands.cs"));
+ Assert.Contains(
+ "public static ServerCollectionStatus Classify(bool? isOnline, bool hasCollectorErrors, bool awaitingFirstCollection) =>",
+ rules, StringComparison.Ordinal);
+
+ /* The dot tells the reader which axis it is on, rather than leaving them to infer it from a colour —
+ the #2429 / #2422 conflation in miniature. */
+ Assert.Contains(
+ "Darling has no live ping: this is how old the newest collection is, not a connection check.",
+ Dot(Now).DotTooltip, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The pin that would actually have caught this one, in the shape #2470 had to arrive at the hard way.
+ /// #2451 counted the literal "IsOnline switch" at exactly one occurrence in exactly one file, and
+ /// the copy it existed to forbid was already sitting in another file written as if statements — it
+ /// evaded that pin on both axes at once.
+ ///
+ /// So the invariant is not "one switch". It is that the WORDS are written once, scanned across
+ /// every tree that can hold a copy, with comments removed by a lexer rather than a line prefix — three of
+ /// the four copies this issue found sit inside files whose doc comments legitimately quote all five
+ /// words.
+ ///
+ /// "Warning" is deliberately not in the forbidden set. It is shared with the alert-badge
+ /// severity, the AG-health labels and the fleet band labels, none of which are this ladder, and a text
+ /// scan cannot tell them apart. It costs nothing: no copy of THIS ladder can be written without also
+ /// spelling at least "Online" and "Offline".
+ ///
+ /// XAML is out of scope on purpose — MainWindow.xaml must spell the words to match on them,
+ /// and is what holds those spellings to the
+ /// enum instead.
+ ///
+ [Fact]
+ public void TheStatusWords_AreWrittenInExactlyOnePlace()
+ {
+ var rulesFile = Path.Combine(RepoRoot(), "PerformanceMonitor.Common", "ServerHealthBands.cs");
+ var forbidden = new[] { "\"Online\"", "\"Offline\"", "\"Unknown\"", "\"Awaiting first collection\"", "\"AwaitingFirstCollection\"" };
+
+ /* Part A: the phrase only this ladder spells, anywhere in the three trees. A copy with all five
+ states has to write it; nothing else in the product has any reason to. */
+ var spellingTheePhrase = ScannedSources()
+ .Where(f => !PathsEqual(f.Key, rulesFile))
+ .Where(f => f.Value.Contains("\"Awaiting first collection\"", StringComparison.Ordinal)
+ || f.Value.Contains("\"AwaitingFirstCollection\"", StringComparison.Ordinal))
+ .Select(f => f.Key)
+ .ToList();
+
+ Assert.True(spellingTheePhrase.Count == 0,
+ "these files spell the never-collected state themselves instead of rendering the one ladder: "
+ + string.Join(", ", spellingTheePhrase));
+
+ /* Part B: any file that turns a freshness band into anything is only allowed to do it through the
+ rules. This is the half that catches a FOUR-state copy — one that never spells the fifth word and
+ so slips past Part A, which is precisely what the sidebar dot was. */
+ var classifiers = ScannedSources()
+ .Where(f => f.Value.Contains("ClassifyFreshness(", StringComparison.Ordinal))
+ .ToList();
+
+ var expected = new[]
+ {
+ Path.Combine("PerformanceMonitor.Common", "ServerHealthBands.cs"),
+ Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs"),
+ Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.Overview.cs"),
+ Path.Combine("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingFleetReader.cs"),
+ Path.Combine("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"),
+ };
+
+ /* The scan is looking at something. A rename that emptied it would otherwise make every assertion
+ below vacuously true, which is the failure mode a coverage check has and a test must not. */
+ foreach (var relative in expected)
+ {
+ Assert.Contains(classifiers, f => f.Key.EndsWith(relative, StringComparison.Ordinal));
+ }
+
+ var offenders = new List();
+ foreach (var (path, source) in classifiers)
+ {
+ if (PathsEqual(path, rulesFile))
+ {
+ continue;
+ }
+
+ foreach (var word in forbidden)
+ {
+ if (source.Contains(word, StringComparison.Ordinal))
+ {
+ offenders.Add($"{path} spells {word}");
+ }
+ }
+ }
+
+ Assert.True(offenders.Count == 0,
+ "a status word is written somewhere that classifies freshness, so two surfaces can disagree again: "
+ + string.Join("; ", offenders));
+
+ /* And they are written in the one function every surface renders. */
+ var rules = ReadRepoFile(Path.Combine("PerformanceMonitor.Common", "ServerHealthBands.cs"));
+ Assert.Contains("public static string Word(this ServerCollectionStatus status) => status switch", rules, StringComparison.Ordinal);
+ Assert.Contains("public static string McpToken(this ServerCollectionStatus status) => status switch", rules, StringComparison.Ordinal);
+ Assert.Contains("public static string Headline(this ServerCollectionStatus status) => status switch", rules, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The MCP token vocabulary is published to clients, so it stays spelled the way it shipped — but it is a
+ /// rendering of the same decision, not a second ladder with its own thresholds. list_servers
+ /// carried its own 2-minute and 15-minute constants until #2473, which meant
+ /// could move and the tool would go on answering with the old ones.
+ ///
+ [Fact]
+ public void TheMcpTokens_RenderTheSameLadder_WithoutTheirOwnThresholds()
+ {
+ Assert.Equal("Online", ServerCollectionStatus.Online.McpToken());
+ Assert.Equal("Warning", ServerCollectionStatus.Stale.McpToken());
+ Assert.Equal("Offline", ServerCollectionStatus.Offline.McpToken());
+ Assert.Equal("AwaitingFirstCollection", ServerCollectionStatus.AwaitingFirstCollection.McpToken());
+ Assert.Equal("Unknown", ServerCollectionStatus.Unknown.McpToken());
+
+ /* One arm differs from Word(), and only one. The rest being identical is what makes the difference
+ legible as a decision rather than as drift. */
+ var differing = Enum.GetValues()
+ .Where(s => !string.Equals(s.Word(), s.McpToken(), StringComparison.Ordinal))
+ .ToList();
+ Assert.Equal(new[] { ServerCollectionStatus.AwaitingFirstCollection }, differing);
+
+ var tools = ReadRepoFile(Path.Combine(
+ "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"));
+ Assert.DoesNotContain("TimeSpan.FromMinutes(2)", tools, StringComparison.Ordinal);
+ Assert.DoesNotContain("TimeSpan.FromMinutes(15)", tools, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Every freshness band maps to exactly one state, and the flags a surface binds are the same flags that
+ /// state was derived from. Composed rather than switched twice on purpose — #2470's own correction was
+ /// that a second switch is a second ladder even when it returns the right type.
+ ///
+ [Fact]
+ public void EveryFreshnessBand_MapsToOneStateAndOneFlagTriple()
+ {
+ var expected = new Dictionary
+ {
+ [ServerFreshness.Fresh] = ServerCollectionStatus.Online,
+ [ServerFreshness.Stale] = ServerCollectionStatus.Stale,
+ [ServerFreshness.Offline] = ServerCollectionStatus.Offline,
+ [ServerFreshness.NeverCollected] = ServerCollectionStatus.AwaitingFirstCollection,
+ };
+
+ foreach (var band in Enum.GetValues())
+ {
+ var flags = ServerCollectionStatusRules.FlagsFor(band);
+ var viaFlags = ServerCollectionStatusRules.Classify(flags.IsOnline, flags.HasCollectorErrors, flags.AwaitingFirstCollection);
+
+ Assert.Equal(expected[band], ServerCollectionStatusRules.FromFreshness(band));
+ Assert.Equal(expected[band], viaFlags);
+ }
+
+ /* Unknown is not reachable from a band, which is why it is a named state rather than a fall-through:
+ a row that has been constructed but never refreshed is in it. */
+ Assert.DoesNotContain(ServerCollectionStatus.Unknown, expected.Values);
+ Assert.Equal(ServerCollectionStatus.Unknown, ServerCollectionStatusRules.Classify(null, false, false));
+ }
+
+ ///
+ /// The guard runs on the changes it guards. #2471 found a pin in Lite.Tests that CI skipped for three of
+ /// the trees it scanned, which is a guard that has silently stopped guarding — the same failure one layer
+ /// out. This suite reads PerformanceMonitor.Common as well as both Darling trees, so the step that
+ /// runs it has to fire on core as well as darling.
+ ///
+ [Fact]
+ public void TheGuard_RunsOnEveryTreeItScans()
+ {
+ var workflow = ReadRepoFile(Path.Combine(".github", "workflows", "build.yml"));
+
+ var step = workflow.IndexOf("- name: Run Darling tests", StringComparison.Ordinal);
+ Assert.True(step > 0, "the step that runs Darling.Tests was renamed — re-point this assertion before editing it");
+
+ var gate = workflow[step..workflow.IndexOf("run:", step, StringComparison.Ordinal)];
+ Assert.Contains("steps.filter.outputs.darling == 'true'", gate, StringComparison.Ordinal);
+ Assert.Contains("steps.filter.outputs.core == 'true'", gate, StringComparison.Ordinal);
+
+ /* And the two filters really do cover the trees this file walks. */
+ Assert.Contains("- 'PerformanceMonitor.Common/**/!(*.md)'", workflow, StringComparison.Ordinal);
+ Assert.Contains("- 'Darling/**/!(*.md)'", workflow, StringComparison.Ordinal);
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
+
+ /// A card in a given state, built from the flags rather than from a clock, so the palette read
+ /// off it covers the states ApplyFreshness cannot reach as well as the ones it can.
+ private static ServerSummaryItem CardWith(ServerCollectionStatus status)
+ {
+ var flags = status switch
+ {
+ ServerCollectionStatus.Online => new ServerCollectionFlags(true, false, false),
+ ServerCollectionStatus.Stale => new ServerCollectionFlags(true, true, false),
+ ServerCollectionStatus.Offline => new ServerCollectionFlags(false, false, false),
+ ServerCollectionStatus.AwaitingFirstCollection => new ServerCollectionFlags(null, false, true),
+ _ => new ServerCollectionFlags(null, false, false),
+ };
+
+ var card = new ServerSummaryItem
+ {
+ ServerName = "SQL2022",
+ ServerId = 1,
+ IsOnline = flags.IsOnline,
+ HasCollectorErrors = flags.HasCollectorErrors,
+ AwaitingFirstCollection = flags.AwaitingFirstCollection,
+ };
+
+ Assert.Equal(status, card.CardStatus);
+ return card;
+ }
+
+ /// The sidebar dot's DataTrigger values mapped to the brush key each one sets, read out of the
+ /// Ellipse that actually owns them rather than out of the whole file (MainWindow.xaml has other dots).
+ private static Dictionary SidebarDotTriggers()
+ {
+ var xaml = ReadRepoFile(Path.Combine("Darling", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml"));
+
+ var at = xaml.IndexOf("{Binding Server.DotStatus}", StringComparison.Ordinal);
+ Assert.True(at > 0, "the sidebar status dot is gone — find where it moved before editing this test");
+
+ var open = xaml.LastIndexOf("", open, StringComparison.Ordinal);
+ Assert.True(open > 0 && close > open, "the sidebar status dot is no longer a closed Ellipse element");
+
+ var element = xaml[open..close];
+ var triggers = new Dictionary(StringComparer.Ordinal);
+
+ const string marker = "= 0; i = element.IndexOf(marker, i + 1, StringComparison.Ordinal))
+ {
+ var valueStart = i + marker.Length;
+ var word = element[valueStart..element.IndexOf('"', valueStart)];
+
+ const string setter = "Value=\"{DynamicResource ";
+ var setterAt = element.IndexOf(setter, valueStart, StringComparison.Ordinal);
+ Assert.True(setterAt > 0, $"the '{word}' trigger sets no brush");
+ var keyStart = setterAt + setter.Length;
+ triggers[word] = element[keyStart..element.IndexOf('}', keyStart)];
+ }
+
+ return triggers;
+ }
+
+ /// Every C# source file in the trees that can hold a copy of the ladder, with comments removed.
+ private static IEnumerable> ScannedSources()
+ {
+ var roots = new[]
+ {
+ Path.Combine(RepoRoot(), "PerformanceMonitor.Common"),
+ Path.Combine(RepoRoot(), "Darling", "PerformanceMonitor.Darling.Viewer"),
+ Path.Combine(RepoRoot(), "Darling", "PerformanceMonitor.Darling.Service"),
+ };
+
+ foreach (var root in roots)
+ {
+ Assert.True(Directory.Exists(root), $"{root} is gone — this scan is walking nothing");
+
+ foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
+ {
+ if (file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
+ || file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return new KeyValuePair(file, StripComments(File.ReadAllText(file)));
+ }
+ }
+ }
+
+ private static bool PathsEqual(string a, string b) =>
+ string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Drops comments while leaving every string literal intact. A line-prefix filter is not enough here and
+ /// that is not hypothetical: the files this scan walks put block comments mid-file, put prose after code
+ /// on the same line, and quote all five status words inside doc comments that are entirely legitimate.
+ /// Handles regular, verbatim and raw string literals plus char literals, because a scan that mangles a
+ /// literal is a scan that can miss the copy it exists to find.
+ ///
+ private static string StripComments(string source)
+ {
+ var kept = new StringBuilder(source.Length);
+ var i = 0;
+
+ while (i < source.Length)
+ {
+ var c = source[i];
+
+ if (c == '/' && i + 1 < source.Length && source[i + 1] == '/')
+ {
+ while (i < source.Length && source[i] != '\n') i++;
+ continue;
+ }
+
+ if (c == '/' && i + 1 < source.Length && source[i + 1] == '*')
+ {
+ i += 2;
+ while (i + 1 < source.Length && !(source[i] == '*' && source[i + 1] == '/')) i++;
+ i = Math.Min(source.Length, i + 2);
+ kept.Append(' ');
+ continue;
+ }
+
+ if (c == '@' && i + 1 < source.Length && source[i + 1] == '"')
+ {
+ kept.Append(source[i]).Append(source[i + 1]);
+ i += 2;
+ while (i < source.Length)
+ {
+ if (source[i] == '"' && i + 1 < source.Length && source[i + 1] == '"')
+ {
+ kept.Append(source[i]).Append(source[i + 1]);
+ i += 2;
+ continue;
+ }
+
+ kept.Append(source[i]);
+ if (source[i++] == '"') break;
+ }
+
+ continue;
+ }
+
+ if (c == '"' && i + 2 < source.Length && source[i + 1] == '"' && source[i + 2] == '"')
+ {
+ var fence = 0;
+ while (i + fence < source.Length && source[i + fence] == '"') fence++;
+ kept.Append(source, i, fence);
+ i += fence;
+
+ while (i < source.Length)
+ {
+ if (source[i] == '"')
+ {
+ var run = 0;
+ while (i + run < source.Length && source[i + run] == '"') run++;
+ kept.Append(source, i, run);
+ i += run;
+ if (run >= fence) break;
+ continue;
+ }
+
+ kept.Append(source[i++]);
+ }
+
+ continue;
+ }
+
+ if (c == '"' || c == '\'')
+ {
+ var quote = c;
+ kept.Append(source[i++]);
+ while (i < source.Length)
+ {
+ if (source[i] == '\\' && i + 1 < source.Length)
+ {
+ kept.Append(source[i]).Append(source[i + 1]);
+ i += 2;
+ continue;
+ }
+
+ kept.Append(source[i]);
+ if (source[i++] == quote) break;
+ }
+
+ continue;
+ }
+
+ kept.Append(source[i++]);
+ }
+
+ return kept.ToString();
+ }
+
+ private static string RepoRoot([CallerFilePath] string thisFile = "")
+ {
+ for (var dir = new DirectoryInfo(Path.GetDirectoryName(thisFile)!); dir is not null; dir = dir.Parent)
+ {
+ if (Directory.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.Common")))
+ {
+ return dir.FullName;
+ }
+ }
+
+ throw new DirectoryNotFoundException($"Could not locate the repo root walking up from {thisFile}");
+ }
+
+ private static string ReadRepoFile(string relative) =>
+ File.ReadAllText(Path.Combine(RepoRoot(), relative)).Replace("\r\n", "\n", StringComparison.Ordinal);
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
index 85ddfe5b30..2e13eeea28 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
@@ -317,23 +317,14 @@ private static FleetServerCard BuildCard(
FailedCollectorCount = collectors.Failing,
};
- /* Freshness -> the card's connection state, exactly as the WPF card's ApplyFreshness. */
+ /* Freshness -> the card's collection state, through the SAME mapping the WPF card and the sidebar
+ row use (#2473). It was a hand-written copy of ApplyFreshness that happened to agree; the copy on
+ the sidebar row happened not to, which is the argument for none of them writing it out. */
var freshness = ServerHealthClassifier.ClassifyFreshness(lastCollection, now);
- bool? isOnline;
- bool awaitingFirstCollection;
- bool hasCollectorErrors;
- if (freshness == ServerFreshness.NeverCollected)
- {
- isOnline = null;
- awaitingFirstCollection = true;
- hasCollectorErrors = false;
- }
- else
- {
- isOnline = freshness != ServerFreshness.Offline;
- awaitingFirstCollection = false;
- hasCollectorErrors = freshness == ServerFreshness.Stale;
- }
+ var flags = ServerCollectionStatusRules.FlagsFor(freshness);
+ var isOnline = flags.IsOnline;
+ var awaitingFirstCollection = flags.AwaitingFirstCollection;
+ var hasCollectorErrors = flags.HasCollectorErrors;
var overall = ServerHealthClassifier.OverallMetricSeverity(metrics);
var band = ServerHealthClassifier.ClassifyBand(isOnline, awaitingFirstCollection, hasCollectorErrors, overall);
@@ -478,7 +469,9 @@ private static string BuildReason(FleetServerCard c)
if (c.AwaitingFirstCollection)
{
- return "Awaiting first collection";
+ /* The word itself, not a copy of it — this was one of five spellings of the phrase across four
+ files, which is the duplication #2473's pin now forbids. */
+ return ServerCollectionStatus.AwaitingFirstCollection.Word();
}
var parts = new List();
@@ -523,13 +516,11 @@ private static string BuildReason(FleetServerCard c)
return parts.Count > 0 ? string.Join(", ", parts) : "Needs attention";
}
- private static string StatusLabel(bool? isOnline, bool awaitingFirstCollection, bool hasCollectorErrors) => isOnline switch
- {
- true when hasCollectorErrors => "Warning",
- true => "Online",
- false => "Offline",
- _ => awaitingFirstCollection ? "Awaiting first collection" : "Unknown",
- };
+ /// The card's status word. Delegates to the one ladder every Darling surface renders (#2473):
+ /// this file's own copy agreed with the WPF card, but the WPF sidebar row's copy did not, and three
+ /// agreeing copies plus one that does not is still four places where the answer is decided.
+ private static string StatusLabel(bool? isOnline, bool awaitingFirstCollection, bool hasCollectorErrors) =>
+ ServerCollectionStatusRules.Classify(isOnline, hasCollectorErrors, awaitingFirstCollection).Word();
///
/// Classifies a server's raw SERVERPROPERTY('EngineEdition') into the RELIABLE per-server platform flags the
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
index 0b6ab3d9f6..706323e392 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -998,26 +998,22 @@ public static async Task GetServerProperties(
/* ─────────────────────────── list_servers helpers ─────────────────────────── */
- /// Older than twice the ~1-minute collector cadence = the collection has visibly lagged.
- private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(2);
-
- /// Older than this (or no collection at all) = the server is treated as Offline.
- private static readonly TimeSpan OfflineThreshold = TimeSpan.FromMinutes(15);
-
///
- /// The freshness-derived status the headless viewer's cards use (ServerSummaryItem.ClassifyFreshness):
- /// Fresh → Online, Stale → Warning, long-dead → Offline, never-collected → AwaitingFirstCollection
- /// (the service hasn't reached the server yet — a bootstrap state, not an outage; additive status
- /// value, existing values unchanged). Both instants are UTC.
+ /// The freshness-derived status this tool reports: Fresh → Online, Stale → Warning, long-dead → Offline,
+ /// never-collected → AwaitingFirstCollection (the service hasn't reached the server yet — a bootstrap
+ /// state, not an outage). Both instants are UTC.
+ ///
+ /// It used to classify freshness itself, against its OWN copies of the 2-minute and 15-minute
+ /// thresholds — so ServerHealthThresholds could move and list_servers would silently keep
+ /// answering with the old numbers. It now shares the ladder with every other status surface (#2473). What
+ /// it does NOT share is the vocabulary: spells the
+ /// never-collected state as one word because that value was published to MCP clients, and a status value
+ /// a client keys on is a consumer API.
///
- private static string FreshnessStatus(DateTime? lastCollectionUtc, DateTime nowUtc)
- {
- if (!lastCollectionUtc.HasValue) return "AwaitingFirstCollection";
- var age = nowUtc - lastCollectionUtc.Value;
- if (age > OfflineThreshold) return "Offline";
- if (age > StaleThreshold) return "Warning";
- return "Online";
- }
+ private static string FreshnessStatus(DateTime? lastCollectionUtc, DateTime nowUtc) =>
+ ServerCollectionStatusRules
+ .FromFreshness(ServerHealthClassifier.ClassifyFreshness(lastCollectionUtc, nowUtc))
+ .McpToken();
/// Product-name label for a sql_major_version (the viewer's SqlVersionLabel); 2016+ is
/// what the product supports, older/unknown majors fall back to a bare version tag, null to empty.
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml
index 5cf0910cd0..6dd64420aa 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml
@@ -518,11 +518,27 @@
+ (#2031: a silenced server must not look healthy-quiet).
+
+ #2473: the dot renders the same ServerCollectionStatus the Overview
+ card does, and carries the same first line the card's tooltip opens
+ on. It had triggers for three of the five states, so a registered-
+ but-never-collected server fell through to the muted default and
+ went grey "Unknown" while the card said amber "Awaiting first
+ collection" about that same server, one panel over.
+
+ There is deliberately still NO trigger for "Unknown": grey is the
+ honest paint for a server whose freshness was never classified, and
+ it is the muted default precisely so it needs no arm. Every OTHER
+ member of the enum must have one, which is asserted rather than
+ commented (ViewerSidebarDotRendersTheCardStatusTests) — a missing
+ trigger paints a wrong colour and fails nothing, which is exactly
+ how this defect survived. -->
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
index 857967fcdf..2ce4d56f18 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
@@ -340,8 +340,8 @@ public static FleetHealthBand ClassifyBand(ServerSummaryItem s) =>
s.IsOnline,
/* Via the card's discriminant, not the raw flag. The shared classifier honours an awaiting marker
whatever IsOnline says, so an online card carrying a stray marker banded Warning while the card
- said "Online" and had nothing to report — a third reading of the same pair. See ServerCardStatus. */
- s.CardStatus == ServerCardStatus.AwaitingFirstCollection,
+ said "Online" and had nothing to report — a third reading of the same pair. See ServerCollectionStatus. */
+ s.CardStatus == ServerCollectionStatus.AwaitingFirstCollection,
s.HasCollectorErrors,
s.OverallMetricSeverity);
@@ -363,15 +363,17 @@ public static string BuildReason(ServerSummaryItem s)
{
/* Keyed on the card's own status discriminant rather than on the flags behind it. Reading
AwaitingFirstCollection independently of IsOnline is what let an online card claim it was awaiting
- its first collection — see ServerCardStatus. */
- if (s.CardStatus == ServerCardStatus.Offline)
+ its first collection — see ServerCollectionStatus. */
+ if (s.CardStatus == ServerCollectionStatus.Offline)
{
return "Offline — no recent collection";
}
- if (s.CardStatus == ServerCardStatus.AwaitingFirstCollection)
+ if (s.CardStatus == ServerCollectionStatus.AwaitingFirstCollection)
{
- return "Awaiting first collection";
+ /* The word itself, not a copy of it — this line held the fourth spelling of the phrase, in the
+ fourth file, which is exactly the shape the #2473 pin now forbids. */
+ return s.CardStatus.Word();
}
var parts = new List();
@@ -456,13 +458,13 @@ private static string Headline(ServerSummaryItem s)
/* Offline and never-reached come back from BuildReason as whole sentences that already name the
state — the same sentence the status word shows — so a band label in front would only say
"Offline" twice. */
- ServerCardStatus.Offline or ServerCardStatus.AwaitingFirstCollection => BuildReason(s),
+ ServerCollectionStatus.Offline or ServerCollectionStatus.AwaitingFirstCollection => BuildReason(s),
/* "Unknown" is the one status word with no band behind it: ClassifyBand goes straight to the
metrics and, on a clean card, answers Healthy. The word wins, and the metrics are appended when
they have something to add — not knowing whether a server is reporting is no reason to withhold
the CPU number that WAS collected. */
- ServerCardStatus.Unknown => WithReason(UnknownStatus, "; ", s),
+ ServerCollectionStatus.Unknown => WithReason(UnknownStatus, "; ", s),
/* Online and stale: the band is the headline. A healthy card gets an all-clear rather than
BuildReason's "Needs attention" fallback, which is written for a ranking that only ever holds
@@ -481,7 +483,7 @@ problem servers and on a grid showing EVERY server would say the opposite of the
/// event count, for one. Appending unguarded produces "Warning — Needs attention", which tells the reader
/// exactly what they already knew and is how the ranking-only fallback reaches a card at all. Two arms had
/// their own copy of the append and only one of them was guarded, which is the same lesson as
- /// one level down.
+ /// one level down.
///
private static string WithReason(string headline, string separator, ServerSummaryItem s)
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
index 6c113766ee..ec2f8a1e2f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
@@ -344,40 +344,15 @@ number alongside (Lite's headline). */
instantUtc.HasValue ? Math.Max(0, (int)(nowUtc - instantUtc.Value).TotalMinutes) : null;
}
-///
-/// The Overview card's status-line state, as a VALUE rather than a rendered string. The word on the card
-/// (), the colour it is painted
-/// (), the reason the ranking shows
-/// () and the sentence in the card's tooltip
-/// () are all renderings OF THIS.
-///
-/// That is the entire reason it exists. Those four used to read the
-/// (IsOnline, AwaitingFirstCollection) flag pair independently, and two review passes on #2429
-/// each turned up a DIFFERENT combination where two of the readings contradicted each other — first
-/// IsOnline null with no awaiting marker, then IsOnline true WITH one, which would have put a
-/// green "Online" over a tooltip reading "Awaiting first collection". Both flags are plain settable
-/// properties, so nothing stops a fixture or a new data path reaching either pair. Two instances of one
-/// category is the signal to fix the category: with a single discriminant there is no combination left for
-/// the renderings to disagree about, because they no longer each decide.
-///
-public enum ServerCardStatus
-{
- /// Collection is current.
- Online,
-
- /// Online, but the newest collection has lagged — the card's amber "Warning".
- Stale,
+/* The card's status discriminant used to be declared here, as ServerCardStatus. It is now
+ PerformanceMonitor.Common's ServerCollectionStatus, because three OTHER places derived the same ladder and
+ two of them are in the headless service, which cannot reference WPF (#2473). The argument for having one
+ discriminant at all is unchanged and is written down on the enum; what changed is how far "one" reaches.
- /// Nothing has landed for long enough to call the server dark.
- Offline,
-
- /// Registered but never collected — the service has not reached it yet, not a dead server.
- AwaitingFirstCollection,
-
- /// Freshness was never classified. cannot produce
- /// this; a hand-built summary can, which is exactly why it is a named state rather than a fall-through.
- Unknown,
-}
+ The rename is not cosmetic. Lite has its own ServerCardStatus meaning a CONNECTION check, and #2457 kept
+ the two axes apart on purpose; giving the shared type the collection name (beside Common's existing
+ ServerConnectionStatus) means the two can no longer be confused for each other by a reader or by a
+ using directive. */
///
/// One Overview server card's view-model — copied from Lite's ServerSummaryItem
@@ -594,36 +569,31 @@ public string ThreadsDisplay
? ViewerTimeHelper.ForDisplay(LastCollectionTime.Value).ToString("HH:mm:ss")
: "Never";
- /* Connection status — verbatim from Lite; in the viewer the inputs come from ApplyFreshness.
- The null arm distinguishes "not reached yet" (bootstrap) from a legacy unknown.
-
- This is the ONE place the (IsOnline, AwaitingFirstCollection) pair is read. Everything downstream —
- the word, the colour, the ranking's reason, the tooltip — renders the resulting ServerCardStatus, so
- no two of them can land on different answers for the same card. See ServerCardStatus for the two
- contradictions that motivated collapsing it. */
- public ServerCardStatus CardStatus => IsOnline switch
- {
- true when HasCollectorErrors => ServerCardStatus.Stale,
- true => ServerCardStatus.Online,
- false => ServerCardStatus.Offline,
- _ => AwaitingFirstCollection ? ServerCardStatus.AwaitingFirstCollection : ServerCardStatus.Unknown,
- };
-
- public string StatusDisplay => CardStatus switch
- {
- ServerCardStatus.Stale => "Warning",
- ServerCardStatus.Online => "Online",
- ServerCardStatus.Offline => "Offline",
- ServerCardStatus.AwaitingFirstCollection => "Awaiting first collection",
- _ => "Unknown",
- };
-
+ /* Collection status. The (IsOnline, HasCollectorErrors, AwaitingFirstCollection) triple is resolved by
+ ServerCollectionStatusRules.Classify and nowhere else in the viewer — the sidebar row's dot carried its
+ own four-state copy until #2473, which is how a never-collected server got a grey "Unknown" dot beside
+ this card's amber "Awaiting first collection". Everything downstream — the word, the colour, the
+ ranking's reason, the tooltip, the sidebar dot — renders the resulting ServerCollectionStatus, so no
+ two of them can land on different answers for the same server. See ServerCollectionStatus for the
+ contradictions that motivated collapsing it.
+
+ The null arm distinguishes "not reached yet" (bootstrap) from a legacy unknown. */
+ public ServerCollectionStatus CardStatus =>
+ ServerCollectionStatusRules.Classify(IsOnline, HasCollectorErrors, AwaitingFirstCollection);
+
+ public string StatusDisplay => CardStatus.Word();
+
+ /* The palette stays here rather than moving to the rules class: these are the viewer's dark-theme hexes,
+ and the sidebar dot paints the same states from the THEME dictionaries instead (a DynamicResource, so
+ it follows the light / cool-breeze themes the cards do not). The states agree; only the colour source
+ differs, and ViewerSidebarDotRendersTheCardStatusTests pins that every state the card paints has a
+ trigger on the dot. */
public SolidColorBrush StatusBrush => MakeBrush(CardStatus switch
{
- ServerCardStatus.Stale => "#FFD54F", // amber — stale collection
- ServerCardStatus.Online => "#81C784",
- ServerCardStatus.Offline => "#E57373",
- ServerCardStatus.AwaitingFirstCollection => "#FFD54F", // amber — queued, not dead
+ ServerCollectionStatus.Stale => "#FFD54F", // amber — stale collection
+ ServerCollectionStatus.Online => "#81C784",
+ ServerCollectionStatus.Offline => "#E57373",
+ ServerCollectionStatus.AwaitingFirstCollection => "#FFD54F", // amber — queued, not dead
_ => "#888888",
});
@@ -725,25 +695,22 @@ public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, Dat
ServerHealthClassifier.ClassifyFreshness(lastCollectionUtc, nowUtc);
///
- /// Maps the freshness band onto Lite's card inputs, taking the live-ping's place: Fresh → Online,
- /// Stale → the amber Warning state, Offline → the red Offline overlay, NeverCollected → the amber
- /// "Awaiting first collection" state (IsOnline stays null: the truth is "unknown, not reached yet",
- /// not "was up and died").
+ /// Maps the freshness band onto the card's three status flags, taking the live-ping's place: Fresh →
+ /// Online, Stale → the amber Warning state, Offline → the red Offline overlay, NeverCollected → the amber
+ /// "Awaiting first collection" state (IsOnline stays null: the truth is "unknown, not reached yet", not
+ /// "was up and died").
+ ///
+ /// The mapping itself is , shared with the sidebar
+ /// row and the service's fleet reader. It was written out here in longhand, and the sidebar's longhand
+ /// copy set two of the three flags and dropped AwaitingFirstCollection — an omission that is
+ /// invisible in a block of assignments and impossible when the three arrive together (#2473).
///
public void ApplyFreshness(DateTime nowUtc)
{
- var freshness = ClassifyFreshness(LastCollectionTime, nowUtc);
- if (freshness == ServerFreshness.NeverCollected)
- {
- IsOnline = null;
- HasCollectorErrors = false;
- AwaitingFirstCollection = true;
- return;
- }
-
- AwaitingFirstCollection = false;
- IsOnline = freshness != ServerFreshness.Offline;
- HasCollectorErrors = freshness == ServerFreshness.Stale;
+ var flags = ServerCollectionStatusRules.FlagsFor(ClassifyFreshness(LastCollectionTime, nowUtc));
+ IsOnline = flags.IsOnline;
+ HasCollectorErrors = flags.HasCollectorErrors;
+ AwaitingFirstCollection = flags.AwaitingFirstCollection;
}
private static SolidColorBrush SeverityBrush(HealthSeverity severity) => severity switch
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
index 48763ddb57..1ea1d0b712 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
@@ -23,8 +23,9 @@ namespace PerformanceMonitor.Darling.Viewer;
/// One row of the servers table, as the viewer's server list shows it. Was a positional record; it is now
/// a class because the ported Lite server-row chrome needs mutable, change-notifying runtime state on each
/// row — the favorite star (, matched from the viewer's registry) and the
-/// collection-freshness status dot ( / →
-/// ) update in place on the refresh timers without resetting the list's selection.
+/// collection-freshness status dot ( / /
+/// → → ) update in
+/// place on the refresh timers without resetting the list's selection.
/// The Postgres-sourced fields stay immutable (get-only); only the sidebar overlay state is settable.
/// Equality is now reference-based, which every consumer already relies on (they key on
/// or hold the list's own instances).
@@ -87,7 +88,7 @@ public bool? IsOnline
{
_isOnline = value;
OnPropertyChanged(nameof(IsOnline));
- OnPropertyChanged(nameof(DotStatus));
+ RaiseDotChanged();
}
}
}
@@ -104,34 +105,103 @@ public bool HasCollectorErrors
{
_hasCollectorErrors = value;
OnPropertyChanged(nameof(HasCollectorErrors));
- OnPropertyChanged(nameof(DotStatus));
+ RaiseDotChanged();
}
}
}
+ private bool _awaitingFirstCollection;
+
///
- /// Sidebar status-dot vocabulary — "Online"/"Warning"/"Offline"/"Unknown", identical to Lite's
- /// ServerConnection.DotStatus so the ported server-row DataTriggers colour the Ellipse the same way.
+ /// True when no collection has EVER landed for this server: the service hasn't reached it yet — a
+ /// registered-but-queued server during bootstrap, not a dead one. The row had no such flag until #2473,
+ /// which is precisely why its dot went grey "Unknown" while the Overview card one panel over said amber
+ /// "Awaiting first collection" about the same server, off the same freshness call.
///
- public string DotStatus => IsOnline switch
+ public bool AwaitingFirstCollection
{
- true => HasCollectorErrors ? "Warning" : "Online",
- false => "Offline",
- _ => "Unknown"
- };
+ get => _awaitingFirstCollection;
+ set
+ {
+ if (_awaitingFirstCollection != value)
+ {
+ _awaitingFirstCollection = value;
+ OnPropertyChanged(nameof(AwaitingFirstCollection));
+ RaiseDotChanged();
+ }
+ }
+ }
+
+ ///
+ /// The sidebar row's status, as a VALUE — the same the Overview card
+ /// renders (#2473). This row used to derive its own copy of the status ladder from the same flags, on a
+ /// different type, on a different surface, and with only four of the five states: the sidebar and the card
+ /// could therefore say different things about one server, and for a never-collected server they did. Both
+ /// now read , which is the collapse #2429 argued for —
+ /// with one discriminant there is no flag combination left for the renderings to disagree about.
+ ///
+ public ServerCollectionStatus CardStatus =>
+ ServerCollectionStatusRules.Classify(IsOnline, HasCollectorErrors, AwaitingFirstCollection);
+
+ ///
+ /// Sidebar status-dot vocabulary — the SAME words the Overview card's StatusDisplay shows, because
+ /// both render . They are also the DataTrigger values MainWindow.xaml keys
+ /// the Ellipse fill off, so a state with no trigger falls through to the muted grey default rather than
+ /// failing anything — which is how "Awaiting first collection" was silently grey. The trigger set is
+ /// pinned against the enum in ViewerSidebarDotRendersTheCardStatusTests.
+ ///
+ public string DotStatus => CardStatus.Word();
+
+ ///
+ /// What the dot means, for the ToolTip the sidebar Ellipse carries — the answer to #2422 one surface over,
+ /// and the thing that makes an amber dot legible at all. The card's amber covers two states (a stale
+ /// collection and a never-collected server) and the card disambiguates them with a WORD; a dot has no room
+ /// for one, so the tooltip does that job instead. The first line is
+ /// , word-for-word what the Overview card says for the
+ /// same state.
+ ///
+ /// The second line names the axis. In Darling every one of these words is a COLLECTION answer —
+ /// there is no live ping to a monitored server — which is the opposite of Lite, where the identically
+ /// coloured dot reports a connection check. A reader who uses both should not have to infer which. The
+ /// third names the gesture THIS surface supports: ServerList_MouseDoubleClick opens the tab, and a
+ /// single click only selects, so naming one would be naming a no-op.
+ ///
+ public string DotTooltip => string.Join(
+ "\n",
+ CardStatus.Headline(),
+ "Darling has no live ping: this is how old the newest collection is, not a connection check.",
+ "Double-click the row to open this server's tab");
+
+ /// Raises the change notifications for everything derived from the three status flags. One
+ /// helper rather than three call sites per setter: was added after
+ /// , and a derived member that a setter forgets to announce is a dot that stops
+ /// updating in place — silent, and only visible on a list refresh.
+ private void RaiseDotChanged()
+ {
+ OnPropertyChanged(nameof(CardStatus));
+ OnPropertyChanged(nameof(DotStatus));
+ OnPropertyChanged(nameof(DotTooltip));
+ }
///
/// Sets the dot from the same collection-freshness classification the Overview cards use
- /// (): Fresh → Online, Stale → the amber Warning,
- /// Offline → red, NeverCollected → the grey Unknown dot (the service hasn't reached the server yet —
- /// during a fleet bootstrap that is "queued", not "dead"). Both instants are UTC (the store is naive
- /// UTC; nowUtc is ).
+ /// (), through the same
+ /// mapping: Fresh → Online, Stale → the amber Warning,
+ /// Offline → red, NeverCollected → the amber "Awaiting first collection" (the service hasn't reached the
+ /// server yet — during a fleet bootstrap that is "queued", not "dead"). Both instants are UTC (the store
+ /// is naive UTC; nowUtc is ).
+ ///
+ /// This method used to set two flags out of three by hand and drop the awaiting marker on the floor.
+ /// Nothing about a block of assignments makes a missing one visible, which is why the flags now arrive as
+ /// a single value (#2473).
///
public void ApplyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc)
{
- var freshness = ServerSummaryItem.ClassifyFreshness(lastCollectionUtc, nowUtc);
- IsOnline = freshness == ServerFreshness.NeverCollected ? null : freshness != ServerFreshness.Offline;
- HasCollectorErrors = freshness == ServerFreshness.Stale;
+ var flags = ServerCollectionStatusRules.FlagsFor(
+ ServerSummaryItem.ClassifyFreshness(lastCollectionUtc, nowUtc));
+ IsOnline = flags.IsOnline;
+ HasCollectorErrors = flags.HasCollectorErrors;
+ AwaitingFirstCollection = flags.AwaitingFirstCollection;
}
// ── Per-server alert "needs attention" badge state (from the polled alert history, ack-aware) ──
diff --git a/PerformanceMonitor.Common/ServerHealthBands.cs b/PerformanceMonitor.Common/ServerHealthBands.cs
index 6db8ef17c8..d28acfe694 100644
--- a/PerformanceMonitor.Common/ServerHealthBands.cs
+++ b/PerformanceMonitor.Common/ServerHealthBands.cs
@@ -37,6 +37,166 @@ public enum ServerFreshness
NeverCollected,
}
+ ///
+ /// One server's COLLECTION status, as a value rather than a rendered string — the discriminant every
+ /// Darling surface that reports "is this server reporting" renders. The Overview card's word and colour,
+ /// the sidebar row's dot, the fleet roll-up's label and the list_servers MCP status are all
+ /// renderings OF THIS.
+ ///
+ /// Why it lives here and not in the viewer. It used to be a viewer-local enum, and three
+ /// other places wrote their own copy of the same ladder anyway — one of them (the sidebar dot) had only
+ /// four of the five states, so a registered-but-never-collected server got a grey "Unknown" dot beside an
+ /// amber "Awaiting first collection" card, on the same screen, from the same
+ /// call (#2473). Two of the copies live in the
+ /// headless service, which cannot reference WPF, so the only place all four can render one ladder is
+ /// this assembly.
+ ///
+ /// Why the name is not "card status". Lite has a ServerCardStatus of its own and it
+ /// answers a DIFFERENT question: Lite's word comes from a live connection check, this one from how old
+ /// the newest collection is. #2457 turned down folding freshness into Lite's word precisely so the two
+ /// axes stay apart, and the same distinction already has a type here —
+ /// is the connection answer, this is the collection one.
+ /// Sharing a name across the two would have invited exactly the conflation both issues were about.
+ ///
+ public enum ServerCollectionStatus
+ {
+ /// Collection is current.
+ Online,
+
+ /// Online, but the newest collection has lagged — the amber "Warning".
+ Stale,
+
+ /// Nothing has landed for long enough to call the server dark.
+ Offline,
+
+ /// Registered but never collected — the service has not reached it yet, not a dead server.
+ AwaitingFirstCollection,
+
+ /// Freshness was never classified. cannot
+ /// produce this; a hand-built card can, which is exactly why it is a named state rather than a
+ /// fall-through.
+ Unknown,
+ }
+
+ ///
+ /// The three status flags a freshness band explodes into, as ONE value. Every surface that shows a server
+ /// carries these three as separate settable properties (WPF binds them individually — the offline overlay
+ /// reads IsOnline, the card border reads all three), so the flags cannot simply be replaced by the
+ /// discriminant. What they CAN be is derived in one place: the sidebar dot's own
+ /// ApplyFreshness set two of the three and silently dropped the third, which is the whole of #2473.
+ /// Returning them together is what makes dropping one a visible edit rather than an omission.
+ ///
+ /// Reachability: true = fresh or stale, false = offline, null = not reached yet.
+ /// The amber warning flag — in Darling, a stale collection.
+ /// No collection has EVER landed (a bootstrap state, not an outage).
+ public readonly record struct ServerCollectionFlags(
+ bool? IsOnline,
+ bool HasCollectorErrors,
+ bool AwaitingFirstCollection);
+
+ ///
+ /// The collection-status ladder, in ONE function, plus the three renderings of its result. Nothing else
+ /// in the product may turn a freshness band into a status word.
+ ///
+ /// The failure this exists to make impossible. Four places derived this ladder independently:
+ /// the WPF Overview card, the WPF sidebar dot, the web/MCP fleet roll-up, and list_servers. Three
+ /// agreed; the sidebar dot had no arm for a never-collected server and fell through to grey "Unknown"
+ /// while the card one panel over said amber "Awaiting first collection" (#2473). That is the same defect
+ /// #2429 spent two review rounds on, and its argument applies unchanged: with a single discriminant there
+ /// is no combination left for the renderings to disagree about, because they no longer each decide.
+ ///
+ /// Three renderings, not one, and that is deliberate. is what a human
+ /// reads on a card, a dot or a roll-up. is what an MCP client keys on — a
+ /// consumer API whose values were published as machine tokens and cannot be re-spelled without breaking
+ /// downstream automation. is the sentence a tooltip opens on. They are three
+ /// renderings of one decision; only the decision is shared, and only the decision needed to be.
+ ///
+ public static class ServerCollectionStatusRules
+ {
+ ///
+ /// The (IsOnline, HasCollectorErrors, AwaitingFirstCollection) triple, resolved.
+ /// The order matters and is the #2429 reading: an online server's flags win over an awaiting marker,
+ /// so a stale card cannot also claim to be awaiting its first collection.
+ ///
+ public static ServerCollectionStatus Classify(bool? isOnline, bool hasCollectorErrors, bool awaitingFirstCollection) =>
+ isOnline switch
+ {
+ true when hasCollectorErrors => ServerCollectionStatus.Stale,
+ true => ServerCollectionStatus.Online,
+ false => ServerCollectionStatus.Offline,
+ _ => awaitingFirstCollection ? ServerCollectionStatus.AwaitingFirstCollection : ServerCollectionStatus.Unknown,
+ };
+
+ ///
+ /// A freshness band exploded into the three flags every surface binds.
+ /// leaves IsOnline null on purpose: the truth is "unknown, not reached yet", not "was up and died",
+ /// and a red Offline on a merely-queued server is what sent a 24-server field report chasing a phantom
+ /// scheduler bug.
+ ///
+ public static ServerCollectionFlags FlagsFor(ServerFreshness freshness) => freshness switch
+ {
+ ServerFreshness.NeverCollected => new ServerCollectionFlags(null, false, true),
+ ServerFreshness.Offline => new ServerCollectionFlags(false, false, false),
+ ServerFreshness.Stale => new ServerCollectionFlags(true, true, false),
+ _ => new ServerCollectionFlags(true, false, false),
+ };
+
+ ///
+ /// Freshness straight to the discriminant, for the surfaces that carry no flags of their own. Composed
+ /// out of and rather than switching on the band again —
+ /// a second switch is a second ladder even when it returns the same type, which is the correction
+ /// #2470 had to make once already.
+ ///
+ public static ServerCollectionStatus FromFreshness(ServerFreshness freshness)
+ {
+ var flags = FlagsFor(freshness);
+ return Classify(flags.IsOnline, flags.HasCollectorErrors, flags.AwaitingFirstCollection);
+ }
+
+ /// The words a human reads. They are also the DataTrigger values the WPF sidebar keys
+ /// its dot colour off, so a word that stopped matching would silently fall through to the muted default
+ /// dot rather than fail anything — which is why the viewer pins the trigger set against this enum.
+ public static string Word(this ServerCollectionStatus status) => status switch
+ {
+ ServerCollectionStatus.Stale => "Warning",
+ ServerCollectionStatus.Online => "Online",
+ ServerCollectionStatus.Offline => "Offline",
+ ServerCollectionStatus.AwaitingFirstCollection => "Awaiting first collection",
+ _ => "Unknown",
+ };
+
+ ///
+ /// The token the MCP list_servers / get_server_status surface publishes. It differs from
+ /// in exactly one arm, and the difference is load-bearing rather than sloppy:
+ /// AwaitingFirstCollection shipped as a machine token beside the pre-existing values, and MCP
+ /// status values are a consumer API — clients key on them, so re-spelling one is a breaking change.
+ /// Keeping the two vocabularies next to each other is what stops the next reader "fixing" the
+ /// inconsistency.
+ ///
+ public static string McpToken(this ServerCollectionStatus status) => status switch
+ {
+ ServerCollectionStatus.Stale => "Warning",
+ ServerCollectionStatus.Online => "Online",
+ ServerCollectionStatus.Offline => "Offline",
+ ServerCollectionStatus.AwaitingFirstCollection => "AwaitingFirstCollection",
+ _ => "Unknown",
+ };
+
+ /// What the word MEANS, in words — the first line of whichever tooltip renders it. Every arm
+ /// names collection explicitly, because the complaint in #2422 was precisely that a word and a colour
+ /// left the reader guessing which axis they were about. In Darling that axis is always collection
+ /// freshness: there is no live ping to a monitored server.
+ public static string Headline(this ServerCollectionStatus status) => status switch
+ {
+ ServerCollectionStatus.Stale => "Warning — collection has lagged on this server",
+ ServerCollectionStatus.Online => "Online — collection is current",
+ ServerCollectionStatus.Offline => "Offline — nothing has been collected for long enough to call the server dark",
+ ServerCollectionStatus.AwaitingFirstCollection =>
+ "Awaiting first collection — registered, but the service has not reached it yet",
+ _ => "Unknown — this server's collection freshness has not been classified",
+ };
+ }
+
///
/// Per-metric health bands for an Overview card's severity dots — a verbatim mirror of the Dashboard's
/// HealthSeverity. is a metric with no collected data (e.g. Threads on Azure SQL