feat(compare): side-by-side YAML diff for two resources#754
Merged
Conversation
Adds a `Compare ⇄` flow for diffing two K8s resources of the same kind. Two entry points converge on the same view: - **Drawer "Compare" button**: opens a picker (same-namespace promoted, alphabetical, ↑↓/Enter keyboard nav), navigates to `/compare?kind=&a=&b=`. - **Table compare mode**: header toggle flips ResourcesView into pick mode — leading A/B badge column, sticky bottom tray with 2 slots, cap-at-2 with replace-oldest, Esc exits. Diff view: Monaco DiffEditor (real impl of the existing `YamlDiffEditor` stub), side-by-side or unified, hide-unchanged collapses regions, Spec-only drops status. Per-side error rendering — failed side gets a red pill + banner, working side still renders. Swap A↔B updates URL. Resources are normalized before diffing (strip managedFields/uid/resourceVersion/last- applied/pod-template-hash) so the diff is signal not noise. Pure helpers extracted with tests: `parseRef`/`refToParam` (URL ref parsing), `togglePick`/`pickIndex` (cap-replace state machine), `sortCandidates`/`filterCandidates` (picker order), `normalizeForCompare`. 49 new tests pin the load-bearing behavior. The frontend `useResources` hook now gates on `Boolean(kind)` so the picker's lazy-on-open pattern doesn't fire a 404 for the empty kind.
Address findings from the second review pass: - parseRef now rejects `?a=prod/` (empty name after slash) — was silently wedging callers in an indefinite loading state since useResource had nothing to fetch. - CompareViewRoute no longer flashes "Failed to load side A" for a refetch failure that has cached data — banner now only fires when the side has no data at all. Stale beats misleading. - Kind change in compare mode now also exits compare mode, not just clears picks — leaving the tray on with empty pills after the kind switch was the worst-of-both UX. - Tray render now gated on `compareEnabled` (mirrors the existing toolbar toggle gate) so library consumers without onNavigate can't get a tray whose Compare CTA silently no-ops. - Picker error prop typed `unknown` (was `Error | null`) — React Query emits unknown; renderer falls back through `String(err)` for non-Error throws. - togglePick cap-replace rewritten as `[...picks.slice(1), ref]` — clearer intent than the old slice arithmetic that only happened to be correct for cap=2. Type cleanup: - Single `NamespacedRef` shape replaces the three accidental duplicates (Pick / CompareTrayPick / ParsedRef / SortableCandidate). - `SIDE_TONES` const centralises the A/B palette used by the drawer pill, picker chip, tray pill, and table row badge. Palette changes touch one place instead of four. - rowHighlightClass extracted from a 4-deep nested ternary in ResourceRowCells (CLAUDE.md flag). Web cleanup: - useCompareCandidates lifts the shared `useResources` + map pattern out of useCompareLauncher and CompareViewRoute. Comments stripped (CLAUDE.md: no WHAT narration): A→B gradient ribbon, "see the design memo" pointer, DNS-1123 duplication in url.test.ts, verbose PINNING block in normalize.test.ts. 51 tests pass (+2 — empty-name URL rejection, slash-only URL rejection).
Two viewport-related issues caught on real-world wide screens (2000px+) that I missed in 1200px visual tests: - ResourceCompareView's root lacked `flex-1`, so on wide viewports the diff view collapsed to its content width and left half the screen empty. - CompareTray's Compare CTA and Exit X collided with the fixed-position debug + shortcut-help overlay buttons anchored bottom-right of the viewport. Added right padding to the tray content row so the buttons sit clear of the overlay. Verified at 2000x1100.
Playwright MCP defaults to ~1280px, which hides whole classes of layout bugs that only show up at desktop / ultrawide widths. The compare PR shipped two of them — a full-screen view that collapsed to content width without `flex-1`, and a sticky bar that collided with Radar's fixed bottom-right overlay buttons — both invisible at 1280 but obvious at 2000+. - /visual-test command now opens with a "set viewport FIRST" step, defaults to 1920x1080, and points at the 1280/1920/2560 sweep for layout-sensitive changes. - visual-test-start.sh prints the same reminder on launch so anyone driving the harness sees it before navigating. - "What to look for" checklist gains two wide-viewport bullets.
- New "Raw metadata" toggle (off by default) — when on, normalize skips the metadata-noise strip pass, so resourceVersion, uid, managedFields, last-applied-configuration, pod-template-hash, etc. show up in the diff. For the rare case of debugging API-level differences. - Fixed the layout toggle to match the other three toolbar toggles: constant "Unified" label, highlight when active. Was changing both label and icon on click — inconsistent with Spec only / Diff only / Raw metadata. - README + docs screenshot updated to reflect the four-toggle toolbar.
…tate Five fixes from the Bugbot review on commit 54d8789: - WorkloadView's compare launcher now prefers the URL-supplied `rest.group` over the apiVersion-derived one. Otherwise CRD compare clicked before the resource fetch completes (or after a fetch failure) loses the group and routes to the wrong kind on collisions. - CompareResourcePicker now takes a `sourceSide` prop and renders the source chip with the matching SIDE_TONES color/letter. Clicking the pencil on the B pill no longer shows "A" for the current B resource; the header copy also switches to "Replace side B with another …". - Picker resets `query` and `highlightIdx` when `open` transitions to true. The drawer flow keeps the picker mounted, so without this the previous session's search and highlight position leaked into the next compare. - Picker now also clamps `highlightIdx` on query change (was only on filtered.length change). Typing in search could leave Enter selecting a row different from the visually highlighted one. - Picker arrow keys no-op when the filtered list is empty. Previously ArrowDown on empty list set highlightIdx to -1. The Bugbot "empty name passes URL validation" finding was already fixed in commit 9c9e089 (parseRef rejects `?a=prod/`); the comment was stale.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5805484. Configure here.
Three fixes from Cursor Bugbot on commit 5805484: - CRD compare was silently broken: App.tsx's URL-sync effect strips the bare `group` query param on every non-topology view (reserves it for topology grouping mode). Renamed the compare URL param `group` → `apiGroup` to match Radar's repo-wide convention. App.tsx never touches `apiGroup`, so the value survives navigation into /compare. Affected writers: useCompareLauncher, ResourcesView compare-nav, CompareViewRoute swap. Reader: CompareViewRoute. Docs updated. - ResourceCompareView now takes per-side `aLoading` / `bLoading` instead of a single `loading` flag. A fast side renders immediately while the slow side spins; only both-pending shows the full-pane "Loading resources…" placeholder. - When both sides fail, the error banner now lists both messages (`A: <aError> · B: <bError>`) instead of dropping bError on the floor.
Compare-as-text was pushing the action bar to a second row at narrow drawer widths on kinds with the most actions (Pod, Node, Flux Kustomization/HelmRelease, Argo Application). Two coordinated changes: - Compare is now icon-only (matches Delete's pattern at the same right-end of the bar; tooltip "Compare to another <kind>" stays). Saves ~70px from the row. - Drawer MIN_WIDTH bumped 400 → 520. The previous 400 left even text-light kinds wrapping; 520 fits the widest button row (Pod with Terminal + Logs + Port Forward + YAML + Compare + Delete) with ~12px of slack.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds a
Compare ⇄flow for diffing two Kubernetes resources of the same kind side-by-side. Modelled after Aptakube's resource-diff feature, scoped to v1: single cluster, same kind, two-way.Two entry points converge on the same diff view:
Comparebutton in the resource action bar opens a picker dialog. Same-namespace candidates are promoted to the top (the obvious target), alphabetical within each group, ↑↓ keyboard navigation, Enter to pick.Compare ⇄toggle in theResourcesViewheader flips the table into pick mode — leading A/B-badge column, sticky bottom tray with two pick slots, cap-at-2 with replace-oldest so a row click always has a visible effect, Esc exits.Both routes navigate to
/compare?kind=&apiGroup=&a=ns/name&b=ns/name(URL is shareable;apiGrouponly needed for CRDs that collide with core kinds).What the diff view does
Monaco
DiffEditor(real implementation of the existingYamlDiffEditorstub atYamlEditor.tsx:250). Four header toggles: Raw metadata (off — Radar strips noise; flip on to see it), Spec only (drop status), Diff only (collapse unchanged regions), Unified (single-column layout). A↔B swap rewrites the URL; click the pencil on either pill to re-pick.Resources are normalised before diffing —
managedFields,uid,resourceVersion,creationTimestamp,kubectl.kubernetes.io/last-applied-configuration,pod-template-hashand similar noise stripped — so the diff shows intent, not server-assigned state.Per-side rendering: a fast side renders immediately while the slow side spins; if A succeeds and B 404s (stale share-link), the working side stays useful, the failed side gets a red pill + warning icon, and the banner names exactly which side(s) failed.
Implementation notes
packages/k8s-ui/src/components/compare/. Pure helpers (parseRef/refToParam,togglePick/pickIndex,sortCandidates/filterCandidates,normalizeForCompare) are exported and unit-tested — 54 new tests pin the load-bearing behaviour.CompareResourceRef), oneCompareSide = 'a' | 'b', oneSIDE_TONESpalette for the A/B colors shared across drawer pill / picker chip / tray pill / table row badge.useResourceshook now gates onBoolean(kind)so the picker's lazy-on-open pattern doesn't fire a 404 for the empty kind.onNavigate— embeds that don't pass it get the button hidden rather than a dead click.Out of scope (deliberate)
Cross-kind compare and three-way compare — both worth more thought before adding. Cross-cluster compare needs Radar Hub multi-cluster anyway. Semantic ("by category") grouping over the raw line diff is the natural next step.