Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .claude/commands/visual-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ source .playwright-mcp/visual-test-state.env

## Step 6: Visual Testing with Playwright

### Step 6a: Set viewport FIRST — before any navigation

**Playwright MCP defaults to a narrow viewport (~1280px) that does NOT match real users.** Set the viewport explicitly at the start of every visual test, before the first `browser_navigate`:

```ts
mcp__playwright__browser_resize({ width: 1920, height: 1080 })
```

**Default to 1920×1080** — the single most common desktop resolution, and the only reliable way to catch layout bugs (missing `flex-1`, `w-full`, or `min-w-0`) that the narrow default hides.

**Sweep widths when the change is layout-sensitive.** For new pages, full-screen views, sticky bars, side panels, anything that flexes — capture each of:
- **1280×800** — small laptop (close to Playwright's default; the easiest layouts to pass)
- **1920×1080** — standard desktop (primary)
- **2560×1440** — ultrawide / external monitor (where overflow / underfill bugs surface)

Two real bugs that 1200px screenshots missed and 2000px+ caught:
- A full-screen view (`ResourceCompareView`) was missing `flex-1` on its root — at 1280px it filled the column by accident; at 2000px+ it collapsed to content width with empty space to the right.
- A sticky bottom bar (`CompareTray`) collided with Radar's fixed bottom-right overlay buttons (debug + `?` shortcut help) — only visible once the bar extended to the viewport's right edge.

**When in doubt: capture both 1280 and 1920 for the same flow** — it takes 10 extra seconds and is the cheapest sanity check available.

### Step 6b: Navigate and screenshot

Navigate to `$RADAR_URL` and systematically test the changed areas.

### Screenshot Strategy
Expand Down Expand Up @@ -133,6 +156,8 @@ For each changed renderer/component:
- Layout doesn't break (no overflow, no clipping, proper spacing)
- Responsive behavior in the drawer (text truncation, wrapping)
- Long text doesn't overflow into adjacent table columns
- **At wide viewports (≥1920)**: full-screen views fill the column edge-to-edge (no large empty area to the right — usually a missing `flex-1` / `w-full` on the root)
- **At wide viewports (≥1920)**: sticky bottom bars don't sit underneath Radar's fixed bottom-right overlay buttons (debug + `?`) — leave ~80px right padding on any new bottom bar

## Step 7: Report

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ Manage Helm releases deployed in your cluster.
- Inspect values, compare revisions, view release history
- Upgrade, rollback, or uninstall releases directly from the UI

### Compare Resources

Diff any two Kubernetes resources of the same kind side-by-side — like comparing a staging Deployment to its production sibling, or two pods that should be identical but aren't.

<p align="center">
<img src="docs/screenshots/compare-view.png" alt="Compare View" width="800">
<br><em>Compare View — Side-by-side YAML diff with field-level highlighting</em>
</p>

- **Two entry points**: a `Compare` button in the resource detail drawer, or compare mode in the resource table (toggle, pick two rows, hit Compare)
- **Side-by-side or unified** view, with one-click swap of A ↔ B
- **Diff-only mode** collapses unchanged regions so you only see what differs
- **Spec-only mode** drops `status` fields to focus on intent rather than observed state
- Server-assigned noise (`managedFields`, `resourceVersion`, `kubectl.kubernetes.io/last-applied-configuration`) is stripped automatically so the diff stays signal — flip **Raw metadata** on if you actually want to see it
- Same-namespace candidates are surfaced first in the picker — usually the resource you want to compare against
- Shareable URLs: `/compare?kind=&apiGroup=&a=ns/name&b=ns/name`

<p align="center">
<img src="docs/screenshots/compare-mode-tray.png" alt="Compare Mode Tray" width="800">
<br><em>Compare mode in the resource table — pick two rows, hit Compare</em>
</p>

### TLS Certificate Management

View TLS certificate details and expiry dates across all namespaces — catch expiring certificates before they cause outages.
Expand Down
Binary file added docs/screenshots/compare-mode-tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/compare-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
192 changes: 192 additions & 0 deletions packages/k8s-ui/src/components/compare/CompareResourcePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { clsx } from 'clsx'
import { GitCompare, Search, X } from 'lucide-react'
import { DialogPortal } from '../ui/DialogPortal'
import { pluralToKind } from '../../utils/navigation'
import type { CompareResourceRef } from './ResourceCompareView'
import { sortCandidates, filterCandidates } from './sort'
import { SIDE_TONES, type CompareSide } from './types'

export interface CompareResourcePickerProps {
open: boolean
onClose: () => void
/** The resource the user is comparing *from*. */
source: CompareResourceRef
/** Which side the source occupies — drives the chip color/label in the dialog header.
* Defaults to 'a': the launcher flow's source becomes A in the resulting URL. */
sourceSide?: CompareSide
/** Candidate resources — same kind as source. Source is filtered out automatically. */
candidates: CompareResourceRef[]
loading?: boolean
error?: unknown
onPick: (r: CompareResourceRef) => void
}

export function CompareResourcePicker({
open,
onClose,
source,
sourceSide = 'a',
candidates,
loading,
error,
onPick,
}: CompareResourcePickerProps) {
const [query, setQuery] = useState('')
const [highlightIdx, setHighlightIdx] = useState(0)
Comment thread
cursor[bot] marked this conversation as resolved.
const listRef = useRef<HTMLUListElement | null>(null)

// Reset query + highlight every time the picker opens. The drawer flow keeps
// the picker mounted across opens, so without this a previous session's
// search would leak into the next compare.
useEffect(() => {
if (open) {
setQuery('')
setHighlightIdx(0)
}
}, [open])

const filtered = useMemo(
() => filterCandidates(sortCandidates(candidates, source), query),
[candidates, source, query],
)

// Clamp on any filter shape change — list length OR query (the user typing
// can swap which rows are visible without changing length).
useEffect(() => {
setHighlightIdx(prev => (prev >= filtered.length ? 0 : prev))
}, [filtered.length, query])

useEffect(() => {
if (!listRef.current) return
const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)
el?.scrollIntoView({ block: 'nearest' })
}, [highlightIdx])

function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
// Empty list: arrow keys would otherwise compute Math.min(0+1, -1) = -1.
if (filtered.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightIdx(i => Math.min(i + 1, filtered.length - 1))
Comment thread
cursor[bot] marked this conversation as resolved.
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightIdx(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const pick = filtered[highlightIdx]
if (pick) onPick(pick)
} else if (e.key === 'Home') {
e.preventDefault()
setHighlightIdx(0)
} else if (e.key === 'End') {
e.preventDefault()
setHighlightIdx(filtered.length - 1)
}
}

const sourceLabel = sourceSide === 'a' ? 'A' : 'B'
const sourceChipBg = SIDE_TONES[sourceSide].chipBg

return (
<DialogPortal open={open} onClose={onClose} className="max-w-xl w-full max-h-[70vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
<div className="flex items-center gap-2">
<GitCompare className="w-5 h-5 text-skyhook-400" />
<h3 className="text-sm font-semibold text-theme-text-primary">
{sourceSide === 'a'
? `Compare to another ${pluralToKind(source.kind)}`
: `Replace side B with another ${pluralToKind(source.kind)}`}
</h3>
</div>
<button
onClick={onClose}
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>

<div className="px-4 py-3 border-b border-theme-border shrink-0 space-y-2">
<div className="text-xs text-theme-text-secondary flex items-center gap-1.5 flex-wrap">
<span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none', sourceChipBg)}>
{sourceLabel}
</span>
<span className="font-mono text-theme-text-primary">
{source.namespace && <span className="opacity-60">{source.namespace}/</span>}
{source.name}
</span>
</div>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-theme-text-tertiary pointer-events-none" />
<input
autoFocus
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search by name or namespace… ↑↓ navigate, ↵ pick"
className="w-full pl-9 pr-3 py-2 text-sm bg-theme-elevated border border-theme-border rounded-lg text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-400"
/>
</div>
</div>

<div className="flex-1 min-h-0 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
Loading {source.kind}…
</div>
)}
{error != null && (
<div className="flex items-center justify-center py-8 text-red-400 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
)}
{!loading && error == null && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-theme-text-tertiary text-sm gap-1">
{candidates.length <= 1
? `No other ${source.kind} available to compare against.`
: 'No matches.'}
</div>
)}
{!loading && error == null && filtered.length > 0 && (
<ul ref={listRef} className="divide-y divide-theme-border/50">
{filtered.map((c, idx) => {
const sameNs = c.namespace === source.namespace
const isActive = idx === highlightIdx
return (
<li key={`${c.namespace}/${c.name}`} data-idx={idx}>
<button
onClick={() => onPick(c)}
onMouseEnter={() => setHighlightIdx(idx)}
className={clsx(
'w-full text-left px-4 py-2.5 transition-colors',
'flex items-baseline gap-2',
isActive ? 'bg-skyhook-500/10' : 'hover:bg-theme-hover',
)}
>
<span className="text-sm font-mono text-theme-text-primary truncate flex-1">
{c.name}
</span>
{c.namespace && (
<span
className={clsx(
'text-xs font-mono shrink-0',
sameNs ? 'text-skyhook-400' : 'text-theme-text-tertiary',
)}
title={sameNs ? 'Same namespace as the source — likely target' : undefined}
>
{c.namespace}
</span>
)}
</button>
</li>
)
})}
</ul>
)}
</div>
</DialogPortal>
)
}
130 changes: 130 additions & 0 deletions packages/k8s-ui/src/components/compare/CompareTray.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { clsx } from 'clsx'
import { GitCompare, X, ArrowRight } from 'lucide-react'
import { Tooltip } from '../ui/Tooltip'
import { pluralToKind } from '../../utils/navigation'
import { SIDE_TONES, type CompareSide, type NamespacedRef } from './types'

