@skyhook-io/k8s-ui 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +7 -2
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  25. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  26. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  27. package/src/components/gitops/RollbackDialog.tsx +117 -0
  28. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  29. package/src/components/gitops/detail-helpers.test.ts +97 -0
  30. package/src/components/gitops/detail-helpers.ts +112 -0
  31. package/src/components/gitops/index.ts +52 -1
  32. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  33. package/src/components/gitops/tree/index.ts +2 -0
  34. package/src/components/gitops/tree/merge.test.ts +240 -0
  35. package/src/components/gitops/tree/merge.ts +160 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  37. package/src/components/resources/ResourcesView.tsx +403 -69
  38. package/src/components/resources/index.ts +1 -0
  39. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  40. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  41. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  42. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  43. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  44. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  45. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  46. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  48. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  49. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  50. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  52. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  53. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  54. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  55. package/src/components/resources/renderers/index.ts +8 -0
  56. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  57. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  58. package/src/components/resources/resource-utils-flux.ts +14 -0
  59. package/src/components/resources/resource-utils.ts +1 -0
  60. package/src/components/resources/resources-column-filter.test.ts +74 -0
  61. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  62. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  63. package/src/components/ui/YamlEditor.tsx +28 -15
  64. package/src/components/ui/drawer-components.tsx +8 -20
  65. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  66. package/src/components/workload/WorkloadView.tsx +2 -2
  67. package/src/index.ts +3 -0
  68. package/src/types/core.ts +31 -6
  69. package/src/types/index.ts +1 -0
  70. package/src/types/rbac.ts +99 -0
  71. package/src/utils/api-resources.ts +9 -1
  72. package/src/utils/badge-colors.ts +25 -0
  73. package/src/utils/gitops-owner.test.ts +70 -111
  74. package/src/utils/gitops-owner.ts +37 -74
  75. package/src/utils/helm-status.test.ts +50 -0
  76. package/src/utils/index.ts +1 -0
  77. package/src/utils/rbac-badges.ts +61 -0
  78. package/src/utils/rbac-blast-radius.test.ts +137 -0
  79. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -0,0 +1,36 @@
