Skip to content

Give a PostgreSQL target PostgreSQL tabs in the WPF viewer (#2530) - #2555

Merged
erikdarlingdata merged 9 commits into
devfrom
feat/2530-viewer-pg-tabs
Aug 22, 2026
Merged

Give a PostgreSQL target PostgreSQL tabs in the WPF viewer (#2530)#2555
erikdarlingdata merged 9 commits into
devfrom
feat/2530-viewer-pg-tabs

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Closes the WPF viewer half of #2530. With #2547 (web) already on dev, this is the rest of the issue.

What ships

git grep IsPostgres across PerformanceMonitor.Darling.Viewer returned nothing, so an Aurora target opened on the SQL Server correlated-lane Overview and got the whole nineteen-tab strip — tempdb, Trace Flags, Query Store, Plan Viewer, Always On — nearly all of it permanently empty and several tabs meaningless for the engine. It now gets six, the same six #2547 chose for the web, with the same ids and the same grouping so the two front ends do not teach one engine two shapes.

Tab Panels
Overview every PostgreSQL collector for this server, from collection_log composed against the catalog — status, last run, runs, failures, rows, and why for one that cannot run here
Activity Blocking sub-tab: the sampling denominator, chains, and a cycles expander. Query Shapes sub-tab: top statement shapes over per-database counters
Vacuum xmin horizon → autovacuum backlog → freeze headroom, in that causal order, on one tab
Waits Aurora's cumulative wait counters
I/O pg_stat_io per backend / object / context
Replication slot WAL retention and the xmin each slot pins

All nine PostgreSQL collectors land on exactly one tab, and that placement is derived from CollectorCatalog in both directions — a tenth collector turns EveryPostgresCollector_IsShownOnExactlyOnePostgresTab red naming itself. That is the check that did not exist while eight of them shipped MCP-only for three releases. The ViewerCollectorCoverageTests allow-list, which carried all nine as tracked debt with "remove each when the PostgreSQL tab ships", is now empty.

Six against nineteen is the design. The tabs that are missing (tempdb, Query Store, trace flags, plan cache, system_health, Always On) have no PostgreSQL analogue to fill, and the signals that matter here — wraparound headroom, the xmin horizon, vacuum backlog, WAL retention — have no SQL Server analogue either.

The Aurora-only panels are SHOWN, not hidden

pg_wait_stats and pg_statement_stats both gate on IsAurora, so on stock PostgreSQL they can never have content. Each panel prints CollectorEngineCapability.NotCollectedMessage — the same sentence the MCP surface and the web dashboard print, naming the server, the engine, the collector and the exact aurora_stat_* surface, ending "and never will". The defect #2530 is about is unexplained emptiness, not emptiness; hiding would also make the tab strip a different shape on two PostgreSQL servers in one fleet, and would re-derive in the viewer a gate the collectors already decide.

The Overview grid is catalog-driven for the same reason. A gated-off collector writes no collection_log row at all — dispatch filters it out, deliberately, because a fake SUCCESS/0-rows would be ~2,880 rows a day per server of noise — so a grid built from the log alone would drop precisely the row an operator most needs explained. Rows come from the nine catalog collectors and the log only fills them in. Three distinct states, because they need three different responses: a permanent engine gap (nothing to do), a collector that should be running with no rows in the window (something to chase), and one that ran and failed (its own error text).

One copy of the SQL

The nine DarlingPg*Reader classes move from the service's Mcp/ folder into PerformanceMonitor.Darling.Storage, which the service, the viewer and the tests all already reference. Namespace change only — no query text, no ordinal mapping, no signature is touched, and every existing reader test still pins the same constants.

Copying them into the viewer instead would have meant a second copy of, among others, a 200-line recursive blocking walk whose revisit guard, root attribution and truncation flag were each a separate review finding. The copy that drifts is never the one being read — the same reasoning CollectorEngineCapability's own comment gives for keeping one copy of a sentence both SKUs print.

ViewerCollectorCoverageTests.ReaderLayerText() follows that: it now scans the viewer's ViewerDataService*.cs plus the shared store readers those files NAME, one hop outwards, never the reverse. Coverage still means "the viewer reads it" — stop calling DarlingPgIoReader and pg_io_stats loses coverage immediately, proved below — without demanding a duplicate copy of the SQL purely to satisfy a text match.

Mechanics worth knowing

  • The six TabItems continue the same TabControl at indices 19–24 rather than living in a second one. For a PostgreSQL server the nineteen SQL Server tabs are collapsed and these six shown, so both sets keep fixed indices and every drill-down that navigates by an index constant is untouched. A pin asserts the PostgreSQL run is a contiguous block at the end, in registry order, with headers matching the XAML.
  • Each PostgreSQL index gets its own dispatch arm, never default: — falling through would run the SQL Server overview lanes (CPU %, wait ms/sec, buffer pool, file-I/O latency: four collectors that cannot run on this engine) at a PostgreSQL server, which is the exact defect being closed.
  • engine_kind and sql_engine_edition are selected by BOTH server reads. The sidebar uses ManagedServersSql on any seeded store — i.e. every real deployment — so a discriminator on only ServersSql would have left every PostgreSQL target on the SQL Server tab set while a unit test over the other query passed. Pinned.
  • Only a positive claim switches. IsPostgres is MonitoredEngineKind.IsPostgres(EngineKind), false for null, empty, unrecognised and sqlserver. Such a server keeps the SQL Server tabs and gets no engine badge at all rather than one reading "SQL Server" — the tabs it gets are a default, not a finding. An unrecognised token renders as the raw token, because the describer's "an unrecognised engine" is worded to sit mid-sentence in the capability messages and the literal string is what an operator would search their store for.
  • The database filter is hidden at a PostgreSQL target: it drives the SQL Server database-scoped reads and nothing else, and offering to filter views that never consult it is worse than not offering.
  • The blocking denominator prints whether or not the grid has rows. pg_blocking is a periodic sample, not an event log; "two chains" means something different in 60 captures than in 4, and the stored edge list cannot tell an absent capture from a capture that found nothing.
  • The display projections keep the store's sentinels out of the cells. -1 is the not-applicable sentinel (chosen over 0 precisely because 0 reads as "started this instant" / "retains nothing"), an untracked pg_stat_io write counter reads "not tracked" rather than 0 (Aurora backends do not write data files), a NULL recurrence is "unknown" and not "once", temp_blks_written is labelled in blocks because the block size is a compile-time server setting, and every timestamp goes through ViewerTimeHelper.ForDisplay.

Verification

Every pin was executed on macOS against the compiled Darling.Tests.dll through a throwaway net10.0 reflection host that loads types by name (enumerating them drags in PresentationFramework, which does not exist here). That is not the xUnit runner; CI is the arbiter for the Windows suites.

passed 170, failed 0

covering ViewerPostgresTabsTests (22), ViewerCollectorCoverageTests, ServerPageTabsTests, MonitoredEngineKindStoreTests, all ten moved DarlingPg*ReaderTests, ViewerDataServiceTests, ViewerServerTabCapabilityPinTests, ViewerGridPayloadColumnOrderPinTests, ViewerConfigChanges*Tests, FleetViewTests, DarlingEmptyEnumeration*Tests.

Every new guard was proved RED first, by reverting only the thing under test:

Mutation Pin that went red
stop calling DarlingPgIoReader from the viewer EveryCollectorTable_HasAViewerReader_OrIsAllowListed"pg_io_stats"
drop pg_wait_stats from the Waits tab's registry entry EveryPostgresCollector_IsShownOnExactlyOnePostgresTab and TheAuroraOnlyCollectors_AreShown_NotHidden
remove the Waits arm from LoadInnerTabAsync EveryPostgresTab_HasItsOwnArmInTheDispatch
rename the Vacuum TabItem header in the XAML TheRegistry_MatchesTheTabItemsDeclaredInTheXaml
stop filling PgWaitsNote.Text EveryPostgresPanel_ExplainsItsOwnEmptyState
call a PostgreSQL read from a SQL Server tab loader NoSqlServerTabLoader_CallsAPostgresRead
IsPostgres → "any non-empty token" TheTabSet_SwitchesOnlyOnAPositivePostgresClaim(sqlserver), (mysql)
build the Overview grid from the LOG instead of the catalog TheOverviewGrid_ShowsAGatedOffCollector_WithTheReason
let -1 render as a number TheNotApplicableSentinel_NeverRendersAsANumber
drop the discriminator from the sidebar's own query BothServerReads_CarryTheEngineDiscriminator

Also: full-solution build clean; --locked-mode restore passes for both Darling.Tests and the viewer (the one-line lock addition is the viewer's new explicit PerformanceMonitor.Collectors reference); the XAML parses as well-formed XML; bare-LF count is 0 across all 41 changed files; CHANGELOG.md staged through hash-object --no-filters so its diff is 4 lines rather than 5,000; and the FleetIdentifierScrubTests regex was run by hand over all changed files (0 offenders — its own run is vacuous in a .claude/worktrees/ checkout, since it excludes any path containing /.claude/).

What could NOT be verified

Said plainly rather than implied:

  • No pixels. No Windows box. Column widths, the six-tab strip, the three stacked grids on Vacuum, the Expander for cycles, the engine badge and the row-highlight DataTriggers are all unexercised. The pins assert structure, placement and prose — never layout.
  • No live PostgreSQL target. Nothing here ran against the PostgreSQL monitoring host. Specifically unverified against real rows: the -1 sentinel actually appearing in root_xact_duration_ms / safe_wal_size_bytes, Aurora's writes arriving as the "not tracked" flag rather than 0, vacuum_threshold being 0 on a never-analyzed table, and whether PgGridRowLimit (200) is the right ceiling on a real autovacuum backlog.
  • The Windows test suites did not run locally (net10.0-windows). The reflection host is not the xUnit runner; CI decides.
  • The engine switch itself is untested end to end. ApplyEngineTabSet() touches TabItem.Visibility and InnerTabs.SelectedIndex, which need a WPF dispatcher. The decision it makes is pinned; the application of it is not.

Corrections to #2530's plan

Recorded on the issue as well. The web half found five errors; these are the viewer's:

  1. "Twelve SQL Server tabs" is the web's count. The viewer has nineteen top-level inner tabs (65 counting inner ones), so the desktop version of this defect was materially worse than the issue described.
  2. The issue's §4 says "panels and tabs for the eight reads, web and viewer", which assumes the viewer consumes the same reads. It does not — the viewer talks to the store directly and has never gone through MCP. The real choice was duplicate the SQL or move it somewhere both can see; the issue does not contain that decision, and it was the largest one in this PR.
  3. Darling/README.md said "three of the seven" PostgreSQL collectors are outage predictors. Seven was stale twice over (Darling monitors PostgreSQL and Amazon Aurora PostgreSQL #2213's eighth, Collect pg_stat_database: temp-file spills, cache hit ratio, deadlocks and commit ratio in one read #2539's ninth). Corrected to nine, derived.
  4. Darling/README.md said the blocking-chain reads have no PostgreSQL equivalent. They have had one since the pg_blocking collector landed. Corrected.
  5. The parity framing was wrong here too, in the direction the web half already noted: the viewer's most valuable PostgreSQL screen turned out to be the one with no SQL Server counterpart at all — a per-collector report that says which of the nine ran and why one of them never will. Built by asking "what does the SQL Server viewer have", that screen does not exist.

Not closing #2530 — that is Erik's call.


Review round

Three claude[bot] findings, all real. Two of them were the same defect, so the fix is an invariant rather than two columns.

Finding Fix
PgSlotRow.Conflicting reaches no viewer column. A logical slot whose needed rows were vacuumed away by a recovery conflict — visible on the web, invisible on the desktop. Its own column (not folded into the invalidation reason: the cause is hot_standby_feedback, not max_slot_wal_keep_size, and the two need different responses), plus the invalidated row highlight.
The I/O grid shows less than the MCP tool for the same collector — no ExtendTimeMs, no avg_read_ms, hit_pct or pct_of_total_read_time. All four added. ContextMeaning moved to DarlingPgIoReader, beside the query that produces the value it explains, so the MCP surface and the viewer print one copy; it rides as the Context cell's tooltip, because it is a paragraph and it is the dimension with no SQL Server counterpart.
service'''s in the csproj comment — my own shell escaping. Fixed.

The category, not the instances. Two instances of "a field the shared reader goes to the trouble of returning that no screen shows" is the signal to stop fixing instances. EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason reflects over the ten reader row records against their display classes: every field reaches a column, or appears in FoldedFields naming the column that carries the fact and why that shape reads better. Derived both ways — a new reader field turns it red until something happens, and EveryFoldedField_StillExists_AndSaysWhy deletes an exemption the moment its field stops existing.

Proved red by deleting exactly the two columns review found, each pin naming itself:

FAIL  ViewerPostgresTabsTests.EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason
        PgSlotRow.Conflicting
FAIL  ViewerPostgresTabsTests.EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason
        PgIoRow.ExtendTimeMs

It found three more on its first run, which is the point of writing it instead of two columns:

  • PctTowardMultixactWraparound had no column at all. The multixact side has its own shutdown ceiling, reached independently — a workload heavy on row-level share locks or subtransactions gets there first, and nothing on the XID columns would say so.
  • AutovacuumFreezeMaxAge and AutovacuumMultixactFreezeMaxAge were renamed rather than shown; they are now columns, because the percentages beside them are graded against this cluster's settings and two databases at "80% to emergency" are different distances from trouble when their freeze_max_age differs.

Filled while the pin was pointing at them: the autovacuum grid's ANALYZE half (a table can be vacuumed on schedule and still hand the planner stale row counts), live tuples and the dead-tuple share, the manual-vs-automatic run split, and autovacuum_count — zero being the classic wraparound route, since relfrozenxid never advances on a table autovacuum has never processed. Statements gain the database OID (half the read's grain), one cache-hit ratio over both Aurora cache tiers and both miss sources, and temp blocks read beside written.

Merge note

dev moved while this was in review and the PR went CONFLICTING — worth flagging on its own, because a conflicting PR dispatches no workflows, so gh pr checks shows a lone passing check-branches and looks exactly like a settled green. Only mergeable / mergeStateStatus tell the truth. (All six checks had gone green on the first push before that happened.)

Merged dev in rather than rebasing — a force-push orphans the inline review anchors above. The text merge was clean, and carried a semantic conflict it could not see: #2548's new PgInt64IdentityWireShapeTests arrived naming DarlingPgStatementReader and DarlingPgBlockingReader, which this branch had already moved. One using.

Nothing about #2548's fix changes here — queryid was already rendered as a string in the viewer's statement projection, for the same reason it is now a string on the wire.

Re-verified after the merge: full solution build clean, and 146 pins re-executed through the reflection host including #2548's own new suites — 0 failures.

Three of my own, found re-reading the diff

Not review findings — mine, and each is the same shape: prose the markup did not deliver.

  • The framing notes were looked up by POSITION (All[0..2]) into a registry whose whole job is to be reorderable. The first reorder would have put the Vacuum note above the Activity grids, silently and correctly-compiling. NoteFor(id) instead.
  • The Query Shapes tab gave the per-database counters twice the height of the statement grid they exist to explain, which is backwards — they are the follow-up question (did this spill?), not a second list of equal standing.
  • An unreachable engine-name fallback. IsPostgres is only true for a token DescribeEngineKind recognises, so "an engine the store has not recorded" described a state that branch excludes. Deleted, rather than left to read later as a real case somebody should handle. (An unrecognised token is now also trimmed before it reaches the badge — raw did not have to mean untrimmed.)

Review round two, and the CI red

The red was mine, and the guard that caught it is exactly the kind this PR is arguing for.
DarlingPgReadSqlParsesLiveTests (from #2554's lane, merged in from dev) discovers the shipped PostgreSQL reads by reflection filtered on a hardcoded namespace string — and this branch moved the readers, so the filter matched nothing. Its own anti-vacuity floor failed the build saying precisely that: "the reflection filter has stopped matching, so this test is no longer checking anything." Without that assertion it would have gone green over an empty list.

The filter now takes the namespace from a reader type it has to resolve anyway, so it follows the readers wherever they live. Verified rather than assumed: on macOS that test skips before reaching its own count (no DARLING_TEST_PG), so "it passed" locally proves nothing — a throwaway host invoked the shipped private ShippedReadSql() directly and it discovers 12 constants across the nine readers, the number its own doc comment names. Proved red by restoring the literal: 0.

Two more claude[bot] findings, both real:

Finding Fix
Bytes() picks the display unit against the UNROUNDED quotient, then formats N1 — so one byte short of a megabyte rendered "1024.0 KB", a number expressed in its own next unit. Affects every byte column. Re-check once after rounding. Pinned at the MB/GB/TB boundaries and at the exact boundaries below them, so a fix that broke the ordinary case would not pass.
WPF applies the LAST matching DataTrigger, and IsInvalidated/IsInactive are not exclusive — an invalidated slot is usually also inactive, its subscriber having gone. Declaring inactive last painted the informational amber over the red on exactly the rows that needed the red. Swapped — and asserted as a rule over every row style on these tabs, not fixed on the one grid: each flag carries a severity, and a milder one may not follow a severer one. The Overview grid's pair is mutually exclusive today and is ordered that way anyway, so one rule covers the tab set rather than a rule plus an exception.

Both proved red by reverting only the thing under test.

Final state: all six checks green on 1f41186a, mergeable=MERGEABLE, mergeStateStatus=CLEAN, and the review run on that head posted no new findings. Not merging — that is Erik's call.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits August 22, 2026 23:58
The WPF viewer cannot reference the service, so the PostgreSQL reads it
needs for #2530 had to be reachable from somewhere both front ends see.
They move to PerformanceMonitor.Darling.Storage, which the service, the
viewer and the tests all already reference. Namespace change only: no
query text, no ordinal mapping and no signature is touched, and every
existing reader test still pins the same constants.

Copying them into the viewer instead would have meant a second copy of,
among others, a 200-line recursive blocking walk whose revisit guard,
root attribution and truncation flag were each a separate review
finding. Two copies of a query like that diverge, and the copy that
diverges is never the one being read - the same reasoning
CollectorEngineCapability's own comment gives for keeping one copy of a
sentence both SKUs print.

Storage gains InternalsVisibleTo("Darling.Tests") because the ordinal
pins reach the internal Map*Row helpers, which followed the readers; the
service keeps its own for everything that stayed behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`git grep IsPostgres` across the viewer returned nothing, so an Aurora
target opened on the SQL Server correlated-lane Overview and got the
whole nineteen-tab strip - tempdb, Trace Flags, Query Store, Plan
Viewer, Always On - nearly all of it permanently empty and several tabs
meaningless for the engine. It now gets six: Overview, Activity, Vacuum,
Waits, I/O and Replication, the same six #2547 chose for the web, with
the same ids and the same grouping so the two front ends do not teach
one engine two shapes.

Placement is DERIVED from CollectorCatalog in both directions rather
than enumerated. All nine PostgreSQL collectors reach exactly one tab; a
tenth turns the pin red naming itself, which is the check that did not
exist while eight of them shipped MCP-only for three releases. The
ViewerCollectorCoverageTests allow-list, which carried all nine as
tracked debt, is now empty - and its reader-layer scan now follows the
shared store readers the viewer NAMES, one hop outwards, so coverage
still means "the viewer reads it" without demanding a duplicate copy of
the SQL to satisfy a text match.

Both Aurora-only panels are SHOWN on stock PostgreSQL, not hidden. Each
prints CollectorEngineCapability.NotCollectedMessage - the same sentence
the MCP surface and the web print, naming the server, the engine, the
collector and the exact aurora_stat_* surface, ending "and never will".
The defect here is unexplained emptiness, not emptiness; hiding would
also make the tab strip a different shape on two PostgreSQL servers in
one fleet. For the same reason the Overview grid is built from the
CATALOG rather than from collection_log: a gated-off collector writes no
log row at all, so a log-driven grid would drop precisely the row an
operator most needs explained.

Mechanics worth knowing:

- The six TabItems CONTINUE the same TabControl at indices 19-24 rather
  than living in a second one. For a PostgreSQL server the nineteen SQL
  Server tabs are collapsed and these six shown, so both sets keep fixed
  indices and every drill-down that navigates by an index constant is
  untouched.
- Each PostgreSQL index gets its OWN dispatch arm. Falling through to
  `default:` would run the SQL Server overview lanes - four collectors
  that cannot run on this engine - at a PostgreSQL server.
- engine_kind and sql_engine_edition are selected by BOTH server reads.
  The sidebar uses ManagedServersSql on any seeded store, i.e. every real
  deployment, so a discriminator on only ServersSql would have left every
  PostgreSQL target on the SQL Server tabs while a unit test passed.
- Only a POSITIVE claim switches. A null or unrecognised token keeps the
  SQL Server tabs and shows no engine badge at all, because the tabs such
  a server gets are a default rather than a finding.
- The display projections keep the store's sentinels out of the cells:
  -1 is not a number, an untracked pg_stat_io write counter is not 0, a
  NULL recurrence is "cannot tell" and not "once", temp blocks are not
  bytes, and every timestamp goes through ViewerTimeHelper.

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

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review

This is a large, well-scoped change (WPF viewer parity with the web dashboard's PostgreSQL tabs from #2547), and the mechanics are sound: tab ids/grouping match the web registry exactly, the index-continuation scheme (19-24) keeps SQL Server drill-downs untouched, every dispatch arm is explicit (no default: fallthrough), the nine reader classes are confirmed namespace-only renames (verified with git diff -M), and the -1/sentinel/naive-UTC handling in the display layer is careful and well-tested. ViewerPostgresTabsTests genuinely enforces the claims made in the PR description (checked several pins by hand against the registry/XAML/dispatch).

Found two real content-parity gaps versus the web dashboard for the same collectors (left as inline comments), plus one typo. Nothing here is a crash risk; the gaps are silently-dropped diagnostic signal rather than incorrect behavior.

Focus was correctness, Lite/Darling(web) parity, and security — no missing-index DMV concerns apply (no new T-SQL; this PR touches C#/XAML and PostgreSQL reads/queries only). Didn't attempt to build/run the WPF app (Windows-only; consistent with the PR's own "no pixels" caveat).

Comment on lines +286 to +325
internal sealed class SlotRow
{
public string SlotName { get; init; } = "";
public string SlotType { get; init; } = "";
public string ActiveText { get; init; } = "";
public string WalStatus { get; init; } = "";
public string RetainedWal { get; init; } = "";
public string RetainedWalTrend { get; init; } = "";
public string SafeWalSize { get; init; } = "";
public string XminAge { get; init; } = "";
public string CatalogXminAge { get; init; } = "";
public string InactiveSince { get; init; } = "";
public string DatabaseName { get; init; } = "";
public string Plugin { get; init; } = "";
public string InvalidationReason { get; init; } = "";
public bool IsInvalidated { get; init; }
public bool IsInactive { get; init; }
}

internal static SlotRow Slot(DarlingPgSlotReader.PgSlotRow row) => new()
{
SlotName = row.SlotName,
SlotType = row.SlotType ?? "",
ActiveText = row.IsActive ? "active" : "INACTIVE",
WalStatus = row.WalStatus ?? "",
RetainedWal = Bytes(row.RetainedWalBytes),
RetainedWalTrend = ByteDelta(row.FirstRetainedWalBytes, row.RetainedWalBytes),
SafeWalSize = Bytes(row.SafeWalSizeBytes),
XminAge = Count(row.XminAge),
CatalogXminAge = Count(row.CatalogXminAge),
InactiveSince = Timestamp(row.InactiveSince),
DatabaseName = row.DatabaseName ?? "",
Plugin = row.Plugin ?? "",
InvalidationReason = row.InvalidationReason ?? "",
/* An invalidated slot has already lost its WAL: the replica behind it needs rebuilding, and that
is a different day from an inactive slot that is merely accumulating. */
IsInvalidated = !string.IsNullOrEmpty(row.InvalidationReason)
|| string.Equals(row.WalStatus, "lost", StringComparison.OrdinalIgnoreCase),
IsInactive = !row.IsActive,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PgSlotRow.Conflicting is never surfaced here or in the XAML's PgReplicationSlotsGrid (no Conflicting property on SlotRow, no column, no row-highlight trigger). This isn't a derived/computed field — it's a raw stored column the shared reader already reads (DarlingPgSlotReader.cs selects conflicting straight off collect.pg_replication_slot_stats), and the web dashboard renders it as its own "Conflicting" column (PG_SLOT_COLUMNS in server-tabs.js, fed from DarlingMcpPgSlotTools.cs's conflicting = r.Conflicting).

A conflicting logical replication slot (one whose retained rows got vacuumed away, breaking it) is exactly the kind of "quiet, slow, predictable failure" this PR's own Overview/Vacuum design philosophy is built around surfacing — but on the WPF viewer this signal is currently invisible. An operator using only the desktop viewer would have no way to see it, while the same operator on the web dashboard would.

Given IsInvalidated/IsInactive already drive row highlighting here, adding Conflicting (property + XAML column, maybe folded into the existing highlight logic or its own trigger) looks like a small, self-contained follow-up rather than something that needs new plumbing — the data is already flowing through PgSlotRow.

Comment on lines +264 to +284
internal static IoRow Io(DarlingPgIoReader.PgIoRow row) => new()
{
BackendType = row.BackendType ?? "",
ObjectType = row.ObjectType ?? "",
Context = row.Context ?? "",
Reads = Count(row.Reads),
ReadTimeMs = Math.Round(row.ReadTimeMs, 1),
Hits = Count(row.Hits),
Extends = Count(row.Extends),
Evictions = Count(row.Evictions),
Reuses = Count(row.Reuses),
/* "not tracked", not 0. On Aurora the whole pg_stat_io write side is NULL because backends there
do not write data files, and the read carries WriteCountersTracked precisely so this cell can
tell that apart from a server that wrote nothing. */
Writes = row.WriteCountersTracked ? Count(row.Writes) : "not tracked",
WriteTimeMs = row.WriteCountersTracked
? Math.Round(row.WriteTimeMs, 1).ToString("N1", CultureInfo.CurrentCulture)
: "not tracked",
OpSize = Bytes(row.OpBytes),
StatsReset = Timestamp(row.StatsReset),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The WPF I/O tab shows noticeably less than the web dashboard's get_pg_io_stats panel for the same collector/tab. DarlingMcpPgIoTools.cs computes context_meaning (what the backend/object/context combination means), avg_read_ms (ReadTimeMs / Reads), hit_pct (Hits / (Hits+Reads) * 100), and pct_of_total_read_time on top of the same PgIoRow, and the web's PG_IO_COLUMNS renders all four — none of them appear here or in IoRow/the XAML grid. ExtendTimeMs (the raw field the reader already returns) isn't projected at all either, unlike Reads's ReadTimeMs.

These are cheap derivations (a couple of divisions, already done once in DarlingMcpPgIoTools.cs) rather than new data, so this looks like it was simply not ported over rather than a deliberate simplification — worth a quick pass to close the gap, or a one-line note if it's intentional scope-trimming for this PR.

</ItemGroup>

<ItemGroup>
<!-- #2530: the PostgreSQL reads moved here from the service'''s MCP folder so the WPF viewer can run the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Typo: service'''s (triple apostrophe) → should be service's.

erikdarlingdata and others added 6 commits August 23, 2026 00:20
All three claude[bot] findings were real. Two of them are the same
defect - a field the shared reader goes to the trouble of returning that
no viewer column shows - so the fix is an invariant rather than two
columns, per the rule that the second instance of something is a sign
you are fixing the wrong level.

EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason
reflects over the ten reader row records against their display classes:
every field must reach a column, or appear in FoldedFields naming the
column that carries the fact and why that shape reads better. Derived
both ways - a new reader field turns it red until something happens, and
an exemption for a field that no longer exists is deleted the moment the
reader stops returning it. Proved red by deleting exactly the two
columns review found, each naming itself.

It found three more on its first run, which is the point:
PctTowardMultixactWraparound had no column at all (the multixact side
has its own shutdown ceiling and a subtransaction-heavy workload reaches
it first, with nothing on the XID columns to say so), and the two
freeze_max_age settings were renamed rather than shown.

The findings themselves:

- Replication gains a Conflicting column, folded into the invalidated
  row highlight. A logical slot whose needed rows were vacuumed away by a
  recovery conflict is a different failure from invalidation-by-WAL-size,
  with a different cause (hot_standby_feedback, not
  max_slot_wal_keep_size), so it is its own column rather than folded
  into the invalidation reason. It was visible on the web and invisible
  on the desktop.
- I/O gains ExtendTimeMs plus the three derivations the MCP tool already
  computed - avg read ms, a per-combination hit ratio, and each row's
  share of the window's read time (which is how the grid's order was
  decided, made legible). ContextMeaning moved to DarlingPgIoReader,
  beside the query that produces the value it explains, so the MCP
  surface and the viewer print one copy; it rides as the Context cell's
  tooltip because it is a paragraph.
- The csproj comment's triple apostrophe, from my own shell escaping.

Also filled while the pin was pointing at them: the autovacuum grid's
ANALYZE half (a table can be vacuumed on schedule and still give the
planner stale row counts), live tuples and the dead-tuple SHARE, the
manual-vs-automatic run split, and autovacuum_count - zero being the
classic wraparound route, since relfrozenxid never advances on a table
autovacuum has never processed. Statements gain the database OID (half
the read's grain), one cache-hit ratio over both Aurora cache tiers, and
temp blocks READ beside written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge itself was clean, but it carried a SEMANTIC conflict a text
merge cannot see: PgInt64IdentityWireShapeTests arrived on dev naming
DarlingPgStatementReader and DarlingPgBlockingReader, which this branch
had already moved to PerformanceMonitor.Darling.Storage. One using.

Nothing about #2548's fix changes here — queryid was already rendered as
a string in the viewer's statement projection, for the same reason it is
now a string on the wire: a PostgreSQL int8 queryid routinely exceeds
2^53, and the one field whose entire purpose is joining back to
pg_stat_statements must not go through anything that rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three note assignments indexed All[0..2], which couples them to the
registry's STRIP order - a list whose whole job is to be reorderable.
The first reorder would have put the Vacuum note above the Activity
grids, silently and correctly-compiling. NoteFor(id) instead, returning
empty for an id the registry does not carry, because a missing note must
never take a tab down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IsPostgres is only true for a token DescribeEngineKind recognises, so
inside that branch EngineDescription always has words - the local
function offering 'an engine the store has not recorded' was describing
a state this code path excludes, which is the kind of defensive branch
that later reads as a real case somebody should handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two small ones the prose already promised and the markup did not deliver.

The Query Shapes tab gave the per-database counters TWICE the height of
the statement grid they exist to explain, which is backwards: they are
the follow-up question (did this spill?), not a second list of equal
standing. Swapped.

And an unrecognised engine token reaches the badge raw so an operator
can search their store for it; raw did not have to mean untrimmed, and
a hand-edited row with surrounding whitespace would have rendered it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +56 to +67
string[] units = { "B", "KB", "MB", "GB", "TB", "PB" };
double scaled = value;
var unit = 0;
while (scaled >= 1024 && unit < units.Length - 1)
{
scaled /= 1024;
unit++;
}

return unit == 0
? $"{value:N0} B"
: string.Create(CultureInfo.CurrentCulture, $"{scaled:N1} {units[unit]}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bytes() picks the display unit by comparing the unrounded scaled value against 1024, but then formats with N1 (one decimal). That means a value whose unrounded quotient sits just under 1024 in a given unit renders as 1024.0 in that unit instead of bumping to the next one:

PgDisplay.Bytes(1048575)   // 1 MB - 1 byte → "1024.0 KB", not "1.0 MB"
PgDisplay.Bytes(1073741823) // 1 GB - 1 byte → "1024.0 GB"... wait, "1024.0 MB", not "1.0 GB"

This affects every column built on Bytes()/ByteDelta()RetainedWal, SafeWalSize, TotalSize, OpSize, PeakMemory, WalWritten, TempBytes — whenever the underlying byte count lands just below a power-of-1024 boundary. Only Bytes(-1) and Bytes(0) are pinned in ViewerPostgresTabsTests, so this boundary case isn't caught.

Fix: re-check after rounding, e.g.

while (scaled >= 1024 && unit < units.Length - 1)
{
    scaled /= 1024;
    unit++;
}
if (unit < units.Length - 1 && Math.Round(scaled, 1) >= 1024)
{
    scaled /= 1024;
    unit++;
}

Comment on lines +3978 to +3983
<DataTrigger Binding="{Binding IsInvalidated}" Value="True">
<Setter Property="Background" Value="#33FF6B6B"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsInactive}" Value="True">
<Setter Property="Background" Value="#33FFB86B"/>
</DataTrigger>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both DataTriggers set the same Background property, and WPF applies the last matching trigger when more than one condition is true simultaneously. IsInactive is declared after IsInvalidated, so it wins when both are true.

Per ViewerPostgresDisplay.cs's Slot() projection, these two flags are not mutually exclusive — IsInvalidated is set for an invalidated/lost/conflicting slot regardless of IsActive, and an invalidated slot is commonly also inactive (its subscriber has disconnected). So the common case of "this slot is invalidated" renders with the merely-informational orange "inactive" highlight instead of the red "invalidated" one, understating severity to an operator scanning row colors by background.

Swapping the declaration order fixes it (last-wins then favors the more severe state):

<DataTrigger Binding="{Binding IsInactive}" Value="True">
    <Setter Property="Background" Value="#33FFB86B"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsInvalidated}" Value="True">
    <Setter Property="Background" Value="#33FF6B6B"/>
</DataTrigger>

(Note: the same ordering pattern already exists pre-PR elsewhere in this file for IsAborted/IsAttention, so this isn't a novel mistake — but it's new code in this PR and the two flags here are more likely to co-occur than most other pairs in the file.)

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

This PR is C#/WPF (the Darling desktop viewer's new PostgreSQL tabs) — no T-SQL install scripts touched, so the T-SQL style checklist doesn't apply here. Lite ships no PostgreSQL support at all (no IsPostgres/engine-discriminator concept anywhere in Lite/), so there's no Lite/Darling parity gap to flag for this change; the relevant parity axis (viewer vs. web dashboard) is explicitly addressed in the PR description and pinned by ViewerPostgresTabsTests.

Reviewed in depth: the shared-reader move to PerformanceMonitor.Darling.Storage, the new ViewerDataService.Postgres.cs reads (parameterized, correct ordinal mapping, correct Aurora-gap-vs-fault distinction in BuildPostgresCollectorHealth), ViewerPostgresDisplay.cs's sentinel/formatting rules, the tab registry/dispatch wiring in ViewerPostgresTabs.cs + ViewerServerTab.Postgres.cs + ViewerServerTab.xaml.cs, and the XAML/csproj/DarlingPgIoReader.cs changes. No SQL injection surface, no missing await/sync-over-async, no dispatch fallthrough or index drift, no project-reference direction problems.

Two real findings, posted as inline comments:

  1. ViewerPostgresDisplay.csBytes() picks its display unit before rounding, so a value just under a 1024 boundary (e.g. 1048575) renders as "1024.0 KB" instead of "1.0 MB". Cosmetic, but affects every byte-based column (WAL retained, safe WAL size, I/O op size, temp bytes, etc.). Not covered by the existing Bytes(-1)/Bytes(0) pins.
  2. ViewerServerTab.xaml — the Replication grid's row-highlight DataTriggers can silently downgrade severity. IsInactive (orange) is declared after IsInvalidated (red), and WPF's last-trigger-wins means an invalidated and inactive slot — a common combination, since a slot's subscriber typically disconnects once it's invalidated — shows the less-severe orange instead of red.

Both are narrow and non-blocking, but real bugs in new code from this PR.

**The CI red is mine, and the guard that caught it did its job.**
DarlingPgReadSqlParsesLiveTests discovers the shipped PostgreSQL reads by
reflection, filtered on a HARDCODED namespace string — and this branch
moved the readers, so the filter matched nothing and its own anti-vacuity
floor failed the build saying exactly that: "the reflection filter has
stopped matching, so this test is no longer checking anything". That
assertion is the reason a silent pass was not what happened instead.

The filter now takes the namespace from a reader TYPE it has to resolve
anyway, so it follows the readers wherever they live. Verified the fix
rather than assuming it: on macOS that test SKIPS before reaching its own
count (no DARLING_TEST_PG), so "it passed" proves nothing — a throwaway
host invoked the shipped private ShippedReadSql() directly and it
discovers 12 constants across the nine readers, which is the number its
own doc comment names. Proved red by restoring the literal: 0.

**Two review findings, both real.**

Bytes() chose the display unit against the UNROUNDED quotient and then
formatted with N1, so one byte short of a megabyte rendered "1024.0 KB" —
a number expressed in its own next unit, which reads as a typo rather
than a size, on every column built over bytes. Re-checked after rounding.

WPF applies the LAST matching DataTrigger, and IsInvalidated/IsInactive
are not exclusive: an invalidated replication slot is usually also
inactive, because its subscriber has already gone. Declaring inactive
last painted the informational amber over the red on exactly the rows
that needed the red. Swapped — and asserted as a RULE over every row
style on these tabs rather than fixed on the one grid review found, since
the same ordering pattern is one edit away from recurring: each flag
carries a severity and a milder one may not follow a severer one. The
Overview grid's pair is mutually exclusive today and is ordered that way
anyway, so one rule covers the tab set instead of a rule and an
exception.

Both proved red by reverting only the thing under test.

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

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

Reviewed the full diff (46 files, ~2.8k additions) against CONTRIBUTING.md. This closes the WPF-viewer half of #2530, giving Darling's desktop viewer the same six PostgreSQL inner tabs (Overview, Activity, Vacuum, Waits, I/O, Replication) the web dashboard shipped in #2547.

Parity check. The relevant parity boundary here is Darling's WPF viewer vs. Darling's web dashboard (not Lite — Lite has no PostgreSQL target, confirmed in Darling/README.md). I diffed ViewerPostgresTabs.All against POSTGRES_TABS in server-tabs.js: ids, headers, and tab order match exactly (overview/activity/vacuum/waits/io/replication). Content shape intentionally differs (grids vs. tiles/charts), and the PR docs are explicit and honest about that rather than papering over it.

Correctness.

  • All nine PostgreSQL reads are parameterized ($1, $2, ... via typed NpgsqlParameter) — no SQL injection surface, no string-built predicates.
  • The nine reader classes moved from Service/Mcp into Darling.Storage are namespace-only changes (verified via git diff --stat); no query text or ordinal mapping changed, confirmed by the existing reader tests still pinning the same constants.
  • DarlingPgIoReader (new in this PR) fixes a real bug in the same commit that introduced it: binding DateTime.SpecifyKind(..., Unspecified) rather than Utc, since Npgsql infers timestamptz for Kind=Utc and the store's naive-UTC columns then get zone-shifted, silently dropping rows east of UTC. The commit message says this was "found by round-2 review" — good catch, good fix.
  • The -1/absent/NULL sentinel handling in ViewerPostgresDisplay.cs (PgDisplay) is careful throughout, and is asserted by dedicated tests (TheNotApplicableSentinel_NeverRendersAsANumber, ASize_NeverRendersExpressedInItsOwnNextUnit, AnUntrackedIoCounter_SaysSo_RatherThanReadingZero).
  • Tab-index wiring (PgOverviewInnerTabIndex..PgReplicationInnerTabIndex = 19-24) is cross-checked against the actual XAML TabItem order and the LoadInnerTabAsync dispatch by ViewerPostgresTabsTests (TheRegistry_MatchesTheTabItemsDeclaredInTheXaml, EveryPostgresTab_HasItsOwnArmInTheDispatch), so drift between registry/XAML/dispatch would fail CI rather than silently misroute a tab click.
  • Row-highlight severity ordering (WPF applies the last matching DataTrigger) is asserted generically across every PostgreSQL grid style (EveryPostgresRowStyle_DeclaresItsSeverestHighlightLast), not just spot-fixed where review found it.
  • DarlingServer.IsPostgres/EngineDescription correctly treat absence (null/unrecognized/empty engine_kind) as "no claim" rather than defaulting to either engine, preserving pre-PostgreSQL targets have no UI on either SKU, and both UIs render SQL Server tabs at them #2530 behavior for servers that haven't reconnected since the engine_kind column landed. Both ServersSql and ManagedServersSql carry the discriminator (the sidebar uses the latter on any seeded store), verified by BothServerReads_CarryTheEngineDiscriminator.

Minor observation (not blocking): PgDisplay.CountDelta doesn't guard the -1 not-applicable sentinel the way ByteDelta explicitly does. It's currently only called with DeadTupleTrend (dead-tuple counts, never sentinel-eligible per the reader), so it's not a live bug, just a latent inconsistency if a future caller feeds it a sentinel-capable field.

No missing-index DMV suggestions, no T-SQL in this diff (Darling stores to PostgreSQL, and object names are appropriately unqualified per this codebase's existing search_path convention for reader queries, matching every other ViewerDataService*.cs file).

Nice work — this is an unusually well self-verified PR (the coverage/registry/dispatch/XAML consistency pins in ViewerPostgresTabsTests.cs are doing real work, not just padding coverage numbers).

@erikdarlingdata
erikdarlingdata merged commit 8ec9e8c into dev Aug 22, 2026
7 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/2530-viewer-pg-tabs branch September 12, 2026 20:32
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