Skip to content

Say which seat this viewer has, once, instead of one refusal at a time (#2479) - #2483

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/2479-seat-indicator
Aug 21, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/2479-seat-indicator

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Items 3 and 4 of #2479, shipped together because they are one user experience.

The gap

#2400 asked why a viewer on a jumpbox refuses "Current Active Queries". #2403 answered it by rewriting the individual refusals — four better messages, and the same discovery model: you learn your seat at the moment a write is denied, which is the worst moment to learn it. The viewer has known the answer since V8 (one has_table_privilege('config_mute_rules','INSERT') probe at connect, ViewerDataService.DetectReadOnlyAsync) and never showed it anywhere you could look on purpose.

Item 4 is why this gets worse in UAT rather than better. The two defaults are deliberately opposite:

Seat Setting Default Result
Local, on the service host postgres.connectAs admin read-write
Remote, over the LAN postgres.network.role viewer read-only

A UAT tester connecting from their own desktop is the second row by construction, so the read-only seat is the expected default rather than a misconfiguration — and nothing in the product said so.

What this adds

A Seat: field in the status bar, first among the fixed fields because it is the only one describing this connection rather than the fleet. The whole answer is in its tooltip: what a "write" means here (a row in the monitoring store, never anything sent at a monitored SQL Server), which config key decided it, that the two defaults are opposite on purpose, and how to change it. Same probe, same V8 fail-safe — this only makes the verdict visible before a write is attempted.

Unreachable is its own state, and never collapses into read-only

DetectReadOnlyAsync fails safe to read-only for an unexpected response but raises ViewerStoreUnreachableException for a failure to reach the store. #2117 built that split deliberately, because collapsing them told an operator whose service was simply down to go and change a database role. A status field is exactly where that gets quietly re-collapsed, so:

  • the state machine carries four values — Unknown, Unreachable, ReadOnly, ReadWrite;
  • the unreachable tooltip actively denies the read-only reading ("This is NOT a read-only seat — it is no connection at all") and names no role at all, so it cannot send anyone at connectAs;
  • the schema-skew arm, which did reach the store but never asked the seat question, leaves the field at Seat: -- rather than claiming a verdict nothing measured.

The catch (ViewerStoreUnreachableException) arm's body is pinned to contain ViewerSeatState.Unreachable and to contain no ViewerSeatState.ReadOnly at all.

Honest about what it can and cannot know

StoreIsOnThisMachine only orders the two sentences by which default is likelier to have decided this seat. It cannot claim which one did — a non-loopback store host does not prove a remote seat (#2279: a bring-your-own store on another host, reached from the service host, reads identically), so the likelier default leads and both are always stated.

Colours reuse the status bar's existing vocabulary rather than inventing one: ErrorBrush for an unreachable store (the same severity an erroring collector gets from CollectorHealthText), WarningBrush for a read-only seat — visible, because a tester who does not notice it is back to discovering the seat one refusal at a time, but not alarming, because on a remote seat it is correct — and the muted foreground for read-write and not-yet-probed.

Verification

  • Ran the shipped ViewerSeatIndicator.cs itself. Darling.Tests is net10.0-windows and cannot run on macOS, so the file — which has no WPF dependency — was <Compile Include>'d into a throwaway net10.0 console harness and all 33 assertions executed against the real source: 33/33 pass, including that the four labels are distinct, that the unreachable label and tooltip contain no read-only claim, that both defaults are named in both placements, and that the ordering flips with StoreIsOnThisMachine while neither is claimed.
  • Viewer and Darling.Tests both build clean, 0 errors.
  • Status bar counted from the file: 6 column definitions, 6 fields, SeatText ahead of CollectorHealthText.
  • Against dev the whole test class fails to compile — ViewerSeatIndicator and ViewerSeatState do not exist there.
  • Not verified: nothing has been run in a live WPF window, so the field's rendered width, its colour against all three themes, and multi-line tooltip wrapping are unexercised. Manual check for a reviewer with the viewer open: connect a remote (viewer-role) seat and confirm Seat: read-only in the warning colour with the tooltip naming postgres.network.role; stop the service and reconnect, and confirm Seat: not connected rather than Seat: read-only.

Onboarding

Makes docs/uat-onboarding.md §2.3's "How to tell which seat you have" subsection unnecessary (#2476) — the paragraph beginning "There is no global banner — it shows up per surface", along with its Settings-window recipe and the list of per-surface tells. §2.3's table of the two defaults, the "nothing is written to your monitored SQL Server" explanation and the "How to change it" recipe are all now in the tooltip too, so that section can shrink to a pointer at the status bar; keeping the prose is still reasonable, but it stops being the only place the answer exists.

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

#2479)

#2400 asked why a viewer on a jumpbox refuses "Current Active Queries", and
#2403 answered by rewriting the individual refusals. Four better messages,
and the same discovery model: you learn your seat at the moment a write is
denied, which is the worst moment to learn it. The viewer has known the
answer since V8 - one has_table_privilege probe at connect - and never
showed it anywhere you could look on purpose.

This gets worse in UAT rather than better, which is item 4. The two
defaults are deliberately opposite: a local seat takes postgres.connectAs,
which defaults to admin (read-write), while a seat reached over the LAN
takes postgres.network.role, which defaults to viewer (read-only). A UAT
tester connecting from their own desktop is the second case by
construction, so the read-only seat is the EXPECTED default rather than a
misconfiguration - and nothing in the product said so. The onboarding now
states it; the product still did not.

So the status bar gains a "Seat:" field, first among the fixed fields
because it is the only one describing THIS CONNECTION rather than the
fleet, with the whole answer in its tooltip: what a "write" means here (a
row in the MONITORING store, never anything sent at a monitored SQL
Server), which config key decided it, that the two defaults are opposite on
purpose, and how to change it. Same probe, same V8 fail-safe - this only
makes the verdict visible before a write is attempted.

Unreachable is a state of its own and never collapses into read-only.
DetectReadOnlyAsync fails safe to read-only for an unexpected RESPONSE but
raises ViewerStoreUnreachableException for a failure to REACH the store,
which #2117 built deliberately, because collapsing them told an operator
whose service was simply down to go and change a database role. A status
field is exactly where that gets quietly re-collapsed, so the state machine
carries four values (Unknown, Unreachable, ReadOnly, ReadWrite), the
unreachable tooltip actively denies the read-only reading and names no
role at all, and the schema-skew arm - which reached the store but never
asked the seat question - leaves the field at "Seat: --" rather than
claiming a verdict nothing measured.

StoreIsOnThisMachine only ORDERS the two sentences by which default is
likelier to have decided this seat. It cannot claim which one did: a
non-loopback store host does not prove a remote seat (#2279 - a
bring-your-own store on another host, reached from the service host, reads
identically), so the likelier default leads and both are always stated.

Colours reuse the status bar's existing vocabulary rather than inventing
one: ErrorBrush for an unreachable store, the same severity an erroring
collector gets; WarningBrush for a read-only seat, visible because a tester
who does not notice it is back to discovering the seat one refusal at a
time, but not alarming because on a remote seat it is correct; muted for
read-write and not-yet-probed, which are the unremarkable states.

The wording lives in a pure helper so it is pinned by tests rather than by
reading a XAML file, and the connect flow's arms are pinned structurally -
the helper cannot be wrong about a state nobody sets, and a later refactor
that adds an early return is exactly how the field goes stale and shows the
previous verdict, which is worse than showing none. The status bar's column
count is pinned against its field count for the same reason: a TextBlock at
Grid.Column="5" with five columns defined stacks silently on column 4.

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

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (ViewerSeatIndicator.cs, MainWindow.xaml/.xaml.cs, ViewerSeatIndicatorTests.cs). No blocking issues found.

Correctness

  • All exit paths from the connect flow in OnLoaded publish a seat state: happy path (ReadOnly/ReadWrite), schema-skew (Unknown, correctly — the probe never ran, so no verdict is claimed), ViewerStoreUnreachableException (Unreachable), and the generic connect-failure catch (Unreachable). The two earlier returns (config unreadable / darling.json missing) happen before _dataService exists, so they correctly leave SeatText at its XAML-default "Seat: --" rather than needing an explicit call.
  • Unreachable never collapses into ReadOnly ([BUG] Darling: remote Viewer fails TLS validation with the exact connection string --print-viewer-connection prints (VerifyFull + Root Certificate) #2117's split is preserved) — verified both in the C# and via the pinned test that scans the catch block body for the literal enum reference.
  • ErrorBrush/WarningBrush/ForegroundMutedBrush all exist in all three theme dictionaries (Dark/Light/CoolBreeze), so FindResource won't throw regardless of active theme.
  • ViewerSeatState/ViewerSeatIndicator are internal, and PerformanceMonitor.Darling.Viewer.csproj already has InternalsVisibleTo for Darling.Tests, so the new test file compiles against them without changes needed there.
  • Status bar column count now matches field count (6 and 6) — checked directly against the XAML diff, not just the pinned test's own arithmetic.

Lite/Darling parity

  • No Lite counterpart is touched, and I don't think one is owed here: Lite has no service/viewer split and no Postgres role-based read-only seat — it's a single process against a local DuckDB store, so there's no equivalent "which seat did I get" question for it to answer. The status bar's other four fields (Collectors:/Database:/Collection:/Servers:) stay identical in both apps; only the new Seat: field is Darling-only, which matches the concept it represents.

Style / tests

  • Comments follow the "explain WHY, at length, with issue numbers" convention this codebase already uses in Darling (mirrors the T-SQL collector convention in CONTRIBUTING.md even though this is C#).
  • Test assertions (exact tooltip substrings, ordering of the two default sentences by storeIsOnThisMachine, non-empty tooltip per state) all match the implementation string-for-string, including the \"admin\"/\"viewer\" quoting.

No security, injection, or performance concerns — this is pure UI text/state plumbing with no external input.

ApplySeatState is called from inside the catch that handles an unreachable
store, and a ResourceReferenceKeyNotFoundException thrown from a catch
block is unhandled. All three brushes exist in all three shipped themes, so
this is not doubt about the keys - it is the call site. A missing key in
some future theme would take the viewer down at the one moment it most
needs to stay up and explain itself, which is a poor trade for a colour.

TryFindResource with the muted foreground as the fallback, and the field's
existing brush as the last resort. The sibling status fields keep the hard
FindResource because none of them is written from an exception handler.

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

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed the diff (MainWindow.xaml, MainWindow.xaml.cs, new ViewerSeatIndicator.cs, new ViewerSeatIndicatorTests.cs).

Scope check: this is Darling-only (the read-only/read-write seat concept comes from the Postgres viewer/admin roles and doesn't exist in Lite, which uses an embedded local DuckDB with no equivalent role split), so there's no Lite/Darling parity drift to flag here.

Correctness:

  • Verified against the actual config code (DarlingConfig.NormalizeNetworkRole, ViewerSettings) that the tooltip's claimed defaults are accurate: postgres.network.role defaults to "viewer" (read-only) and postgres.connectAs defaults to "admin" (read-write) — matches LocalDefaultSentence/NetworkDefaultSentence in ViewerSeatIndicator.cs.
  • The Unreachable vs ReadOnly split ([BUG] Darling: remote Viewer fails TLS validation with the exact connection string --print-viewer-connection prints (VerifyFull + Root Certificate) #2117) is preserved correctly: DetectReadOnlyAsync only throws ViewerStoreUnreachableException for connection failures, and separately fails safe to IsReadOnly = true (no throw) for an uninterpretable response — so the ApplySeatState(_dataService.IsReadOnly ? ReadOnly : ReadWrite) call after the await in the happy path always reflects a real probe result, never a caught exception. Both catch arms in OnLoaded correctly apply Unreachable, never ReadOnly.
  • _dataService is assigned (line ~362) before every code path that reads _dataService?.StoreIsOnThisMachine in ApplySeatState, so the null-conditional there is defensive-only, not a real null path.
  • The <Grid> column count/x:Name re-indexing in MainWindow.xaml is consistent (6 columns for 6 fields; SeatText correctly leads the fixed fields after StatusText), and none of the shifted Grid.Column indices collide with other unrelated grids in the file.
  • TryFindResource + triple-fallback for SeatText.Foreground is a reasonable defensive choice given ApplySeatState is invoked from inside a catch block (a ResourceReferenceKeyNotFoundException there would be unhandled) — confirmed ErrorBrush/WarningBrush/ForegroundMutedBrush exist in all three shipped themes.

Style: C# conventions (naming, async/await, XML doc on the new public/internal surface, one class per file) look consistent with CONTRIBUTING.md's C# section. No T-SQL touched by this PR.

No correctness, parity, or security issues found. Nothing blocking from this pass.

@erikdarlingdata
erikdarlingdata merged commit 8f1d740 into dev Aug 21, 2026
6 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2479-seat-indicator branch September 12, 2026 20:29
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