Skip to content

feat(admin): SPA degraded banner + conflict hatching for fan-out (Phase 2-C PR-2)#687

Merged
bootjp merged 1 commit intomainfrom
feat/keyviz-spa-fanout-banner
Apr 27, 2026
Merged

feat(admin): SPA degraded banner + conflict hatching for fan-out (Phase 2-C PR-2)#687
bootjp merged 1 commit intomainfrom
feat/keyviz-spa-fanout-banner

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 27, 2026

Summary

Phase 2-C PR-2, per the design doc landed in #685 §6: wire the fan-out Fanout / conflict shapes shipped by PR #686 into the admin SPA so operators can see when a peer failed and which rows are soft due to a leadership flip.

Backend is unchanged. PR #686's KeyVizMatrix.Fanout block is currently emitted on every fan-out request but the SPA was discarding it.

What this PR adds

Piece Behavior
Header counter cluster view (N of M nodes) in the per-matrix metadata strip. Hidden when fanout is absent (single-node mode, no behavior change).
FanoutBanner Degraded-mode card above the heatmap when responded < expected. Lists every failed node + error string. Hidden when the cluster is healthy.
ConflictOverlay SVG hatch over rows whose conflict === true. Layered inside the scroll container so the hatch tracks horizontal scroll (same idiom as TimeAxis). 4 px diagonal stripes, alpha 0.45, inheriting currentColor so it works against both light and dark themes.
RowDetail pill Hovering a conflicting row reveals a conflict pill with a hover-tooltip explaining the leadership-flip semantics.

Five-lens self-review

  1. Data loss — n/a; SPA-only change reading existing fields.
  2. Concurrency / distributed — n/a; single browser tab consuming an existing API.
  3. Performance — Banner: zero-cost when healthy (null returned). Hatch: SVG with at most 1024 <rect> elements (matches KeyVizRow budget); empirically negligible. Overlay only renders when at least one row has conflict === true.
  4. Data consistency — UI surfaces existing server signals; semantics come from PR docs: KeyViz cluster fan-out proposal (Phase 2-C) #685 design §4.2 and feat(admin): KeyViz cluster fan-out aggregator (Phase 2-C PR-1) #686 implementation. Not relitigated here.
  5. Test coveragetsc -b --noEmit and vite build clean. UI behavior (banner visibility, hatch presence, header text) is hard to assert in tsc alone; manual verification documented below.

Test plan

  • npm run lint (tsc -b --noEmit) — clean
  • npm run build (Vite) — clean, output writes to internal/admin/dist
  • Manual: make run with --keyvizEnabled --keyvizFanoutNodes=127.0.0.1:8080. Open /keyviz; header reads cluster view (1 of 1 nodes), no banner, no hatch.
  • Manual: configure --keyvizFanoutNodes=127.0.0.1:8080,127.0.0.1:9999 (port 9999 unused). Header reads 1 of 2 nodes, the failed-node banner appears with connection refused for 127.0.0.1:9999.
  • Manual: smoke a leadership flip mid-window (or fake conflict=true server-side via /admin/api/v1/keyviz/matrix?...); the affected row renders with diagonal hatch overlay; hovering reveals the conflict pill.

Out of scope

  • Per-cell hatching — Phase 2-C+ once the proto extension lands and conflict becomes per-cell instead of per-row.
  • Per-node generated_at skew indicator — design §9 Q3, deferred.
  • Banner auto-dismiss / sound notification — out of scope; an operator who has the page open will see the banner. CI alert pages are a separate system.

Closes the SPA half of Phase 2-C; the proto extension follows in PR-3.

Summary by CodeRabbit

  • New Features
    • KeyViz now displays cluster fan-out status with node availability and responsiveness counts.
    • Added visual indicators for rows experiencing conflicts during leadership transitions.
    • Row details now include conflict status labels with informative tooltips.

…se 2-C PR-2)

Wires the Phase 2-C fan-out wire format (PR #686) into the admin
SPA. The server's KeyVizMatrix.fanout block was previously ignored
by the SPA; an operator with --keyvizFanoutNodes configured got the
aggregated heatmap but no signal that one peer had failed or that a
row's totals were soft due to a leadership flip.

API client (web/admin/src/api/client.ts):
- KeyVizFanoutNode and KeyVizFanoutResult mirror the Go shapes from
  internal/admin/keyviz_fanout.go.
- KeyVizRow gains the optional `conflict` flag; KeyVizMatrix gains
  the optional `fanout` block.

Page (web/admin/src/pages/KeyViz.tsx):
- Header counter: "cluster view (N of M nodes)" appears in the
  per-matrix metadata strip when fanout is present. A healthy
  cluster reads "3 of 3 nodes" inline; nothing else changes.
- FanoutBanner: a degraded-mode card above the heatmap when
  responded < expected, lists every failed node with its error
  string and explains what is and is not in the heatmap. Hidden
  when the cluster is healthy so the page stays clean.
- ConflictOverlay: an SVG layer inside the scroll container with
  a 4px diagonal hatch over rows whose conflict flag is true. SVG
  rather than canvas because the hatch needs to scroll with the
  heatmap (same idiom TimeAxis already uses) and SVG's pattern
  primitive is the lowest-overhead way to express it.
- RowDetail: shows a "conflict" pill with a hover-tooltip
  explaining the leadership-flip semantics so an operator who
  notices the hatch can find out why without leaving the page.

The design doc 6 (PR #685) is the spec for these UI bits;
nothing in this PR changes the wire format.

Type check (tsc -b --noEmit) and Vite build clean.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR extends the KeyViz monitoring interface to track cluster degradation and row-level conflicts. New TypeScript interfaces carry fanout metadata (responding and expected nodes) from the server. The UI displays this metadata via a new banner, summary updates, visual overlay, and status indicators.

Changes

Cohort / File(s) Summary
API Client Types
web/admin/src/api/client.ts
Added conflict optional property to KeyVizRow; introduced KeyVizFanoutNode and KeyVizFanoutResult interfaces to describe per-node fan-out outcomes and response counts; added fanout optional property to KeyVizMatrix.
KeyViz UI Page
web/admin/src/pages/KeyViz.tsx
Imported KeyVizFanoutResult type; added conditional FanoutBanner component; enhanced heatmap summary with degraded-cluster node counts; rendered new ConflictOverlay SVG to visually indicate rows with conflicts; added conflict status pill with tooltip in RowDetail header.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A whisker-twitch, a cluster heals,
Fanout flows and conflict reveals,
Rows now speak in banner bright,
Degraded nodes in morning light,
Overlays hatch where disagreement dwells! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: adding a degraded banner for fan-out and conflict hatching visualization in the admin panel, matching the PR's core objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/keyviz-spa-fanout-banner

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
web/admin/src/pages/KeyViz.tsx (2)

240-271: FanoutBanner: solid. One small thought on the <= expected guard.

Returning null when responded >= fanout.expected correctly hides the banner for healthy clusters and is also defensive against a malformed response where responded > expected. The failed-node enumeration via nodes.filter((n) => !n.ok) lines up with KeyVizFanoutNode.ok semantics.

Two optional polish items for a follow-up (not blocking):

  • The "node{fanout.responded === 1 ? "" : "s"}" pluralization assumes English; if the SPA gains i18n later this will need to move to an ICU-style plural rule. Fine for now.
  • Consider giving the banner role="status" (or role="alert" if you want to interrupt screen readers when a previously-healthy cluster degrades) so assistive tech announces the degradation when the response transitions from healthy → degraded mid-session.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/admin/src/pages/KeyViz.tsx` around lines 240 - 271, Add an ARIA role to
the FanoutBanner container so assistive tech announces cluster degradation when
the UI transitions; update the <div className="card border-danger/40"> in the
FanoutBanner component to include an appropriate role attribute (e.g.,
role="status" for passive announcement or role="alert" to interrupt) and ensure
the role is applied to the outermost element rendered by the FanoutBanner
function.

313-326: Hardcoded SVG pattern id — fine today, but useId() would future-proof it.

id="keyviz-conflict-hatch" is global to the document. There's only one Heatmap rendered in this app today, so this works. However, if a future change renders two heatmaps simultaneously (e.g., a comparison view, or even a brief overlap during route transitions with React 19's concurrent rendering), both <defs> will register the same id and fill="url(#keyviz-conflict-hatch)" will resolve to whichever happens to be in the DOM tree first.

Since you're already on React 19, useId() is the idiomatic fix:

♻️ Suggested refactor
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
 function ConflictOverlay({ rows, cellH, width }: ConflictOverlayProps) {
+  const patternId = useId();
   const conflictRows = useMemo(() => {
     ...
   }, [rows]);
   if (conflictRows.length === 0) return null;
   const totalH = rows.length * cellH;
   return (
     <svg ... >
       <defs>
         <pattern
-          id="keyviz-conflict-hatch"
+          id={patternId}
           ...
         >
           <line ... />
         </pattern>
       </defs>
       {conflictRows.map((i) => (
         <rect
           ...
-          fill="url(`#keyviz-conflict-hatch`)"
+          fill={`url(#${patternId})`}
         />
       ))}
     </svg>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/admin/src/pages/KeyViz.tsx` around lines 313 - 326, Replace the hardcoded
SVG pattern id "keyviz-conflict-hatch" with a React-generated unique id via
useId() in the KeyViz component (or the component that renders this <defs>),
assign that id to the <pattern id={uniqueId}> and update all corresponding fill
attributes that reference the pattern to use fill={`url(#${uniqueId})`} (or the
equivalent string). This ensures the pattern id is unique across concurrent
renders or multiple Heatmap/KeyViz instances; import and call React's useId,
store the returned id in a variable (e.g., hatchId), use hatchId for the pattern
id and for any fill="url(...)" references.
web/admin/src/api/client.ts (1)