export type CompareTrayPick = NamespacedRef

export interface CompareTrayProps {
/** Plural kind (e.g. "deployments") — used to label the tray. */
kind: string
picks: CompareTrayPick[]
onRemove: (index: number) => void
/** Called when the user hits the Compare CTA. Only invoked when 2 picks. */
onCompare: () => void
/** Exit compare mode entirely (clears picks). */
onExit: () => void
}

export function CompareTray({ kind, picks, onRemove, onCompare, onExit }: CompareTrayProps) {
const slotA = picks[0]
const slotB = picks[1]
const ready = !!(slotA && slotB)
const kindLabel = pluralToKind(kind)

return (
<div
role="region"
aria-label="Compare resources tray"
className={clsx(
'shrink-0 border-t border-skyhook-500/30 bg-theme-surface/95 backdrop-blur',
'shadow-[0_-8px_24px_-12px_rgba(0,0,0,0.35)]',
)}
>
<div className="h-0.5 w-full bg-gradient-to-r from-blue-400/70 via-skyhook-400/40 to-emerald-400/70" />

{/* Right padding clears the fixed bottom-right overlay buttons (debug / shortcut-help). */}
<div className="flex items-center gap-3 pl-4 pr-20 py-2.5">
<div className="flex items-center gap-2 shrink-0">
<GitCompare className="w-4 h-4 text-skyhook-400" />
<span className="text-xs font-semibold text-theme-text-primary uppercase tracking-wider">
Compare {kindLabel}
</span>
<span className="text-[10px] text-theme-text-tertiary font-medium px-1.5 py-0.5 rounded bg-theme-elevated">
{picks.length}/2
</span>
</div>

<div className="flex items-center gap-2 min-w-0 flex-1">
<PickSlot side="a" pick={slotA} onRemove={() => onRemove(0)} />
<ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
<PickSlot side="b" pick={slotB} onRemove={() => onRemove(1)} />
</div>

<button
onClick={onCompare}
disabled={!ready}
className={clsx(
'shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
ready
? 'btn-brand'
: 'text-theme-text-disabled bg-theme-elevated border border-theme-border-light cursor-not-allowed',
)}
>
<GitCompare className="w-3.5 h-3.5" />
Compare
</button>

<Tooltip content="Exit compare mode (Esc)">
<button
onClick={onExit}
className="shrink-0 p-1.5 rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
aria-label="Exit compare mode"
>
<X className="w-4 h-4" />
</button>
</Tooltip>
</div>
</div>
)
}

