Skip to content

Say why the card is amber, and give "+52 more" somewhere to go (#2424) - #2429

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/2424-overview-explains-itself
Aug 21, 2026
Merged

Say why the card is amber, and give "+52 more" somewhere to go (#2424)#2429
erikdarlingdata merged 4 commits into
devfrom
fix/2424-overview-explains-itself

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2424, and answers both halves of @ehaar's #2422.

Both defects have the same shape: the Overview computes a rich health picture and then renders a lossy summary of it. On a 57-server fleet — which is where this tab earns its keep — they compound. The reporter read "Warning" in amber on the first card and asked what it was warning him about, then read "+52 more need attention" and asked where he could find them. Neither question needed new data; both needed the tab to show something it had already worked out.

The card says why

FleetRollup.BuildReason already turns a card into "CPU 94%, Blocking 12" out of that card's own metric displays — deliberately, per its own comment, so it can never drift from what the card shows — and every bit of that was spent on the five-row Needs Attention ranking. The card, which is the thing being looked at, never got it.

The status line carries it now as a ToolTip, in the shape MainWindow.AlertBadges.cs already established for the sidebar badge: the breakdown, then how to act on it. It is BuildReason's output verbatim rather than a second derivation, because a second derivation would eventually disagree with the six metric rows sitting directly underneath it, which is the exact failure BuildReason exists to prevent. Two arms sit on top of it:

  • a healthy card gets an all-clear, not BuildReason's "Needs attention" fallback — that fallback is written for a ranking that only ever holds problem servers, and on a grid showing every server it would tell each green card the opposite of the truth;
  • Offline and awaiting-first-collection are not labelled twice, their reason already being a whole sentence naming the state.

What the tooltips actually read, run against the shipped source:

card tooltip
CPU 96%, 6 blocking events Critical — CPU 96%, Blocking 6
online, collection stale Warning — collection stale
offline Offline — no recent collection
never collected Awaiting first collection
calm Healthy — every metric on this card is inside its threshold

each followed by Double-click the card to open this server's tab.

The second row is worth naming, because it is the reporter's exact card. StatusDisplay renders "Warning" for one condition only — an online server whose collection has gone stale — and nothing on the card said so, while the amber reads naturally as a metric problem.

Background="Transparent" on that TextBlock is load-bearing rather than decorative: a TextBlock with a null Background hit-tests on its rendered glyphs alone, so the tooltip would have appeared over the letters of "Warning" and nowhere in the space around them.

"+52 more" reaches the 52

This took option 1 from the issue, the filter. The honest answer to "where do I find these warnings?" was nowhere; FleetRollup computes the full problem set and truncates it for display, so those 52 were discarded rather than unavailable. The line is now a link into a needs-attention filter over the card grid — the surface that actually answers the question, because the grid is where the six metric rows live. It filters on FleetRollup.NeedsAttention, the same banding that counted the 52, so the destination cannot disagree with the label that sent you there.

The filter is a toggle beside the existing Sort selector rather than a mode the link switches on invisibly, and the placement is the point: the roll-up header is docked to the top of the Overview and never scrolls, so a filter that is on can never be off screen while the grid it shrank is on it, and showing 12 of 57 renders beside the toggle whenever it is active. A filtered list that looks unfiltered is a worse defect than the dead end it replaces. It is deliberately not persisted the way the sort mode is — a sort is a preference, this is a triage action tied to a moment, and an Overview that opens with 52 of 57 servers already hidden is a support ticket even with the box in plain sight.

The cap of five stays

Considered and kept, not left alone by default. It hides 52 servers on this fleet, which is the complaint — but the ranking renders inside that same non-scrolling docked panel. A list that grew with the fleet would push the cards it is pointing at off the screen, and would stop being a triage shortlist and become a second, worse copy of the grid without the metrics. The repair for an overflow is a destination rather than a longer list, and it now has one. The reasoning is written down on DefaultWorstCount so the next person deciding this has the argument in front of them.

One structural consequence

OverviewItemsControl.ItemsSource was read back in two places to answer "what cards are there" — the re-sort and the tag re-stamp — which is safe only while the bound list is the complete list. That is the exact mistake FleetView was written to make impossible on the sidebar. The full sorted set is now the authority and the grid is projected from it through the filter in one place; sorting the bound list under an active filter would have silently discarded every card the filter was hiding. A test pins that there are exactly two ItemsSource writers and no readers.

Verification

The Windows test suites cannot run on macOS, so the pure logic was run for real anyway: a throwaway net10.0 harness splices FleetRollup and ServerSummaryItem straight out of the shipped files (never retyped) behind a System.Windows.Media shim, and every assertion in the new test class passes against it — the table above is its output. Pointed at dev's copies of the same two files, it does not compile, because none of the members exist there.

The four source-scanning facts were simulated in Python against both versions: 18 of 19 assertions fail on dev and all pass on the branch (the one that passes on dev is the guard that finds the status TextBlock at all). The wiring half has to text-scan source, because a ToolTip attribute and a click handler live in XAML where no assertion about a C# object can reach them, and removing either compiles perfectly clean.

Whole solution builds with 0 warnings and 0 errors; Darling.Tests builds. CHANGELOG deliberately untouched — #2395 is an open release-prep PR that owns that file for 3.5.1.

🤖 Generated with Claude Code

Both halves of #2422 are places where the Overview computes the answer and then
declines to render it, and on the fleet size where this tab earns its keep they
compound. The reporter had 57 servers, read "Warning" in amber on the first
card and asked what it was warning him about, then read "+52 more need
attention" and asked where he could find them.

The reason was already built. FleetRollup.BuildReason turns a card into "CPU
94%, Blocking 12" out of that card's OWN metric displays -- deliberately, per
its own comment, so it can never drift from what the card shows -- and it was
spent entirely on the five-row Needs Attention ranking. The card, which is the
thing being looked at, did not get it. The status line carries it now as a
ToolTip, in the shape MainWindow.AlertBadges.cs already established for the
sidebar badge: the breakdown, then how to act on it. It is BuildReason's output
verbatim rather than a second derivation, because a second derivation would
eventually disagree with the six metric rows sitting directly underneath it,
and that is precisely the failure BuildReason exists to prevent. Two arms sit
on top of it. A healthy card gets an all-clear instead of BuildReason's "Needs
attention" fallback, which is written for a ranking that only ever holds
problem servers and on a grid showing every server would tell each green card
the opposite of the truth. Offline and awaiting-first-collection are not
labelled twice, their reason already being a whole sentence that names the
state. Background="Transparent" on that TextBlock is load-bearing rather than
decorative: a TextBlock with a null Background hit-tests on its rendered glyphs
alone, so the tooltip would have appeared over the letters of "Warning" and
nowhere in the space around them.

Worth naming, because it is the reporter's exact card. StatusDisplay renders
"Warning" for one condition only -- an online server whose collection has gone
stale -- and nothing on the card said so, while the amber reads naturally as a
metric problem. That card's tooltip now says "Warning - collection stale".

"+52 more need attention" is the harder half, because the honest answer to
"where do I find these warnings?" was nowhere. FleetRollup computes the full
problem set and truncates it for display, so those 52 were discarded rather
than unavailable. The line is now a link into a needs-attention filter over the
card grid, which is the option the issue ranked first and the one that actually
answers the question, since the grid is where the six metric rows live. It
filters on FleetRollup.NeedsAttention -- the same banding that counted the 52 --
so the destination cannot disagree with the label that sent you there.

The filter is a toggle beside the existing Sort selector rather than a mode the
link switches on invisibly, and the placement is the point: the roll-up header
is docked to the top of the Overview and never scrolls, so a filter that is on
can never be off screen while the grid it shrank is on it, and "showing 12 of
57" renders beside the toggle whenever it is active. A filtered list that looks
unfiltered is a worse defect than the dead end it replaces. It is not persisted
the way the sort mode is, because a sort is a preference and this is a triage
action tied to a moment; an Overview that opens with 52 of 57 servers already
hidden is a support ticket even with the box in plain sight.

The cap of five stays, deliberately. It hides 52 servers on this fleet, which
is the complaint, but the ranking renders inside that same non-scrolling docked
panel: a list that grew with the fleet would push the cards it is pointing at
off the screen, and would stop being a triage shortlist and become a second,
worse copy of the grid without the metrics. The repair for an overflow is a
destination, not a longer list, and it now has one.

One structural consequence is worth flagging. OverviewItemsControl.ItemsSource
was read back in two places to answer "what cards are there" -- the re-sort and
the tag re-stamp -- which is safe only while the bound list is the complete
list. That is the exact mistake FleetView was written to make impossible on the
sidebar, so the full sorted set becomes the authority here too and the grid is
projected from it through the filter in one place. Sorting the bound list under
an active filter would have silently discarded every card the filter was
hiding.

The tests cover the tooltip's reuse of BuildReason, the healthy and offline
arms, the filter agreeing with the roll-up's own overflow count, and the cap.
The wiring half text-scans source, because a ToolTip attribute and a click
handler live in XAML where no assertion about a C# object can reach them and
removing either compiles perfectly clean.

Fixes #2424

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/// <para>Delegated rather than reimplemented on purpose: two independent derivations of "why is this amber"
/// would eventually disagree, and the one place they would disagree is a card the reader is staring at.</para>
/// </summary>
public string StatusTooltip => FleetRollup.BuildStatusTooltip(this);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor/non-blocking: StatusDisplay has an Unknown arm (IsOnline == null && !AwaitingFirstCollection, line 569) that BuildStatusTooltip/ClassifyBand don't special-case. For that state, ClassifyBand falls through to OverallMetricSeverity/HasCollectorErrors, so on an otherwise-clean summary the tooltip would read "Healthy — every metric on this card is inside its threshold" right next to a status word reading "Unknown" — the two would say opposite things.

Today this looks unreachable in practice: ApplyFreshness (the only place that sets IsOnline/AwaitingFirstCollection for Overview cards) always leaves the pair in one of the two handled states, never in the Unknown combination. But since StatusDisplay already carries a dedicated branch for it, it might be worth a one-line Unknown arm in DescribeBand/BuildStatusTooltip too, so the two stay in lockstep if a future caller ever constructs a summary without going through ApplyFreshness (e.g., a test fixture, or a new data path).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken — 44ea480. You are right that ApplyFreshness cannot produce that pair today, and right that it is worth an arm anyway: the guard costs one branch, and the state it guards is one a fixture or a new data path can construct without going near ApplyFreshness, at which point the contradiction ships silently because nothing fails.

DescribeBand becomes Headline, since it is no longer describing a band — it answers "what does this card's status word mean", with the band as one of the answers. The unknown card still gets its reason when there is one: not knowing whether a server is reporting is no argument for withholding the CPU number that was collected, so an unknown card with a bad metric now reads Unknown — no collection status for this server; CPU 96%, and the healthy-card guard still sits underneath so the ranking's "Needs attention" fallback cannot leak through this arm either. Pinned by TheCardsTooltip_NeverContradictsAnUnknownStatus.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

Scope: C#/XAML only (Darling/PerformanceMonitor.Darling.Viewer/*, Darling/Darling.Tests/*) — no T-SQL touched, so the T-SQL style checklist doesn't apply here.

Lite/Darling parity: No drift. FleetRollup/the multi-server "Overview" grid with a docked worst-N roll-up panel is a Darling-only concept (Lite is single-server, per CONTRIBUTING.md's architecture section, and has no FleetRollup/NeedsAttention/fleet-rollup equivalent to keep in sync). This change has no Lite counterpart to flag as missing.

Correctness: Traced the two new code paths end to end against ServerHealthClassifier.ClassifyBand/FleetRollup.Build:

  • FleetRollup.NeedsAttention uses the exact same predicate (ClassifyBand(s) != Healthy) as Build's problems list, so WorstServers.Count + AdditionalProblemCount == NeedsAttention(...).Count always holds — the "+N more" link really does land on N more cards, not an approximation. Verified this isn't just asserted by the new tests but is structurally true from the shared predicate.
  • DescribeBand's special-casing of Offline/Awaiting-first-collection (skip the redundant band-label prefix) lines up correctly with BuildReason's own early returns for those same two states.
  • The _overviewCards (full set) vs OverviewItemsControl.ItemsSource (filtered projection) split is applied consistently — sort, tag re-stamp, refresh, and clear all route through _overviewCards + ApplyOverviewCardFilter(), and the test pinning "exactly two ItemsSource writers, zero readers" matches what's actually in the diff.
  • Background="Transparent" on the status TextBlock is correctly required for the tooltip to hit-test over the whole element rather than just glyphs — confirmed this is genuinely necessary WPF behavior, not an unnecessary addition.

Left one non-blocking inline note on a latent (currently unreachable) inconsistency between StatusDisplay's Unknown arm and BuildStatusTooltip's banding.

Security/perf: No I/O, no new external input parsing, no secrets. Filter/sort are O(n) in-memory list operations over per-fleet-refresh data — no regression risk at realistic fleet sizes.

No blocking issues found.

Review on #2429 caught the one state where the new tooltip could contradict the
word it hangs off. StatusDisplay renders "Unknown" when IsOnline is null and
nothing marks the card as awaiting its first collection, and ClassifyBand has no
such arm -- it falls straight through to the metrics, so on an otherwise clean
card the tooltip read "Healthy - every metric on this card is inside its
threshold" beside a status word reading "Unknown". Two surfaces, one pixel
apart, saying opposite things, which is a smaller copy of the defect this branch
exists to remove rather than a different one.

It is not reachable today. ApplyFreshness is the only thing that sets that pair
on an Overview card, and its four arms never leave a card there. That is the
argument for pinning it rather than filing it: the cost of the guard is one
branch, and the thing being guarded is a state a test fixture or a future data
path can construct without going anywhere near ApplyFreshness -- at which point
the contradiction ships silently, because nothing would fail.

The unknown card still gets its reason when there is one. Not knowing whether a
server is reporting is no argument for withholding the CPU number that WAS
collected, so a card in that state with a bad metric reads "Unknown - no
collection status for this server; CPU 96%". The healthy-card guard still
applies underneath it, so the ranking's "Needs attention" fallback cannot leak
onto a card through this arm either.

DescribeBand becomes Headline in the process, because it is no longer describing
a band -- it is answering "what does this card's status word mean", with the
band as one of the answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling-only: MainWindow.xaml/.xaml.cs, ViewerDataService.Fleet.cs, ViewerDataService.Overview.cs, plus the new ViewerOverviewExplainsItselfTests.cs).

Correctness: Traced the new Headline/BuildStatusTooltip/NeedsAttention/AttentionFilterCountText logic against ServerHealthClassifier.ClassifyBand and ServerSummaryItem.StatusDisplay. Every StatusDisplay arm (Online/Warning/Offline/Awaiting/Unknown) has a matching, non-contradictory Headline arm, including the "Unknown status" edge case that's unreachable via ApplyFreshness today but is correctly guarded and pinned by a test anyway. The OverviewItemsControl.ItemsSource single-writer refactor checks out — grepped the file and there are exactly the two writers (ClearOverviewCards, ApplyOverviewCardFilter) the new test asserts, so the sort/tag-restamp/refresh paths can no longer silently disagree about whether the filter is active. AttentionFilterCountText's zero-fleet vs zero-matches distinction is correct and tested. No bugs found in the reviewed logic.

Lite/Darling parity: No drift from this PR. The fleet-wide rollup (FleetRollup, worst-N ranking, BuildReason) is explicitly documented as Darling-exclusive — it depends on Darling's single cross-server Postgres store, which Lite's per-server DuckDB architecture can't produce. Lite's Overview tab (Lite/Services/LocalDataService.Overview.cs) has its own ServerSummaryItem.StatusDisplay with the identical bare "Warning" (stale collection) label the original report complained about, and no StatusTooltip equivalent — so the UX gap this PR fixes for Darling still exists in Lite, just via a different code path with no shared plumbing to reuse. Worth a follow-up issue for Lite's Overview cards, but it isn't a parity regression introduced by this PR since Lite never had BuildReason/FleetRollup to begin with.

Style/other: New file has the copyright header; XML docs present on all new public members; no T-SQL touched so the T-SQL style rules don't apply here. One tiny non-blocking nit: OverviewAttentionCountText uses WarningBrush for all three of its states, including the "all N servers are healthy" all-clear message — cosmetic only.

No blocking issues.

Second review pass on #2429 caught that OverviewAttentionCountText paints all
three of its sentences amber, and one of them is an all-clear. That line says
either "showing 12 of 57" -- servers wanting attention, amber is right -- or
"all 57 servers are healthy", where amber is a colour contradicting its own
text. It is cosmetic, and it is also a small copy of the thing this branch
exists to fix: a card that showed a colour and left the reader to guess what it
was claiming.

The brush is set through SetResourceReference rather than assigned, which is the
alert badge's idiom in MainWindow.AlertBadges.cs, so the line still follows a
theme change instead of freezing whichever palette happened to be loaded when
the filter was switched on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Both notes from the second pass are settled.

The WarningBrush nit is taken in 32241bd — cosmetic, but it is a small copy of the defect this branch exists to fix, so it seemed the wrong one to wave through. showing 12 of 57 stays amber; all 57 servers are healthy now paints SuccessBrush, set through SetResourceReference (MainWindow.AlertBadges.cs' idiom) so the line still follows a theme change instead of freezing whichever palette was loaded when the filter went on.

On Lite: agreed, and confirmed independently — Lite/MainWindow.xaml:442 has the same bare {Binding StatusDisplay} with no ToolTip, and Lite has no FleetRollup/BuildReason to reuse, so it is a port rather than a wiring change. Filing separately rather than smuggling it in here. The web dashboard has the other half of the same report, too: Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js:168 renders + N more need attention as a plain muted div with no handler, above a card grid that already has a redrawCards()/fleetFilter seam — so option 1 ports there almost as-is. Both going to Erik as follow-ups.

{
/* Offline and awaiting-first-collection come back from BuildReason as whole sentences that already
name the state, so putting a band label in front would only say "Offline" twice. */
if (s.IsOnline == false || s.AwaitingFirstCollection)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard closes the IsOnline == null && !AwaitingFirstCollection contradiction (the one raised in the prior review round and pinned by TheCardsTooltip_NeverContradictsAnUnknownStatus), but there's a sibling gap it doesn't close: IsOnline == true && AwaitingFirstCollection == true.

AwaitingFirstCollection is a plain mutable { get; set; } (ViewerDataService.Overview.cs:389), so nothing stops a fixture or a future data path from setting it alongside IsOnline = true, just as nothing stopped the null/false combo this commit already guards against. If that happens:

  • StatusDisplay matches on IsOnline alone (true when HasCollectorErrors => "Warning", true => "Online") — AwaitingFirstCollection never enters that switch, so it reads "Online".
  • Headline here short-circuits on s.IsOnline == false || s.AwaitingFirstCollection — the second half fires regardless of IsOnline, so BuildReason returns "Awaiting first collection".

The card would show green "Online" with a tooltip reading "Awaiting first collection — Double-click the card to open this server's tab", which is exactly the class of contradiction this PR (and the prior review round's fix) exists to remove — just a different unreached-today combination than the one already pinned.

Worth mirroring StatusDisplay's own structure here: only consult AwaitingFirstCollection inside the IsOnline is null branch (where it's actually meaningful per ApplyFreshness), the same way the switch does, rather than checking it independently of IsOnline up front.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and taken further than the note — ae87120.

You are the second round to find a different pair of the same two flags disagreeing, and that is the point where it stops being a bug and starts being a category: the word, the colour, BuildReason and the tooltip were four independent readings of one settable pair, and nothing forced any of them to agree. Mirroring StatusDisplay's structure in Headline would have fixed your instance and left the next one to be found the same way.

So they now share a discriminant. ServerCardStatus is derived in exactly one place from the flag pair, and StatusDisplay / StatusBrush / BuildReason / the tooltip headline are renderings of it. Every rendered string and colour is byte-identical, so the existing StatusDisplay pins carry over untouched.

Then I wrote the test as a sweep over the whole product instead of a third guard, and it found two more nobody had reported: ClassifyBand was still reading AwaitingFirstCollection raw, so an online card with a stray marker banded Warning with nothing to report and rendered Warning — Needs attention; and the Unknown arm appended the reason unguarded, so a card outside Healthy with nothing nameable leaked the ranking-only fallback. Both go through one WithReason helper now, and the fallback string is a named constant.

The sweep walks 96 cards (three IsOnline values against awaiting, stale, a CPU number, a blocking max-wait and a failing collector) and asserts no tooltip names a state its own status word contradicts, the ranking fallback never reaches a card, and all five status words were actually exercised. The blocking dimension is in there because widening the sweep is what found it — a long max-wait with a zero event count bands Blocking Warning while BuildReason's gate wants a non-zero count.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling.Tests + MainWindow.xaml/.xaml.cs + ViewerDataService.Fleet.cs/.Overview.cs). Overall this is well-reasoned and well-tested — the NeedsAttention/Build predicate reuse for the filter, the single OverviewItemsControl.ItemsSource writer, and the BuildReason-verbatim tooltip all hold up under inspection, and the two follow-up review commits already closed the "healthy fallback on every card" and "amber all-clear line" issues cleanly.

One remaining gap left an inline comment on (ViewerDataService.Fleet.cs, Headline): the guard added for the IsOnline == null && !AwaitingFirstCollection contradiction doesn't cover the sibling case IsOnline == true && AwaitingFirstCollection == true, which would show "Online" with a tooltip reading "Awaiting first collection" — same defect family, different unreached-today combination.

Lite/Darling parity note (not a regression from this PR, flagging per review scope): Lite's ServerSummaryItem.StatusDisplay (Lite/Services/LocalDataService.Overview.cs:197-203) has the exact same "Warning" status word for IsOnline == true && HasCollectorErrors with no tooltip explaining it — the identical bug shape reported in #2422/#2424, just never given the FleetRollup/BuildStatusTooltip treatment because Lite's Overview never grew the fleet-rollup/needs-attention-filter feature this PR extends. That's a pre-existing architectural gap, not something this PR widens, but since the reporter's exact complaint ("amber card with no explanation") reproduces verbatim in Lite today, it's worth a tracked follow-up if Lite's Overview is meant to stay behavior-compatible with Darling's card semantics.

No T-SQL in this change, so the collector-query style rules don't apply. No security-relevant surface (no new I/O, no untrusted input parsing) — this is pure client-side view-model/filter logic over already-loaded data.

Two review rounds on #2429 each found a different pair of freshness flags where
the card's status word and its tooltip said opposite things. The first was
IsOnline null with no awaiting marker; the second was IsOnline TRUE with one,
which would have put a green "Online" over a tooltip reading "Awaiting first
collection". Both got guarded where they were found. That is the point at which
the second instance stops being a bug and starts being a category: the word, the
colour, the ranking's reason and the tooltip were four independent readings of
the same two settable properties, and nothing forced any of them to agree.

So they now share one discriminant. ServerCardStatus is derived in exactly one
place, from the flag pair, and StatusDisplay, StatusBrush, BuildReason and the
tooltip's headline are renderings OF IT rather than four opinions about it.
Every rendered string and every colour is byte-for-byte what it was, so the
existing StatusDisplay pins carry over untouched -- what changed is that there
is no longer a combination left for two of them to disagree about, because they
no longer each decide.

Then the test was written as a sweep over the whole product of the flags rather
than as a third guard, and it immediately found two more instances that nobody
had reported. ClassifyBand was still reading AwaitingFirstCollection raw, so an
online card carrying a stray marker banded Warning with nothing to report and
rendered "Warning - Needs attention". And the Unknown arm appended the reason
unguarded, which is the same append the band arm had already learned to guard --
so a card outside Healthy with nothing nameable leaked the ranking-only fallback
onto a tooltip. Both now go through one WithReason helper that degrades to the
headline alone, and BuildReason's fallback is a named constant so the two sides
cannot drift on its spelling.

That is four instances of one category, two of them found by the sweep rather
than by a reviewer, which is the argument for the sweep. It walks 96 cards --
the three IsOnline values against awaiting, stale, a CPU number, a blocking
max-wait and a failing collector -- and asserts that no tooltip names a state
its own status word contradicts, that the ranking's fallback never reaches a
card, and that all five status words were actually exercised, so a later rewrite
that collapses one of them cannot leave the sweep passing over a smaller surface
than it claims.

The blocking dimension is in there because widening the sweep is what found it:
a long max-wait with a zero event count bands Blocking as Warning while
BuildReason's own gate wants a non-zero count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

Re-reviewed the latest diff (MainWindow.xaml/.xaml.cs, ViewerDataService.Fleet.cs, ViewerDataService.Overview.cs, ViewerOverviewExplainsItselfTests.cs) against the four prior review rounds already on this thread.

Correctness: Traced ServerCardStatus.CardStatus as the single discriminant behind StatusDisplay, StatusBrush, FleetRollup.ClassifyBand, BuildReason, and BuildStatusTooltip/Headline. Enumerated all 12 combinations of (IsOnline, HasCollectorErrors, AwaitingFirstCollection) by hand — they collapse onto exactly the 5 ServerCardStatus values with no leftover combination where the word and tooltip could disagree, which matches what the 96-case sweep test asserts. The two contradiction cases flagged in earlier rounds (IsOnline == null with no awaiting marker, and IsOnline == true with a stray awaiting marker) are both closed by routing everything through CardStatus in the current diff — confirmed both are actually unreachable now, not just guarded after the fact.

FleetRollup.NeedsAttention and Build's problem-server list use the identical ClassifyBand(s) != Healthy predicate, so WorstServers.Count + AdditionalProblemCount structurally equals NeedsAttention(...).Count — the "+N more" link lands on exactly N more cards, not an approximation. The _overviewCards (full set) vs. OverviewItemsControl.ItemsSource (filtered projection) split is applied consistently across refresh/sort/tag-restamp/clear, matching the "exactly two writers, zero readers" test.

No new bugs found beyond what the last two review rounds already caught and the author fixed (the WarningBrush-on-all-clear cosmetic issue, and the two CardStatus contradiction cases).

Lite/Darling parity: No drift introduced by this PR. Confirmed independently: Lite's ServerSummaryItem (Lite/Services/LocalDataService.Overview.cs) has its own StatusDisplay/StatusBrush that switches on the same (IsOnline, HasCollectorErrors) shape but has no third AwaitingFirstCollection-style flag to desync against, so the specific defect this PR fixes can't occur there — and Lite has no FleetRollup/NeedsAttention/tooltip equivalent to keep in sync in the first place. This is already correctly tracked as a separate, non-blocking follow-up per the thread above rather than something this PR needs to port.

Security/perf: No I/O, no external input, no secrets. Filter/sort/tooltip-build are O(n) in-memory operations over already-loaded per-refresh fleet data — no regression risk at realistic fleet sizes. No T-SQL in this change, so the collector-query style rules don't apply.

No blocking issues.

@erikdarlingdata
erikdarlingdata merged commit 83b1df5 into dev Aug 21, 2026
6 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
Review caught this and it is right, and it is worse than the doc-accuracy nit
it was raised as. ServerCardStatus's comment claimed the
(IsOnline, HasCollectorErrors) pair was read in one place; CardBorderBrush read
IsOffline and HasCollectorErrors raw, so it agreed with the status word by
coincidence rather than by construction.

It already disagreed on one pair. A card that has never been connection-checked
but carries a collector-error marker draws the amber "collectors failing"
border -- verified against dev's own copy of the class, which returns #FFD54F
there -- while its word reads "Unknown" and its tooltip says the server has not
been checked yet. That is precisely the desync family this change exists to
close, sitting in the one member it had not reached.

The Overview loader only sets HasCollectorErrors when the connection check
succeeded, so the pair is unreached in practice. That is the argument for making
it unrepresentable rather than for leaving it alone: both flags are plain
settable properties, and "no caller does this today" is what the viewer's two
review rounds on #2429 each disproved.

So the border reads CardStatus, and so does IsOffline, which the offline
overlay binds. Precedence is unchanged -- a dark server, then the metric rows
worst-first, then failing collectors -- and the amber border is now literally
the same amber the status word is painted, because both render one state.

The claim in the doc comment is now true, and a test asserts it rather than
leaving it as prose for the next reviewer to re-check by hand: exactly one
`IsOnline switch` in the file. That is the assertion that would have caught the
overclaim when it was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
Both from review, and the first one is this PR's own defect turned on itself.

The notice had two colours and three sentences. A search term that matches
nothing leaves the attention filter with nothing to judge, so shown was 0, so
it took the green all-clear arm and read "Needs attention only, matching "xyz"
-- no servers to filter." in the colour that means everything is fine. The
fleet's problem servers were not found healthy; they were never looked at. That
is a colour contradicting its own text, which is the exact finding #2429's
review raised about the amber arm and which I ported without noticing it had a
third case.

So the notice has three states. Amber is a count of servers wanting attention,
green is a real all-clear over a real result set, and neutral is "nothing
matches that term, so no server was judged" -- which says what happened instead
of implying a verdict. The grid line underneath already named the search; the
notice above it was the one still claiming health.

Second: the notice appears and re-words itself with no page load, and carried
no role="status". util.js's noticeStrip is the house idiom for exactly that
kind of non-fatal live notice, so a screen reader now hears the count change
rather than the grid silently shrinking. It matters more here than usual --
this PR is otherwise deliberate about the keyboard path, using onActivate on
both affordances so they are reachable at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
…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>
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
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>
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
Gotcha 11 said a screen of Warning statuses "is not an incident" because
status is "collection freshness, not health". That is wrong, and wrong in
the worst available direction for a monitoring tool: it tells a new user
their first reflex on seeing amber should be to disregard it.

ServerHealthBands.ClassifyBand returns Warning for three unrelated causes.
A metric at warning severity -- CPU, blocking, deadlocks, memory, threads
-- returns Warning straight out of the overallMetricSeverity switch, and
that IS a real condition on the server. Awaiting-first-collection returns
Warning before that switch is reached. Stale collection returns it after,
through the hasCollectorErrors arm the viewer re-uses to carry staleness.

The advice survives in narrowed form, because the original observation was
not baseless: on a fresh install every server is awaiting first collection
at once, and at fleet scale staleness is routine. So an opening screen of
amber usually is not an incident. What it must not become is a rule for
every screen after that one.

It now names the three causes and points at the tooltip #2429 added, which
is built from the same metric rows shown underneath the status word, so it
cannot disagree with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant