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