function PickSlot({
side,
pick,
onRemove,
}: {
side: CompareSide
pick?: CompareTrayPick
onRemove: () => void
}) {
const tones = SIDE_TONES[side]

if (!pick) {
return (
<div className="group flex items-center gap-2 pl-1.5 pr-2.5 py-1 rounded-lg border border-dashed border-theme-border-light text-xs font-mono min-w-0 max-w-[22rem] text-theme-text-tertiary italic flex-1">
<span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none shrink-0 opacity-50', tones.chipBg)}>
{side === 'a' ? 'A' : 'B'}
</span>
<span className="truncate">Pick {side === 'a' ? 'a resource' : 'a second resource'} from the table…</span>
</div>
)
}

const full = pick.namespace ? `${pick.namespace}/${pick.name}` : pick.name
return (
<div
className={clsx(
'group flex items-center gap-2 pl-1.5 pr-1.5 py-1 rounded-lg border text-xs font-mono min-w-0 max-w-[22rem] flex-1',
tones.containerBorder, tones.containerBg,
)}
title={full}
>
<span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none shrink-0', tones.chipBg)}>
{side === 'a' ? 'A' : 'B'}
</span>
<span className="text-theme-text-primary truncate min-w-0 flex-1">
{pick.namespace && <span className="opacity-60">{pick.namespace}/</span>}
{pick.name}
</span>
<button
onClick={onRemove}
className="shrink-0 p-0.5 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated/70"
aria-label={`Remove ${side === 'a' ? 'A' : 'B'}`}
>
<X className="w-3 h-3" />
</button>
</div>
)
}
Loading
Loading