Band Lite's collection freshness, so a quiet server stops looking green (#2452) - #2457
Conversation
…ng green Lite's Overview card carried five metric rows and a status word and none of them was collection freshness. The failure mode that leaves is not exotic: the connection check keeps passing, no collector is *erroring*, and the store takes no new rows for hours. IsOnline comes from ServerManager.GetConnectionStatus -- a live check that succeeds whether or not anything is being collected -- and HasCollectorErrors counts collectors with ConsecutiveErrors > 0, which a collector that has stopped being scheduled never increments. So the card showed a green "Online", a neutral border, and a "Last Collect" timestamp painted in the plain foreground brush, where four hours ago looked exactly like four seconds ago. Lite reaches that state through its own shipped code. A collector answering PERMISSIONS sets IsPermissionRestricted and RunCollectorAsync returns BEFORE LogCollectionAsync, so it writes no collection_log row at all -- and the PERMISSIONS arm of RecordCollectorResult deliberately does not touch ConsecutiveErrors, because a permission denial is not a transient failure. A login that loses rights across the board therefore stops the store dead with ErroringCollectors at zero. The health map is in-memory as well, so a restart zeroes every streak while the store stays as stale as the old session left it. This takes option 1 from the issue, in full. The band is ServerHealthClassifier.ClassifyFreshness from PerformanceMonitor.Common -- the same Fresh / Stale / Offline / NeverCollected ladder the Darling viewer bands, off the same ServerHealthThresholds numbers, and Lite already referenced that project. No threshold is added to Lite: its fastest configured collector runs every minute, which is the derivation CollectorCadence documents, so the shared constant is true here on its own terms rather than borrowed. Option 2 -- folding freshness into the status word -- is turned down, and the reason is written on CardStatus so the next reader has the argument in front of them. Lite's status word is a CONNECTION word and CollectorErrors already means one specific thing. Folding freshness in would make one amber word mean two unrelated failures, which is the conflation #2429 spent four review rounds untangling on the viewer and the reason #2422 was written. So freshness bands its own row and names its own band there: "08:00:00 (stopped)" in red, with a tooltip that says in words that this is about collection stopping rather than about the server's metrics. #2451 landed while this was open and it is what makes the rest of option 1 possible: the issue asked for the tooltip to "name it, the way it names CPU and Blocking today", and there is now a StatusReason built out of the card's own row displays to name it in. CollectionIsNotFresh joins the per-row concern gates beside CpuIsElevated and BlockingIsElevated, and the clause quotes LastCollectionDisplay verbatim like every other part there -- so the sentence and the row cannot render different things, and because the display already carries the band word the clause says which axis it is about without borrowing a metric's vocabulary. It goes FIRST in the reason. Collection is the row that says whether the other four can be believed at all: a card whose collection stopped four hours ago is showing four-hour-old CPU, and meeting "CPU 4%" before learning that is the wrong order to be told the two facts in. The card border escalates too, sharing the amber arm with CollectorErrors, because collector errors and collector silence are the same class of fault -- the monitoring of this server is not working -- and silence is the harder of the two to notice, which is the whole issue. It stays amber even for a stopped collection: red belongs to a dark server, and a red border under a green "Online" would be the loudest contradiction on the card. The band is stamped in GetServerSummaryAsync, the one place a ServerSummaryItem is built, so the two MCP reads get the same answer the Overview does rather than a fact that depends on which caller asked. Null means nobody classified it -- unreachable in the app, reachable from a fixture -- and renders exactly what the row rendered before: the bare stamp, the card's unknown grey, no tooltip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bae028e to
10f6840
Compare
|
Reviewed the diff ( Correctness
Lite/Darling parity
Security/Performance
Nice level of test coverage pinning the anti-conflation invariant (status word never reflects freshness) and the XAML wiring via source-scan tests, given nothing here is otherwise reachable by a C# assertion. |
…ctor one Caught by running #2451's committed test class against this branch rather than waiting for CI to run it, and CI then failed on exactly this one test and nothing else. TheCardBorder_RendersTheSameDiscriminantTheWordDoes pins the border's source text: Assert.Contains("CardStatus == ServerCardStatus.CollectorErrors ? \"#FFD54F\"", source) Folding freshness in as "CardStatus == ServerCardStatus.CollectorErrors || CollectionIsNotFresh ? ..." put the new clause between the discriminant and the colour, so the literal stopped appearing. The pin is a landed guard from another lane and it is asserting something true and worth asserting -- that the border reads the same state the word does, which review on #2451 found it had not been doing -- so the branch moves, not the pin. Two named causes reading one colour is the better shape anyway: each arm gets its own line and its own reason, and "the monitoring of this server is not working" stays one idea expressed as two conditions rather than one condition with a comment explaining that it is really two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed. This is a well-scoped, well-tested change and I don't see any correctness, security, or parity issues to block on. What it does: Stamps Correctness checks I ran through:
No T-SQL in this diff, so the collector-query style conventions don't apply here. I wasn't able to actually run |
ServerConnection.DotStatus computed "Unknown"/"Online"/"Warning"/"Offline" from its own (IsOnline, HasCollectorErrors) pair -- the same four-word ladder the Overview card derives, on a different type, on a different surface, from a different instance of the same flags. The two could say different things about one server and nothing would notice. That is the drift #2429 collapsed the viewer's card for and #2451 collapsed Lite's, and this is the last of them in Lite. Not the last of them anywhere, and an earlier draft of this message said so. Review caught it: DarlingServer.DotStatus in the Darling viewer is a fifth copy, and unlike this one it is not merely capable of disagreeing with its card -- on dev it already does. Both surfaces there classify the SAME ServerFreshness, and a never-collected server draws a grey "Unknown" dot beside an amber "Awaiting first collection" card. That is a different app, a different enum and a different vocabulary decision, so it is filed rather than folded in here. The collapse is not "ServerConnection switches on the pair too, but returns an enum". A second switch is a second ladder however it is typed, so the ladder itself moved into one function: ServerCardStatusRules.Classify, with Word() and Headline() beside it. ServerSummaryItem.CardStatus and ServerConnection.CardStatus both call it, and StatusDisplay/DotStatus and StatusHeadline/DotTooltip are renderings of the result. The card's brushes stay where they are -- hex colours are the card's business and the sidebar paints from theme resources -- but they still switch on CardStatus, not on the flags. The dot gets the tooltip the card's dot got in #2451, opening on the same sentence word for word. It is the thing a reader points at first, which is #2422's complaint one surface over from where it was reported. Its middle line is the part this surface specifically needs. #2457 deliberately kept collection freshness OUT of the status word and gave it its own banded row on the card; the sidebar has no such row and ServerConnection carries no last-collection time to build one from, so a green dot here is a connection answer read somewhere that offers no freshness answer at all. The tooltip says so and names where the freshness answer lives, rather than leaving a reader to infer one from the other -- which is the #2429/#2422 conflation in miniature. Classify takes two arguments and a test names that signature, so folding freshness in cannot happen by accident. #2451's pin moved with the ladder rather than being loosened, and it got wider, because the way it missed this copy is instructive. Assert.Equal(1, CountOccurrences(source, "IsOnline switch")) counted one literal in one file. The fourth copy was in another file AND was written as a chain of if statements rather than a switch, so it evaded that pin on both axes at once and went on evading it through #2451 and #2457. The count now spans both files and pins the classifier's own parameter list; and a new assertion holds the invariant the count was standing in for, syntax-agnostically -- the four words are string literals that appear in exactly one function, and none of them is written in ServerConnection.cs any more. Verification. Lite.Tests targets net10.0-windows and cannot run on macOS, so the logic was run for real anyway: a throwaway net10.0 harness splices ServerCardStatus, ServerCardStatusRules, ServerSummaryItem and the ServerConnection members straight out of the shipped files (a generator reads the source and emits them -- never retyped) behind a System.Windows.Media shim, with a real project reference to PerformanceMonitor.Common. All 35 checks pass, including every reachable (IsOnline, HasCollectorErrors) combination agreeing between the two surfaces. Against dev the strongly-typed harness does not compile at all, so the comparison is run as the identically-expressible subset with the new members reached reflectively: 7 passed / 6 failed on dev, 13 / 0 on the branch. The 7 that pass on both are the controls, and they are the ones a wrong fix breaks first -- the two ladders agree TODAY, which is why this defect is drift potential rather than a live disagreement, and why the assertions that separate the branches are about construction rather than output. The source- and XAML-scanning half was simulated in Python against both versions: 13 fail on dev and all 18 pass on the branch. Whole solution builds, 0 errors. CHANGELOG deliberately untouched -- #2395 is an open release-prep PR that owns that file for 3.5.1. One thing noted and not fixed here: Lite/Mcp/McpDiscoveryTools.cs renders a THIRD vocabulary for the same axis -- "Online"/"Offline"/"Status not checked" -- off ConnectionStatus.IsOnline. It is a different type, it never reads collector health so it has no "Warning" state, and the MCP text is a consumer API, so changing its words is a decision rather than a cleanup. Filed as a separate concern rather than folded in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ered differently On dev, a registered-but-never-collected server draws a grey "Unknown" dot in the sidebar and an amber "Awaiting first collection" card one panel over. Both are stamped from the SAME ClassifyFreshness call. Verified before touching anything, and the premise holds exactly as erikdarlingdata#2473 describes it: DarlingServer.ApplyFreshness sets two flags where ServerSummaryItem.ApplyFreshness sets three, DarlingServer has no AwaitingFirstCollection to set, and MainWindow.xaml carries DataTriggers for Online/Offline/Warning only -- so the fifth state fell through to ForegroundMutedBrush and painted grey without failing anything. The dot is the thing a reader points at first, and it was silently giving the pre-erikdarlingdata#2429 answer. WHAT THE DOT NOW SAYS, AND WHY Amber, matching the card, with the card's word. The card already chose amber for this state and wrote down why -- a queued server is not a dead one, which is the 24-server field incident erikdarlingdata#1552 answered. Grey is arguably honest ("nothing is known yet") and it was the smaller change, but it is a SECOND answer to a question the card has already answered on the same screen, and having two answers is the entire defect. The sidebar's job here is to render the card's decision, not to hold an opinion about it. Amber now covers two states on the dot (a stale collection and a never-collected server), which is exactly what the card already does -- the card disambiguates them with a word and a dot has no room for one, so the dot gains a tooltip instead. Its first line is the shared headline, word for word what the card says. WHERE THE LADDER LIVES Not in the viewer. Collapsing the two viewer surfaces onto a viewer-local rules class would have produced a pin that could not span the files holding the other copies, and erikdarlingdata#2470's whole lesson is that a pin scoped shorter than the duplication is worth nothing. Looking for those files turned up two more: - DarlingFleetReader.StatusLabel -- the web/MCP fleet card's word. Agrees with the card today; a fourth independent derivation of the same five states. - DarlingMcpDataTools.FreshnessStatus -- list_servers, written as if statements, in another file, against its OWN copies of the 2-minute and 15-minute thresholds. That is the erikdarlingdata#2470 evasion shape twice over, plus a real drift bug: ServerHealthThresholds could move and list_servers would go on answering with the old numbers. Both live in the headless service, which cannot reference WPF, so the only assembly all four can render from is PerformanceMonitor.Common -- where ServerHealthClassifier and the thresholds already are, and where ClassifyBand already takes this exact flag triple. The type is ServerCollectionStatus, not ServerCardStatus, and the rename is load-bearing rather than cosmetic. Lite has a ServerCardStatus of its own answering a DIFFERENT question -- its word comes from a live connection check, this one from how old the newest collection is -- and erikdarlingdata#2457 turned down folding freshness into Lite's word precisely to keep the two axes apart. Common already names that distinction (ServerConnectionStatus is the connection answer); this is the collection one. Sharing a name would have invited the conflation both issues were about, and Lite/Models/ServerConnection.cs imports both namespaces, so it would also not have compiled. WHAT THE COLLAPSE ACTUALLY IS Classify(isOnline, hasCollectorErrors, awaitingFirstCollection) is the ladder. Word(), McpToken() and Headline() are three renderings of its result -- not three ladders, which is the correction erikdarlingdata#2470 had to make once already: a second switch is a second ladder even when it returns the right type. McpToken differs from Word in exactly one arm because "AwaitingFirstCollection" shipped as a published MCP status value and clients key on it; a status value is a consumer API, so the vocabularies stay apart and sit next to each other where the difference reads as a decision. FlagsFor(ServerFreshness) is the other half, and it is the half that fixes the bug rather than tidying it. Both ApplyFreshness implementations classified freshness and then wrote the flags out by hand; the sidebar's wrote two of three and dropped the awaiting marker on the floor. Nothing about a block of assignments makes a missing one visible. They now arrive as one value. Behaviour is unchanged everywhere except the sidebar dot. The MCP list_servers tokens were checked arm by arm against the thresholds they used to duplicate (2 min stale, 15 min offline -- identical), and StatusLabel's output is byte-identical. Verified on macOS: Common, the Darling viewer, the Darling service, Lite, Lite.Tests and Darling.Tests all build. The ladder itself was run against the real build in a throwaway net10.0 console -- every freshness band through FlagsFor and Classify agrees with FromFreshness, the erikdarlingdata#2429 flag pairs still resolve the way the card always resolved them, and all four list_servers tokens match the pre-change thresholds. The WPF suites cannot run on macOS; CI is the arbiter for those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #2452 (part 1). Part 2 is split out as #2458 — see the last section for why, and why the issue's reason for pairing them did not survive the implementation.
The defect, and why it is reachable in shipped Lite
The Overview card carries five metric rows and a status word. None of them was collection freshness, so the state the issue describes — connection fine, nothing erroring, store taking no new rows for hours — rendered as a green "Online", a neutral border, and a timestamp in the plain foreground brush where four hours ago looked exactly like four seconds ago.
The issue asks for a measurement: whether Lite installs actually see collection stop without a collector erroring. Lite's own source answers it without needing one, and the path is short:
RemoteCollectorService.RecordCollectorResulttreatsPERMISSIONSas not a failure — deliberately, per its comment, because a permission denial is not transient. It setsIsPermissionRestrictedand leavesConsecutiveErrorsalone.IsCollectorPermissionRestrictedthenreturns out ofRunCollectorAsyncbeforeLogCollectionAsync, so a restricted collector writes nocollection_logrow at all — not even a failed one.GetHealthSummarycountsErroringCollectorsfromConsecutiveErrors > 0.So a login that loses rights across the board stops the store dead with
ErroringCollectorsat zero, the connection check still passing, and every card green._collectorHealthis in-memory too, so an app restart zeroes every streak while the store stays exactly as stale as the previous session left it.What changed, and the one thing that deliberately did not
Option 1 from the issue, sharing the thresholds rather than growing new ones.
ServerHealthClassifier.ClassifyFreshnessout ofPerformanceMonitor.Common— the same Fresh / Stale / Offline / NeverCollected ladder the Darling viewer bands, off the sameServerHealthThresholdsnumbers. Lite already referenced that project, so this cost oneusing. No threshold is added to Lite, and that is a claim about correctness rather than convenience: Lite's fastest configured collector incollection_schedule.jsonruns every minute, which is exactly the derivationCollectorCadencedocuments, soStaleThresholdis true here on its own terms rather than borrowed from a service with a different rhythm.Option 2 — folding freshness into the status word — is turned down, and the reason is written on
StatusDisplay. Lite's status word is a connection word (IsOnlineis a live check, which succeeds whether or not anything is being collected) and its"Warning"already means one specific thing: collectors are erroring. #2429 found the viewer renders that same word for a stale collection and for a metric breach with nothing on the card telling them apart, which is the card @ehaar wrote in about in #2422. Reproducing that here would have traded one defect for the other.So freshness bands its own row and names its own band there. What the card actually renders, printed from the shipped
ServerSummaryItemspliced out of this branch:Onlinegreen#2A2D3511:59:40greenEvery metric on this card is inside its thresholdOnlinegreen#FFD54F11:57:59 (stale)amberNeeds attention: Last collect 11:57:59 (stale)Onlinegreen#FFD54F08:00:00 (stopped)redNeeds attention: Last collect 08:00:00 (stopped)Onlinegreen#FFD54FNeveramberNeeds attention: Last collect Neverand the Last Collect row carries its own tooltip, which is the part that answers the "do not reproduce the conflation" requirement literally:
The status word staying green across all four rows is not an oversight — it is the point.
IsOnlineanswers "did the last connection check succeed", and it did. "Online" and "fresh" are different questions and the card now answers both, each in its own place, each in its own words. The thresholds inside the tooltip are read fromServerHealthThresholdsrather than typed, so the sentence cannot come to disagree with the band it is explaining.Three smaller decisions
The border escalates, on its own arm reading the same amber as the collector-error one. Collector errors and collector silence are one class of fault — the monitoring of this server is not working — and silence is the harder of the two to notice, which is the whole issue; a fix that left the card looking calm at a glance would have fixed a row nobody was asked to read. It stays amber even for a stopped collection: red belongs to the offline overlay, and a red border under a green "Online" would be the loudest contradiction on the card. The row underneath carries the severity in its own colour and its own word.
It is a separate arm rather than a clause on the collector-error one because #2451's
TheCardBorder_RendersTheSameDiscriminantTheWordDoespins that arm's source text —CardStatus == ServerCardStatus.CollectorErrors ? "#FFD54F"— and folding|| CollectionIsNotFreshin put the new clause between the discriminant and the colour, so the literal stopped appearing. That pin is a landed guard from another lane asserting something true and worth asserting, so the branch moved rather than the pin. Two named causes reading one colour is the better shape anyway: each arm gets its own line and its own reason.This was caught by running #2451's committed test class against this branch rather than waiting for CI to run it — and CI then failed on that one test and nothing else, which is the whole argument for doing it that way. All 12 of #2451's tests pass on this branch now.
NeverCollectedis amber, never red. A server that has not been collected yet is queued, not dead — the reasoningServerFreshness.NeverCollectedalready carries from the viewer, where a red "Offline" on a merely-queued server sent a 24-server field report chasing a phantom scheduler bug.The band is stamped in
GetServerSummaryAsync, the one place aServerSummaryItemis built, rather than at the Overview loader — the two MCP reads call that method too, and a band only the Overview applied would be a fact that depended on which caller asked. The clock is handed in rather than read inside the band, so the classification stays pure over (last collection, now).nullmeans nobody classified it: unreachable in the app, reachable from a fixture, and it renders exactly what dev rendered — the bare stamp, the card's unknown grey, no tooltip.Background="Transparent"on both row cells is load-bearing rather than decoration, as #2429 found: aTextBlockwith a nullBackgroundhit-tests on its rendered glyphs alone, so the tooltip would have appeared over the letters and nowhere in the space around them.Verification
Lite.Teststargetsnet10.0-windowsand cannot run on macOS, so the logic was run for real anyway: a throwawaynet10.0harness splicesServerSummaryItemstraight out of the shipped file (a generator reads the source and emits it — never retyped) behind aSystem.Windows.Mediashim, with a real project reference toPerformanceMonitor.Commonso the bands are the shipped classifier's. The table above is that harness's output.The absence is the finding rather than a measurement, so the subset runs identical bodies against both contracts.
ApplyCollectionFreshnessis invoked reflectively there — on dev there is genuinely nothing to stamp, which is the thing being measured, not a harness limit. The five that fail on dev are the quiet server's border, the row naming its band, the card tooltip naming the row, the XAML wiring, and the stamp itself. The three that pass on both sides are the controls, and they are the ones a wrong fix breaks first: a calm currently-collecting card keeps the neutral border (so this is not "paint every card amber"), the status word says the same thing for a stale card as for a fresh one (so this is not option 2 sneaking in), and a calm current card's tooltip still says nothing about collection.EveryCardIsStampedWithItsBandWhereItIsBuiltis a source scan on purpose: deleting that one line compiles perfectly clean and silently returns every card to dev's behaviour, and a property nothing sets is this issue's own failure mode one level up. The XAML half has to text-scan for the same reason #2429 gave — aToolTipattribute lives where no assertion about a C# object can reach it, and removing it compiles clean.devmoved twice while this was open (#2451, then #2450/#2453). Rebased onto88fe948fand re-verified: whole solution builds 0 errors, and 28 cases green — this PR's 16 plus all 12 of #2451's, run against the rebased tree. Not force-pushed, because the PR isMERGEABLE/CLEANagainst that samedevand the rebase was for verification rather than for the merge.Whole solution builds, 0 errors. CHANGELOG deliberately untouched — #2395 is an open release-prep PR that owns that file for 3.5.1.
Part 2 is split out as #2458, not dropped
#2452's second half —
ServerConnection.DotStatusbeing a fourth independent copy of the four-word ladder — is filed as #2458 rather than done here, and the issue's own reason for pairing them does not hold as this landed. It argued that "a freshness band would otherwise need writing twice". It is written once, onServerSummaryItem, and the sidebar dot is untouched, so nothing is duplicated by leaving it.The thing that does argue for splitting: the type
DotStatusshould collapse onto isServerCardStatus, and in Lite that type arrives in #2451, which is still open. Doing it here would mean either duplicating that enum or stacking on an unmerged branch — and a stacked PR reports a meaningless green.#2451 landed while this was open, and it is what completes option 1
The issue's option 1 has two halves: band the row, and "let the tooltip name it, the way it names CPU and Blocking today". The second half needed a tooltip built from the card's own rows, which did not exist in Lite when #2452 was written. #2451 merged into
devmid-flight and brought one, so this branch was rebuilt on top of it rather than textually merged, and takes the whole option instead of half.CollectionIsNotFreshjoinsCpuIsElevated/BlockingIsElevated/DeadlocksAreElevatedin #2451's per-row concern gates, andStatusReasonquotesLastCollectionDisplayverbatim exactly as it quotesCpuDisplay— which is the property that makes that whole design work, per its own comment: the clause and the row cannot render different things. Because the display already carries the band word, the clause names its axis without borrowing a metric's vocabulary.It goes first in the reason. Collection is the row that says whether the other four can be believed at all: a card whose collection stopped four hours ago is showing four-hour-old CPU, and meeting "CPU 4%" before learning that is the wrong order to be told the two facts in.
TheTooltipNamesTheCollectionRowExactlyWhenItIsNotGreenextends #2451's own invariant to this row and asserts it against the shipped brush rather than a copy of the thresholds:🤖 Generated with Claude Code