234-240: Type/comment mismatch on conflict.

The comment states the field is "always present on the wire, defaults to false", but the TS type is conflict?: boolean (optional). If the server contract genuinely guarantees the field is always emitted, the type can be tightened to conflict: boolean so consumers don't need row.conflict === true defensive checks (the existing call sites use truthy checks like if (rows[i].conflict) and {row.conflict && ...} which would still work either way).

Conversely, if you want to keep the optional type for forward/backward compatibility with older servers (e.g., a client running against a pre-PR-1 backend that hasn't been updated), then the comment is the part to soften — something like "present on Phase 2-C+ servers; absent on older builds, treated as false".

Pick one — but the current state has the docstring claim a stronger guarantee than the type expresses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/admin/src/api/client.ts` around lines 234 - 240, The comment and the
TypeScript type for the field conflict are inconsistent: the comment says
"always present on the wire, defaults to false" but the code declares conflict?:
boolean. Pick one approach and make them consistent: either tighten the type by
changing conflict?: boolean to conflict: boolean (and keep the comment) if the
server truly always emits the flag, or relax the comment to state "present on
Phase 2-C+ servers; absent on older builds, treated as false" if you intend to
keep conflict optional for backward compatibility — update the declaration or
the docstring accordingly (reference the conflict field and its current
declaration conflict?: boolean).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/admin/src/pages/KeyViz.tsx`:
- Around line 403-410: The conflict pill uses a native title tooltip which is
not keyboard/touch/screen-reader accessible; update the span rendered when
row.conflict to be keyboard-focusable and expose the explanatory text to AT by
adding tabIndex={0} and an accessible name/description (e.g., an aria-label or
aria-describedby that points to a visually hidden element containing the full
explanation), while keeping the visible "conflict" label and existing className
("pill-muted text-xs") so touch/keyboard users and screen readers receive the
"totals may understate..." caveat; change only the span in KeyViz.tsx (the
row.conflict element) to add tabIndex and aria attributes.

---

Nitpick comments:
In `@web/admin/src/api/client.ts`:
- Around line 234-240: The comment and the TypeScript type for the field
conflict are inconsistent: the comment says "always present on the wire,
defaults to false" but the code declares conflict?: boolean. Pick one approach
and make them consistent: either tighten the type by changing conflict?: boolean
to conflict: boolean (and keep the comment) if the server truly always emits the
flag, or relax the comment to state "present on Phase 2-C+ servers; absent on
older builds, treated as false" if you intend to keep conflict optional for
backward compatibility — update the declaration or the docstring accordingly
(reference the conflict field and its current declaration conflict?: boolean).

In `@web/admin/src/pages/KeyViz.tsx`:
- Around line 240-271: Add an ARIA role to the FanoutBanner container so
assistive tech announces cluster degradation when the UI transitions; update the
<div className="card border-danger/40"> in the FanoutBanner component to include
an appropriate role attribute (e.g., role="status" for passive announcement or
role="alert" to interrupt) and ensure the role is applied to the outermost
element rendered by the FanoutBanner function.
- Around line 313-326: Replace the hardcoded SVG pattern id
"keyviz-conflict-hatch" with a React-generated unique id via useId() in the
KeyViz component (or the component that renders this <defs>), assign that id to
the <pattern id={uniqueId}> and update all corresponding fill attributes that
reference the pattern to use fill={`url(#${uniqueId})`} (or the equivalent
string). This ensures the pattern id is unique across concurrent renders or
multiple Heatmap/KeyViz instances; import and call React's useId, store the
returned id in a variable (e.g., hatchId), use hatchId for the pattern id and
for any fill="url(...)" references.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ddd27651-c567-4d73-8ce7-99861ae2c184

📥 Commits

Reviewing files that changed from the base of the PR and between a3d312f and a3b3be7.

📒 Files selected for processing (2)
  • web/admin/src/api/client.ts
  • web/admin/src/pages/KeyViz.tsx

Comment on lines +403 to +410
{row.conflict && (
<span
className="pill-muted text-xs"
title="Two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
>
conflict
</span>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Conflict pill: tooltip is desktop-hover-only; consider keyboard/touch reachability.

The title attribute renders a native tooltip that:

  • requires mouse hover to display (no equivalent on touch devices),
  • is not reachable via keyboard focus,
  • ignores the explanatory tooltip text on screen readers in many browsers.

Given this pill carries semantically important context (the "totals may understate the true count" caveat), at least making it focusable + exposing the explanation as an accessible name is worth it. Minimal change:

♿ Proposed minimal fix
         {row.conflict && (
           <span
             className="pill-muted text-xs"
+            tabIndex={0}
+            role="note"
+            aria-label="Conflict: two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
             title="Two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
           >
             conflict
           </span>
         )}

Also worth noting: the existing "aggregate" pill on the line above has the same shortcoming, so this could be folded into a small shared Pill component with an optional explanation prop in a follow-up — out of scope for this PR.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{row.conflict && (
<span
className="pill-muted text-xs"
title="Two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
>
conflict
</span>
)}
{row.conflict && (
<span
className="pill-muted text-xs"
tabIndex={0}
role="note"
aria-label="Conflict: two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
title="Two or more nodes reported a non-zero value for the same cell — typical during a leadership flip mid-window. Displayed totals may understate the true count by up to one leader's pre-transfer slice."
>
conflict
</span>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/admin/src/pages/KeyViz.tsx` around lines 403 - 410, The conflict pill
uses a native title tooltip which is not keyboard/touch/screen-reader
accessible; update the span rendered when row.conflict to be keyboard-focusable
and expose the explanatory text to AT by adding tabIndex={0} and an accessible
name/description (e.g., an aria-label or aria-describedby that points to a
visually hidden element containing the full explanation), while keeping the
visible "conflict" label and existing className ("pill-muted text-xs") so
touch/keyboard users and screen readers receive the "totals may understate..."
caveat; change only the span in KeyViz.tsx (the row.conflict element) to add
tabIndex and aria attributes.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 27, 2026

@claude review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request enhances the KeyViz heatmap by adding support for visualizing fan-out results and row-level conflicts. It introduces new API interfaces for cluster fan-out status and implements UI components including a FanoutBanner for degraded states, a ConflictOverlay using SVG patterns to highlight data conflicts, and updated row details. Review feedback suggests correcting the documentation for the conflict flag's wire format and addressing potential ID collisions in the SVG pattern definition.

Comment on lines +234 to +239
// Phase 2-C row-level conflict flag (always present on the wire,
// defaults to false). True when ≥2 nodes reported a non-zero
// value for the same cell — typically a leadership flip mid-
// window. Per design 4.2 / PR-1, this is a row-level signal; it
// moves to per-cell when the proto extension lands in 2-C+.
conflict?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The comment states that the conflict flag is "always present on the wire," but the corresponding Go struct in internal/admin/keyviz_handler.go uses json:"conflict,omitempty". In Go, omitempty on a boolean field means it will be omitted from the JSON output if its value is false. Therefore, the field will only be present on the wire when it is true. The TypeScript interface correctly uses an optional property (conflict?: boolean), but the comment should be updated to reflect the actual wire behavior.

Suggested change
// Phase 2-C row-level conflict flag (always present on the wire,
// defaults to false). True when ≥2 nodes reported a non-zero
// value for the same cell — typically a leadership flip mid-
// window. Per design 4.2 / PR-1, this is a row-level signal; it
// moves to per-cell when the proto extension lands in 2-C+.
conflict?: boolean;
// Phase 2-C row-level conflict flag (omitted on the wire when
// false). True when ≥2 nodes reported a non-zero
// value for the same cell — typically a leadership flip mid-
// window. Per design 4.2 / PR-1, this is a row-level signal; it
// moves to per-cell when the proto extension lands in 2-C+.
conflict?: boolean;

Comment on lines +317 to +326
<pattern
id="keyviz-conflict-hatch"
width={4}
height={4}
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line x1={0} y1={0} x2={0} y2={4} stroke="currentColor" strokeWidth={1} opacity={0.45} />
</pattern>
</defs>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The SVG pattern uses a hardcoded ID keyviz-conflict-hatch. While this is currently safe as there is only one heatmap on the page, using static IDs in reusable components can lead to collisions if multiple instances are rendered (e.g., during navigation transitions or if the UI is expanded to show multiple matrices). Consider generating a unique ID per component instance or defining the pattern once at a higher level in the DOM.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 27, 2026

Claude finished @bootjp's task in 2m 0s —— View job


Code Review — Phase 2-C PR-2: SPA fan-out banner + conflict hatching

  • Gather context and read changed files
  • Analyze web/admin/src/api/client.ts
  • Analyze web/admin/src/pages/KeyViz.tsx
  • Post review feedback

Overall the PR is well-structured and the implementation is clean. The FanoutBanner / ConflictOverlay decomposition is the right call. Three issues below — one is a confirmed correctness bug in the doc comment, one is a future-safety concern, one is accessibility.


1. conflict comment claims "always present" but the wire format uses omitempty — confirmed bug

web/admin/src/api/client.ts:234–239

The comment reads:

// Phase 2-C row-level conflict flag (always present on the wire,
// defaults to false).

But the Go struct at internal/admin/keyviz_handler.go:90 is:

Conflict bool `json:"conflict,omitempty"`

omitempty on a Go bool means the field is omitted from the JSON when it is false. The TypeScript optional type conflict?: boolean is therefore correct — but the comment contradicts it. Any future developer reading the comment may "simplify" row.conflict === true checks to row.conflict and assume undefined means something went wrong rather than "the flag is absent and should be treated as false."

Fix: update the comment to match reality. Fix this →


2. Hardcoded SVG pattern ID "keyviz-conflict-hatch" — future-safety concern

web/admin/src/pages/KeyViz.tsx:318

<pattern id="keyviz-conflict-hatch" ...>

SVG <pattern> IDs are global to the document. Today there's exactly one Heatmap instance, so this is fine. The risk materialises if a future view renders two matrices simultaneously (a comparison pane, a before/after split, or even a brief double-mount during React's concurrent rendering tear-down). Both <defs> register the same ID, and whichever lands last in the DOM tree wins — the other overlay's fill="url(#keyviz-conflict-hatch)" references the wrong pattern.

The idiomatic React 19 fix is useId(). This is already imported-adjacent (the component uses useMemo from the same import). The change is two lines:

-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
 function ConflictOverlay({ rows, cellH, width }: ConflictOverlayProps) {
+  const hatchId = useId();
   const conflictRows = useMemo(() => { ... }, [rows]);
   ...
-  <pattern id="keyviz-conflict-hatch" ...>
+  <pattern id={hatchId} ...>
   ...
-  fill="url(#keyviz-conflict-hatch)"
+  fill={`url(#${hatchId})`}

Not blocking today, but worth fixing before any comparison/multi-matrix feature lands. Fix this →


3. Accessibility: title tooltip on the conflict pill + no ARIA role on FanoutBanner

web/admin/src/pages/KeyViz.tsx:403–410 and 249–270

The conflict pill tooltip:

<span className="pill-muted text-xs">conflict</span>

title only works on mouse hover — it is unreachable from keyboard and ignored by most screen readers and all touch devices. The tooltip text carries meaningful semantics ("totals may understate"), so an operator on a tablet or navigating with a keyboard would miss it. Minimal fix: add tabIndex={0} so it is keyboard-focusable. A proper fix would use aria-label or aria-describedby. Fix this →

FanoutBanner has no ARIA role, so assistive technology won't announce cluster degradation when the component appears mid-session. Adding role="status" (non-intrusive announcement when it renders) or role="alert" (interrupt, better for "degraded") on the outermost <div> would fix this. Fix this →


Smaller observations (no action required)

  • FanoutBanner placement — the banner renders outside the <section className="card"> containing the heatmap (line 59 is before line 61). This appears intentional: an operator-level alert should visually precede the data card. Looks correct.
  • Guard responded >= expected — the >= (rather than ===) is correctly defensive against a malformed response where responded > expected. No issue.
  • flex-wrap on RowDetail header — good defensive add; both pills could exceed the row width on narrow viewports.
  • ConflictOverlay sizing — the SVG height matches rows.length * cellH which mirrors the canvas exactly (TimeAxis is below, not covered). Correct.
  • pointerEvents: "none" on the overlay SVG — correct; mouse events pass through to the canvas for the hover-row logic.
  • useMemo dependency on [rows] — the identity check means a fresh deserialized matrix.rows reference recalculates on every poll tick. This is fine: the memo cost is O(N) on at most 1024 rows, which is negligible.

@bootjp bootjp merged commit edaaa64 into main Apr 27, 2026
14 checks passed
@bootjp bootjp deleted the feat/keyviz-spa-fanout-banner branch April 27, 2026 13:46
bootjp added a commit that referenced this pull request Apr 28, 2026
## Summary

Mirror the existing `ADMIN_*` env-driven plumbing for the KeyViz heatmap
sampler so operators can flip `--keyvizEnabled` and the
`--keyvizFanoutNodes` peer list from `deploy.env` without editing the
script per-deploy.

## What's added

- `KEYVIZ_ENABLED` master switch (validated as `true|false` at the top
of the local script alongside `ADMIN_ENABLED`).
- `KEYVIZ_FANOUT_NODES` comma-separated host:port list. Pre-quoted via
`printf %q` for the same reason `ADMIN_FULL_ACCESS_KEYS` is: commas
survive an unquoted env pass but pre-quoting keeps the pattern uniform.
- `build_keyviz_flags` helper (nameref-output, mirrors
`build_admin_flags`). Empty array when `KEYVIZ_ENABLED != "true"`, so
existing deploys see no behaviour change.
- `KEYVIZ_*` env vars threaded through the SSH env passthrough so the
heredoc-resident `build_keyviz_flags` reads them on the remote.
- `env.example` block documenting the variables and pointing at the
Phase 2-C fan-out auth follow-up
(`docs/design/2026_04_27_proposed_keyviz_cluster_fanout.md`).

## What's not added

- `KEYVIZ_STEP`, `KEYVIZ_MAX_TRACKED_ROUTES`, `KEYVIZ_HISTORY_COLUMNS`,
`KEYVIZ_FANOUT_TIMEOUT` — these have sensible binary-side defaults and
operators have not asked for env-driven overrides yet. Adding them is a
one-line `_flags+=` edit per knob if needed.
- Inter-node fan-out auth — Phase 2-C does not yet ship a pre-shared
bearer token, so a multi-node `KEYVIZ_FANOUT_NODES` setup surfaces 5/N
peer 401s in the SPA degraded banner. The env.example comment documents
this.

## Verification

Deployed to a live 5-node cluster (192.168.0.210-214) with
`KEYVIZ_ENABLED=true` and `KEYVIZ_FANOUT_NODES` set across all five
admin listeners; `--keyviz*` flags appeared on every container's `docker
inspect`, and the admin keyviz handler returned a matrix with the
`fanout` block populated.

```sh
$ docker inspect elastickv --format '{{json .Args}}' | python3 -c "import sys,json;a=json.load(sys.stdin);print([v for v in a if v.startswith('--keyviz')])"
['--keyvizEnabled', '--keyvizFanoutNodes']

$ curl -s -b cookies.txt https://elastickv.bootjp.dev/admin/api/v1/keyviz/matrix \
  | jq '.fanout | {responded, expected}'
{
  "responded": 1,
  "expected": 6
}
```

(The 5/6 401s are the expected fan-out-auth gap, surfaced by the PR #687
`FanoutBanner` in the SPA. Tracked separately.)

## Self-review (CLAUDE.md 5 lenses)

1. **Data loss** — None. Build/runbook plumbing only.
2. **Concurrency** — None.
3. **Performance** — None on the deploy path. Per-node sampler cost is
covered by the sampler's own design doc.
4. **Data consistency** — None.
5. **Test coverage** — `bash -n` syntax-check passes. Existing
`rolling-update.sh` has no automated tests in this repo; the
verification path is "deploy to a real cluster and inspect docker run
args."

## Test plan

- [x] `bash -n scripts/rolling-update.sh` — syntax OK
- [x] Live 5-node deploy with KEYVIZ_ENABLED=true — verified
- [ ] CI: shellcheck (if/when added)

@claude review
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