1
+ // Shared time-series chart types. The shape originated from Prometheus query
2
+ // results but is generic — any source emitting time-stamped numeric samples
3
+ // can feed AreaChart.
4
+
5
+ export interface TimeSeriesPoint {
6
+ timestamp: number
7
+ value: number
8
+ }
9
+
10
+ export interface TimeSeries {
11
+ labels: Record<string, string>
12
+ /** Expected to be sorted ascending by timestamp and finite-valued. The
13
+ * chart's path math and saturation peak scans assume this; passing
14
+ * unsorted or NaN samples will produce garbage rendering, not crashes. */
15
+ dataPoints: TimeSeriesPoint[]
16
+ }
17
+
18
+ /** @deprecated Use {@link TimeSeriesPoint}. Kept for one release for callers
19
+ * still importing the Prom-prefixed name. */
20
+ export type PrometheusDataPoint = TimeSeriesPoint
21
+
22
+ /** @deprecated Use {@link TimeSeries}. Kept for one release for callers still
23
+ * importing the Prom-prefixed name. */
24
+ export type PrometheusSeries = TimeSeries
25
+
26
+ /**
27
+ * Horizontal reference line overlaid on a chart. `kind` is semantic — it
28
+ * drives which value `computeSaturation` treats as the operational ceiling.
29
+ * The chart auto-extends its Y axis to fit reference lines, so they're
30
+ * never clipped.
31
+ */
32
+ export interface ReferenceLine {
33
+ value: number
34
+ label: string
35
+ kind: 'request' | 'limit'
36
+ }
@@ -0,0 +1,192 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { GitCompare, Search, X } from 'lucide-react'
4
+ import { DialogPortal } from '../ui/DialogPortal'
5
+ import { pluralToKind } from '../../utils/navigation'
6
+ import type { CompareResourceRef } from './ResourceCompareView'
7
+ import { sortCandidates, filterCandidates } from './sort'
8
+ import { SIDE_TONES, type CompareSide } from './types'
9
+
10
+ export interface CompareResourcePickerProps {
11
+ open: boolean
12
+ onClose: () => void
13
+ /** The resource the user is comparing *from*. */
14
+ source: CompareResourceRef
15
+ /** Which side the source occupies — drives the chip color/label in the dialog header.
16
+ * Defaults to 'a': the launcher flow's source becomes A in the resulting URL. */
17
+ sourceSide?: CompareSide
18
+ /** Candidate resources — same kind as source. Source is filtered out automatically. */
19
+ candidates: CompareResourceRef[]
20
+ loading?: boolean
21
+ error?: unknown
22
+ onPick: (r: CompareResourceRef) => void
23
+ }
24
+
25
+ export function CompareResourcePicker({
26
+ open,
27
+ onClose,
28
+ source,
29
+ sourceSide = 'a',
30
+ candidates,
31
+ loading,
32
+ error,
33
+ onPick,
34
+ }: CompareResourcePickerProps) {
35
+ const [query, setQuery] = useState('')
36
+ const [highlightIdx, setHighlightIdx] = useState(0)
37
+ const listRef = useRef<HTMLUListElement | null>(null)
38
+
39
+ // Reset query + highlight every time the picker opens. The drawer flow keeps
40
+ // the picker mounted across opens, so without this a previous session's
41
+ // search would leak into the next compare.
42
+ useEffect(() => {
43
+ if (open) {
44
+ setQuery('')
45
+ setHighlightIdx(0)
46
+ }
47
+ }, [open])
48
+
49
+ const filtered = useMemo(
50
+ () => filterCandidates(sortCandidates(candidates, source), query),
51
+ [candidates, source, query],
52
+ )
53
+
54
+ // Clamp on any filter shape change — list length OR query (the user typing
55
+ // can swap which rows are visible without changing length).
56
+ useEffect(() => {
57
+ setHighlightIdx(prev => (prev >= filtered.length ? 0 : prev))
58
+ }, [filtered.length, query])
59
+
60
+ useEffect(() => {
61
+ if (!listRef.current) return
62
+ const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)
63
+ el?.scrollIntoView({ block: 'nearest' })
64
+ }, [highlightIdx])
65
+
66
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
67
+ // Empty list: arrow keys would otherwise compute Math.min(0+1, -1) = -1.
68
+ if (filtered.length === 0) return
69
+ if (e.key === 'ArrowDown') {
70
+ e.preventDefault()
71
+ setHighlightIdx(i => Math.min(i + 1, filtered.length - 1))
72
+ } else if (e.key === 'ArrowUp') {
73
+ e.preventDefault()
74
+ setHighlightIdx(i => Math.max(i - 1, 0))
75
+ } else if (e.key === 'Enter') {
76
+ e.preventDefault()
77
+ const pick = filtered[highlightIdx]
78
+ if (pick) onPick(pick)
79
+ } else if (e.key === 'Home') {
80
+ e.preventDefault()
81
+ setHighlightIdx(0)
82
+ } else if (e.key === 'End') {
83
+ e.preventDefault()
84
+ setHighlightIdx(filtered.length - 1)
85
+ }
86
+ }
87
+
88
+ const sourceLabel = sourceSide === 'a' ? 'A' : 'B'
89
+ const sourceChipBg = SIDE_TONES[sourceSide].chipBg
90
+
91
+ return (
92
+ <DialogPortal open={open} onClose={onClose} className="max-w-xl w-full max-h-[70vh] flex flex-col">
93
+ <div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
94
+ <div className="flex items-center gap-2">
95
+ <GitCompare className="w-5 h-5 text-skyhook-400" />
96
+ <h3 className="text-sm font-semibold text-theme-text-primary">
97
+ {sourceSide === 'a'
98
+ ? `Compare to another ${pluralToKind(source.kind)}`
99
+ : `Replace side B with another ${pluralToKind(source.kind)}`}
100
+ </h3>
101
+ </div>
102
+ <button
103
+ onClick={onClose}
104
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
105
+ aria-label="Close"
106
+ >
107
+ <X className="w-5 h-5" />
108
+ </button>
109
+ </div>
110
+
111
+ <div className="px-4 py-3 border-b border-theme-border shrink-0 space-y-2">
112
+ <div className="text-xs text-theme-text-secondary flex items-center gap-1.5 flex-wrap">
113
+ <span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none', sourceChipBg)}>
114
+ {sourceLabel}
115
+ </span>
116
+ <span className="font-mono text-theme-text-primary">
117
+ {source.namespace && <span className="opacity-60">{source.namespace}/</span>}
118
+ {source.name}
119
+ </span>
120
+ </div>
121
+ <div className="relative">
122
+ <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-theme-text-tertiary pointer-events-none" />
123
+ <input
124
+ autoFocus
125
+ type="text"
126
+ value={query}
127
+ onChange={e => setQuery(e.target.value)}
128
+ onKeyDown={handleKeyDown}
129
+ placeholder="Search by name or namespace… ↑↓ navigate, ↵ pick"
130
+ 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"
131
+ />
132
+ </div>
133
+ </div>
134
+
135
+ <div className="flex-1 min-h-0 overflow-y-auto">
136
+ {loading && (
137
+ <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
138
+ Loading {source.kind}…
139
+ </div>
140
+ )}
141
+ {error != null && (
142
+ <div className="flex items-center justify-center py-8 text-red-400 text-sm">
143
+ {error instanceof Error ? error.message : String(error)}
144
+ </div>
145
+ )}
146
+ {!loading && error == null && filtered.length === 0 && (
147
+ <div className="flex flex-col items-center justify-center py-12 text-theme-text-tertiary text-sm gap-1">
148
+ {candidates.length <= 1
149
+ ? `No other ${source.kind} available to compare against.`
150
+ : 'No matches.'}
151
+ </div>
152
+ )}
153
+ {!loading && error == null && filtered.length > 0 && (
154
+ <ul ref={listRef} className="divide-y divide-theme-border/50">
155
+ {filtered.map((c, idx) => {
156
+ const sameNs = c.namespace === source.namespace
157
+ const isActive = idx === highlightIdx
158
+ return (
159
+ <li key={`${c.namespace}/${c.name}`} data-idx={idx}>
160
+ <button
161
+ onClick={() => onPick(c)}
162
+ onMouseEnter={() => setHighlightIdx(idx)}
163
+ className={clsx(
164
+ 'w-full text-left px-4 py-2.5 transition-colors',
165
+ 'flex items-baseline gap-2',
166
+ isActive ? 'bg-skyhook-500/10' : 'hover:bg-theme-hover',
167
+ )}
168
+ >
169
+ <span className="text-sm font-mono text-theme-text-primary truncate flex-1">
170
+ {c.name}
171
+ </span>
172
+ {c.namespace && (
173
+ <span
174
+ className={clsx(
175
+ 'text-xs font-mono shrink-0',
176
+ sameNs ? 'text-skyhook-400' : 'text-theme-text-tertiary',
177
+ )}
178
+ title={sameNs ? 'Same namespace as the source — likely target' : undefined}
179
+ >
180
+ {c.namespace}
181
+ </span>
182
+ )}
183
+ </button>
184
+ </li>
185
+ )
186
+ })}
187
+ </ul>
188
+ )}
189
+ </div>
190
+ </DialogPortal>
191
+ )
192
+ }
@@ -0,0 +1,130 @@
1
+ import { clsx } from 'clsx'
2
+ import { GitCompare, X, ArrowRight } from 'lucide-react'
3
+ import { Tooltip } from '../ui/Tooltip'
4
+ import { pluralToKind } from '../../utils/navigation'
5
+ import { SIDE_TONES, type CompareSide, type NamespacedRef } from './types'
6
+
7
+ export type CompareTrayPick = NamespacedRef
8
+
9
+ export interface CompareTrayProps {
10
+ /** Plural kind (e.g. "deployments") — used to label the tray. */
11
+ kind: string
12
+ picks: CompareTrayPick[]
13
+ onRemove: (index: number) => void
14
+ /** Called when the user hits the Compare CTA. Only invoked when 2 picks. */
15
+ onCompare: () => void
16
+ /** Exit compare mode entirely (clears picks). */
17
+ onExit: () => void
18
+ }
19
+
20
+ export function CompareTray({ kind, picks, onRemove, onCompare, onExit }: CompareTrayProps) {
21
+ const slotA = picks[0]
22
+ const slotB = picks[1]
23
+ const ready = !!(slotA && slotB)
24
+ const kindLabel = pluralToKind(kind)
25
+
26
+ return (
27
+ <div
28
+ role="region"
29
+ aria-label="Compare resources tray"
30
+ className={clsx(
31
+ 'shrink-0 border-t border-skyhook-500/30 bg-theme-surface/95 backdrop-blur',
32
+ 'shadow-[0_-8px_24px_-12px_rgba(0,0,0,0.35)]',
33
+ )}
34
+ >
35
+ <div className="h-0.5 w-full bg-gradient-to-r from-blue-400/70 via-skyhook-400/40 to-emerald-400/70" />
36
+
37
+ {/* Right padding clears the fixed bottom-right overlay buttons (debug / shortcut-help). */}
38
+ <div className="flex items-center gap-3 pl-4 pr-20 py-2.5">
39
+ <div className="flex items-center gap-2 shrink-0">
40
+ <GitCompare className="w-4 h-4 text-skyhook-400" />
41
+ <span className="text-xs font-semibold text-theme-text-primary uppercase tracking-wider">
42
+ Compare {kindLabel}
43
+ </span>
44
+ <span className="text-[10px] text-theme-text-tertiary font-medium px-1.5 py-0.5 rounded bg-theme-elevated">
45
+ {picks.length}/2
46
+ </span>
47
+ </div>
48
+
49
+ <div className="flex items-center gap-2 min-w-0 flex-1">
50
+ <PickSlot side="a" pick={slotA} onRemove={() => onRemove(0)} />
51
+ <ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
52
+ <PickSlot side="b" pick={slotB} onRemove={() => onRemove(1)} />
53
+ </div>
54
+
55
+ <button
56
+ onClick={onCompare}
57
+ disabled={!ready}
58
+ className={clsx(
59
+ 'shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
60
+ ready
61
+ ? 'btn-brand'
62
+ : 'text-theme-text-disabled bg-theme-elevated border border-theme-border-light cursor-not-allowed',
63
+ )}
64
+ >
65
+ <GitCompare className="w-3.5 h-3.5" />
66
+ Compare
67
+ </button>
68
+
69
+ <Tooltip content="Exit compare mode (Esc)">
70
+ <button
71
+ onClick={onExit}
72
+ className="shrink-0 p-1.5 rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
73
+ aria-label="Exit compare mode"
74
+ >
75
+ <X className="w-4 h-4" />
76
+ </button>
77
+ </Tooltip>
78
+ </div>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ function PickSlot({
84
+ side,
85
+ pick,
86
+ onRemove,
87
+ }: {
88
+ side: CompareSide
89
+ pick?: CompareTrayPick
90
+ onRemove: () => void
91
+ }) {
92
+ const tones = SIDE_TONES[side]
93
+
94
+ if (!pick) {
95
+ return (
96
+ <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">
97
+ <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)}>
98
+ {side === 'a' ? 'A' : 'B'}
99
+ </span>
100
+ <span className="truncate">Pick {side === 'a' ? 'a resource' : 'a second resource'} from the table…</span>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ const full = pick.namespace ? `${pick.namespace}/${pick.name}` : pick.name
106
+ return (
107
+ <div
108
+ className={clsx(
109
+ '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',
110
+ tones.containerBorder, tones.containerBg,
111
+ )}
112
+ title={full}
113
+ >
114
+ <span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none shrink-0', tones.chipBg)}>
115
+ {side === 'a' ? 'A' : 'B'}
116
+ </span>
117
+ <span className="text-theme-text-primary truncate min-w-0 flex-1">
118
+ {pick.namespace && <span className="opacity-60">{pick.namespace}/</span>}
119
+ {pick.name}
120
+ </span>
121
+ <button
122
+ onClick={onRemove}
123
+ className="shrink-0 p-0.5 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated/70"
124
+ aria-label={`Remove ${side === 'a' ? 'A' : 'B'}`}
125
+ >
126
+ <X className="w-3 h-3" />
127
+ </button>
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,272 @@
1
+ import { useMemo, useRef, useState } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { ArrowLeftRight, GitCompare, Rows, FileText, FileCode2, X, Pencil, AlertTriangle, Sparkles } from 'lucide-react'
4
+ import { YamlDiffEditor } from '../ui/YamlEditor'
5
+ import { PaneLoader } from '../ui/PaneLoader'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import { pluralToKind } from '../../utils/navigation'
8
+ import { toComparableYaml } from './normalize'
9
+ import { SIDE_TONES, type CompareSide } from './types'
10
+
11
+ export type { CompareSide }
12
+
13
+ export interface CompareResourceRef {
14
+ kind: string
15
+ namespace: string
16
+ name: string
17
+ group?: string
18
+ }
19
+
20
+ export interface CompareSideError {
21
+ side: CompareSide
22
+ message: string
23
+ }
24
+
25
+ export interface ResourceCompareViewProps {
26
+ a: CompareResourceRef
27
+ b: CompareResourceRef
28
+ aData: unknown
29
+ bData: unknown
30
+ /** Per-side fetch errors. Each side renders independently — a working side stays useful. */
31
+ errors?: CompareSideError[]
32
+ /** Caller-supplied theme passthrough for the Monaco editor. */
33
+ editorTheme?: 'vs-dark' | 'vs'
34
+ onSwap: () => void
35
+ onClose: () => void
36
+ /** Optional — when provided, the resource pill shows a pencil button to re-pick. */
37
+ onChangeA?: () => void
38
+ onChangeB?: () => void
39
+ /**
40
+ * Optional small label rendered after the resource name in each pill.
41
+ * Used by cross-cluster compare to surface the cluster identity for
42
+ * each side; single-cluster compare leaves these undefined.
43
+ */
44
+ aSubtitle?: string
45
+ bSubtitle?: string
46
+ /**
47
+ * When true, the diff editor area drops its padding + rounded border.
48
+ * Use when the host renders compare as a full-bleed page (the diff IS
49
+ * the page). Default false preserves the inset-card look that works
50
+ * inside OSS Radar's narrower compare route.
51
+ */
52
+ bleed?: boolean
53
+ }
54
+
55
+ function ResourcePill({
56
+ resource,
57
+ side,
58
+ error,
59
+ onChange,
60
+ subtitle,
61
+ }: {
62
+ resource: CompareResourceRef
63
+ side: CompareSide
64
+ error?: string
65
+ onChange?: () => void
66
+ subtitle?: string
67
+ }) {
68
+ const tones = SIDE_TONES[side]
69
+ const errTone = 'border-red-400/50 bg-red-500/10'
70
+ const label = side === 'a' ? 'A' : 'B'
71
+ const full = resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name
72
+ return (
73
+ <div
74
+ className={clsx(
75
+ 'group flex items-center gap-2 pl-1.5 pr-2 py-1 rounded-lg border text-xs font-mono min-w-0 max-w-[18rem]',
76
+ error ? errTone : clsx(tones.containerBorder, tones.containerBg),
77
+ )}
78
+ title={error ? `${full} — ${error}` : full}
79
+ >
80
+ <span className={clsx('inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-bold leading-none shrink-0', error ? 'bg-red-400/90 text-red-950' : tones.chipBg)}>
81
+ {label}
82
+ </span>
83
+ {error && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" aria-hidden />}
84
+ <span className={clsx('truncate min-w-0', error ? 'text-red-300' : 'text-theme-text-primary')}>
85
+ {resource.namespace && <span className="opacity-60">{resource.namespace}/</span>}
86
+ {resource.name}
87
+ {subtitle && (
88
+ <span className="opacity-60 ml-1" aria-label={`Source: ${subtitle}`}>
89
+ · {subtitle}
90
+ </span>
91
+ )}
92
+ </span>
93
+ {onChange && (
94
+ <Tooltip content="Pick a different resource">
95
+ <button
96
+ onClick={onChange}
97
+ className="shrink-0 p-0.5 rounded text-theme-text-tertiary opacity-0 group-hover:opacity-100 focus:opacity-100 hover:text-theme-text-primary hover:bg-theme-elevated/70 transition-opacity"
98
+ >
99
+ <Pencil className="w-3 h-3" />
100
+ </button>
101
+ </Tooltip>
102
+ )}
103
+ </div>
104
+ )
105
+ }
106
+
107
+ export function ResourceCompareView({
108
+ a,
109
+ b,
110
+ aData,
111
+ bData,
112
+ errors,
113
+ editorTheme = 'vs-dark',
114
+ onSwap,
115
+ onClose,
116
+ onChangeA,
117
+ onChangeB,
118
+ aSubtitle,
119
+ bSubtitle,
120
+ bleed = false,
121
+ }: ResourceCompareViewProps) {
122
+ const [specOnly, setSpecOnly] = useState(false)
123
+ const [unified, setUnified] = useState(false)
124
+ const [hideUnchanged, setHideUnchanged] = useState(true)
125
+ const [rawMetadata, setRawMetadata] = useState(false)
126
+
127
+ const aYaml = useMemo(() => (aData ? toComparableYaml(aData, { specOnly, rawMetadata }) : ''), [aData, specOnly, rawMetadata])
128
+ const bYaml = useMemo(() => (bData ? toComparableYaml(bData, { specOnly, rawMetadata }) : ''), [bData, specOnly, rawMetadata])
129
+
130
+ const identical = aYaml && bYaml && aYaml === bYaml
131
+ const kindLabel = pluralToKind(a.kind)
132
+ const aError = errors?.find(e => e.side === 'a')?.message
133
+ const bError = errors?.find(e => e.side === 'b')?.message
134
+ const anyError = !!(aError || bError)
135
+
136
+ // Settle gate, keyed on the rendered A/B identity. Mount the diff
137
+ // editor only after both sides have first settled (data or error) for
138
+ // the current pair. Once settled, Monaco stays mounted across re-picks
139
+ // — clearing a side's data transiently during a refetch must not
140
+ // blank the editor. When the A/B identity changes (navigating to a
141
+ // new pair on the same route), the latch resets so the loader shows
142
+ // again until the new fetches settle.
143
+ const settleKey = `${a.kind}|${a.namespace}|${a.name}|${a.group ?? ''}|${aSubtitle ?? ''}` +
144
+ `|${b.kind}|${b.namespace}|${b.name}|${b.group ?? ''}|${bSubtitle ?? ''}`
145
+ const settleKeyRef = useRef(settleKey)
146
+ const hasSettledRef = useRef(false)
147
+ if (settleKeyRef.current !== settleKey) {
148
+ settleKeyRef.current = settleKey
149
+ hasSettledRef.current = false
150
+ }
151
+ const bothSettled = (!!aData || !!aError) && (!!bData || !!bError)
152
+ if (bothSettled) hasSettledRef.current = true
153
+ const showInitialLoader = !hasSettledRef.current && !bothSettled
154
+
155
+ return (
156
+ <div className="flex-1 min-w-0 flex flex-col h-full bg-theme-base">
157
+ <div className="h-0.5 w-full bg-gradient-to-r from-blue-400/70 via-skyhook-400/40 to-emerald-400/70" />
158
+
159
+ <div className="flex items-center gap-3 px-4 py-2.5 border-b border-theme-border bg-theme-surface">
160
+ <GitCompare className="w-5 h-5 text-skyhook-400 shrink-0" />
161
+ <h2 className="text-sm font-semibold text-theme-text-primary shrink-0 whitespace-nowrap">
162
+ Compare <span className="text-theme-text-tertiary mx-0.5">·</span>{' '}
163
+ <span className="text-theme-text-secondary font-medium">{kindLabel}</span>
164
+ </h2>
165
+
166
+ <div className="flex items-center gap-2 min-w-0 flex-1">
167
+ <ResourcePill resource={a} side="a" error={aError} onChange={onChangeA} subtitle={aSubtitle} />
168
+ <Tooltip content="Swap A and B">
169
+ <button
170
+ onClick={onSwap}
171
+ className="shrink-0 p-1.5 rounded-lg text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
172
+ >
173
+ <ArrowLeftRight className="w-3.5 h-3.5" />
174
+ </button>
175
+ </Tooltip>
176
+ <ResourcePill resource={b} side="b" error={bError} onChange={onChangeB} subtitle={bSubtitle} />
177
+ </div>
178
+
179
+ <div className="flex items-center gap-1 shrink-0">
180
+ <ToggleButton active={rawMetadata} onClick={() => setRawMetadata(v => !v)} icon={<Sparkles className="w-3.5 h-3.5" />} label="Raw metadata" tooltip="Keep server-assigned noise (uid, resourceVersion, managedFields, last-applied)" />
181
+ <ToggleButton active={specOnly} onClick={() => setSpecOnly(v => !v)} icon={<FileCode2 className="w-3.5 h-3.5" />} label="Spec only" tooltip="Drop status fields from both sides" />
182
+ <ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged(v => !v)} icon={<FileText className="w-3.5 h-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
183
+ <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
184
+ </div>
185
+
186
+ <div className="w-px h-5 bg-theme-border shrink-0" aria-hidden />
187
+
188
+ <Tooltip content="Close">
189
+ <button
190
+ onClick={onClose}
191
+ className="shrink-0 p-1.5 rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
192
+ >
193
+ <X className="w-4 h-4" />
194
+ </button>
195
+ </Tooltip>
196
+ </div>
197
+
198
+ {anyError && (
199
+ <div className="px-4 py-2 text-xs text-red-400 bg-red-500/10 border-b border-red-500/20 flex items-center gap-2">
200
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
201
+ <span className="min-w-0">
202
+ {aError && bError ? (
203
+ <>
204
+ Failed to load both sides — <span className="font-mono">A: {aError}</span>{' · '}<span className="font-mono">B: {bError}</span>
205
+ </>
206
+ ) : (
207
+ <>
208
+ Failed to load side {aError ? 'A' : 'B'}: <span className="font-mono">{aError || bError}</span>
209
+ </>
210
+ )}
211
+ {(onChangeA || onChangeB) && ' — use the pencil icon on the affected pill to pick a different resource.'}
212
+ </span>
213
+ </div>
214
+ )}
215
+
216
+ {identical && !anyError && (
217
+ <div className="px-4 py-2 text-xs text-emerald-400 bg-emerald-500/10 border-b border-emerald-500/20 flex items-center gap-2">
218
+ <GitCompare className="w-3.5 h-3.5" />
219
+ These resources are identical{specOnly ? ' (spec only)' : ''}.
220
+ </div>
221
+ )}
222
+
223
+ <div className={clsx('flex-1 min-h-0', bleed ? '' : 'p-3')}>
224
+ {showInitialLoader ? (
225
+ <PaneLoader label="Loading resources…" className="h-full" />
226
+ ) : (
227
+ <YamlDiffEditor
228
+ original={aYaml}
229
+ modified={bYaml}
230
+ unified={unified}
231
+ hideUnchanged={hideUnchanged && !identical}
232
+ theme={editorTheme}
233
+ height="100%"
234
+ bleed={bleed}
235
+ />
236
+ )}
237
+ </div>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ function ToggleButton({
243
+ active,
244
+ onClick,
245
+ icon,
246
+ label,
247
+ tooltip,
248
+ }: {
249
+ active: boolean
250
+ onClick: () => void
251
+ icon: React.ReactNode
252
+ label: string
253
+ tooltip: string
254
+ }) {
255
+ return (
256
+ <Tooltip content={tooltip}>
257
+ <button
258
+ onClick={onClick}
259
+ aria-pressed={active}
260
+ className={clsx(
261
+ 'flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-colors whitespace-nowrap',
262
+ active
263
+ ? 'border-skyhook-400/50 bg-skyhook-500/15 text-skyhook-300'
264
+ : 'border-transparent text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
265
+ )}
266
+ >
267
+ {icon}
268
+ {label}
269
+ </button>
270
+ </Tooltip>
271
+ )
272
+ }
@@ -0,0 +1,14 @@
1
+ export { ResourceCompareView } from './ResourceCompareView'
2
+ export type { ResourceCompareViewProps, CompareResourceRef, CompareSideError } from './ResourceCompareView'
3
+ export { CompareResourcePicker } from './CompareResourcePicker'
4
+ export type { CompareResourcePickerProps } from './CompareResourcePicker'
5
+ export { CompareTray } from './CompareTray'
6
+ export type { CompareTrayProps, CompareTrayPick } from './CompareTray'
7
+ export { normalizeForCompare, toComparableYaml } from './normalize'
8
+ export type { NormalizeOptions } from './normalize'
9
+ export { parseRef, refToParam } from './url'
10
+ export type { ParsedRef } from './url'
11
+ export { togglePick, pickIndex, COMPARE_PICK_CAP } from './picks'
12
+ export { sortCandidates, filterCandidates } from './sort'
13
+ export { SIDE_TONES } from './types'
14
+ export type { CompareSide, NamespacedRef } from './types'