@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.
- package/package.json +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- 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'
|