@skyhook-io/k8s-ui 1.8.13 → 1.8.15
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/README.md +9 -0
- package/package.json +6 -3
- package/src/components/resources/get-pod-phase-display.test.ts +204 -0
- package/src/components/resources/resource-utils.ts +101 -0
- package/src/components/shared/CreateResourceDialog.tsx +307 -172
- package/src/components/shared/EditableYamlView.tsx +197 -26
- package/src/components/ui/YamlEditor.test.ts +148 -0
- package/src/components/ui/YamlEditor.tsx +759 -186
- package/src/components/ui/YamlReview.test.ts +45 -0
- package/src/components/ui/YamlReview.tsx +426 -0
- package/src/components/ui/index.ts +9 -0
- package/src/components/ui/monacoRuntime.ts +41 -0
- package/src/components/ui/monacoYaml.worker.ts +1 -0
- package/src/components/ui/yamlMonacoRuntime.ts +110 -0
- package/src/components/workload/WorkloadView.tsx +27 -2
- package/src/monaco-deep.d.ts +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/yaml.test.ts +100 -2
- package/src/utils/yaml.ts +86 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
canApplyYamlPreview,
|
|
4
|
+
reviewedResourceVersionsForPreview,
|
|
5
|
+
type YamlPreviewResult,
|
|
6
|
+
} from './YamlReview'
|
|
7
|
+
|
|
8
|
+
const accepted: YamlPreviewResult = { index: 0, status: 'accepted' }
|
|
9
|
+
|
|
10
|
+
describe('canApplyYamlPreview', () => {
|
|
11
|
+
it('allows fully accepted previews', () => {
|
|
12
|
+
expect(canApplyYamlPreview([accepted], false)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('blocks authoritative rejections even after acknowledgement', () => {
|
|
16
|
+
expect(canApplyYamlPreview([accepted, { index: 1, status: 'rejected' }], true)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('requires acknowledgement for dependency or dry-run unavailable documents', () => {
|
|
20
|
+
const documents = [accepted, { index: 1, status: 'unavailable' as const }]
|
|
21
|
+
expect(canApplyYamlPreview(documents, false)).toBe(false)
|
|
22
|
+
expect(canApplyYamlPreview(documents, true)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('blocks apply until the review diff is visible', () => {
|
|
26
|
+
expect(canApplyYamlPreview([accepted], false, false)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('reviewedResourceVersionsForPreview', () => {
|
|
31
|
+
it('guards reviewed creates by recording expected absence', () => {
|
|
32
|
+
expect(
|
|
33
|
+
reviewedResourceVersionsForPreview([
|
|
34
|
+
{ index: 0, status: 'accepted', action: 'create' },
|
|
35
|
+
{
|
|
36
|
+
index: 1,
|
|
37
|
+
status: 'accepted',
|
|
38
|
+
action: 'update',
|
|
39
|
+
reviewedResourceVersion: '42',
|
|
40
|
+
},
|
|
41
|
+
{ index: 2, status: 'unavailable', action: 'unknown' },
|
|
42
|
+
]),
|
|
43
|
+
).toEqual({ 0: '', 1: '42' })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { AlertTriangle, ArrowLeft, Check, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react'
|
|
3
|
+
import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
|
|
4
|
+
import { Badge } from './Badge'
|
|
5
|
+
import { StatusDot } from './status-tone'
|
|
6
|
+
import { YamlDiffEditor } from './YamlEditor'
|
|
7
|
+
|
|
8
|
+
export interface YamlPreviewResult {
|
|
9
|
+
index: number
|
|
10
|
+
status: 'accepted' | 'rejected' | 'unavailable'
|
|
11
|
+
apiVersion?: string
|
|
12
|
+
kind?: string
|
|
13
|
+
namespace?: string
|
|
14
|
+
name?: string
|
|
15
|
+
action?: 'create' | 'update' | 'unknown'
|
|
16
|
+
submittedYaml?: string
|
|
17
|
+
baselineYaml?: string
|
|
18
|
+
predictedYaml?: string
|
|
19
|
+
warnings?: string[]
|
|
20
|
+
error?: string
|
|
21
|
+
reviewedResourceVersion?: string
|
|
22
|
+
redacted?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface YamlReviewProps {
|
|
26
|
+
submittedYaml: string
|
|
27
|
+
originalYaml?: string
|
|
28
|
+
documents: YamlPreviewResult[]
|
|
29
|
+
nonAtomic?: boolean
|
|
30
|
+
force?: boolean
|
|
31
|
+
isApplying?: boolean
|
|
32
|
+
applyError?: string | null
|
|
33
|
+
applyLabel?: string
|
|
34
|
+
onClose?: () => void
|
|
35
|
+
onBack: () => void
|
|
36
|
+
onApply: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function splitYaml(content: string) {
|
|
40
|
+
return splitYamlDocuments(content).map((document) => document.content)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function previewSeverity(status: YamlPreviewResult['status']) {
|
|
44
|
+
if (status === 'accepted') return 'success' as const
|
|
45
|
+
if (status === 'rejected') return 'error' as const
|
|
46
|
+
return 'warning' as const
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function canApplyYamlPreview(
|
|
50
|
+
documents: YamlPreviewResult[],
|
|
51
|
+
unavailableAcknowledged: boolean,
|
|
52
|
+
reviewReady = true,
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
reviewReady &&
|
|
56
|
+
!documents.some((document) => document.status === 'rejected') &&
|
|
57
|
+
(unavailableAcknowledged || !documents.some((document) => document.status === 'unavailable'))
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function reviewedResourceVersionsForPreview(documents: YamlPreviewResult[]) {
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
documents.flatMap((document) => {
|
|
64
|
+
if (document.action === 'create') return [[document.index, '']]
|
|
65
|
+
if (document.action === 'update' && document.reviewedResourceVersion) {
|
|
66
|
+
return [[document.index, document.reviewedResourceVersion]]
|
|
67
|
+
}
|
|
68
|
+
return []
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function YamlReview({
|
|
74
|
+
submittedYaml,
|
|
75
|
+
originalYaml,
|
|
76
|
+
documents,
|
|
77
|
+
nonAtomic = false,
|
|
78
|
+
force = false,
|
|
79
|
+
isApplying = false,
|
|
80
|
+
applyError,
|
|
81
|
+
applyLabel = 'Apply reviewed changes',
|
|
82
|
+
onClose,
|
|
83
|
+
onBack,
|
|
84
|
+
onApply,
|
|
85
|
+
}: YamlReviewProps) {
|
|
86
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
87
|
+
const reviewRef = useRef<HTMLDivElement>(null)
|
|
88
|
+
const [unified, setUnified] = useState(
|
|
89
|
+
() => typeof window !== 'undefined' && window.innerWidth < 900,
|
|
90
|
+
)
|
|
91
|
+
const diffLayoutChosen = useRef(false)
|
|
92
|
+
const [hideUnchanged, setHideUnchanged] = useState(true)
|
|
93
|
+
const [unavailableAcknowledged, setUnavailableAcknowledged] = useState(false)
|
|
94
|
+
const [diffReady, setDiffReady] = useState(false)
|
|
95
|
+
const reviewSignature = useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
documents.map(
|
|
99
|
+
({
|
|
100
|
+
index,
|
|
101
|
+
status,
|
|
102
|
+
action,
|
|
103
|
+
reviewedResourceVersion,
|
|
104
|
+
predictedYaml,
|
|
105
|
+
submittedYaml: documentYaml,
|
|
106
|
+
error,
|
|
107
|
+
}) => ({
|
|
108
|
+
index,
|
|
109
|
+
status,
|
|
110
|
+
action,
|
|
111
|
+
reviewedResourceVersion,
|
|
112
|
+
predictedYaml,
|
|
113
|
+
documentYaml,
|
|
114
|
+
error,
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
[documents],
|
|
119
|
+
)
|
|
120
|
+
const submitted = useMemo(() => splitYaml(submittedYaml), [submittedYaml])
|
|
121
|
+
const original = useMemo(
|
|
122
|
+
() => (originalYaml ? splitYaml(originalYaml) : submitted),
|
|
123
|
+
[originalYaml, submitted],
|
|
124
|
+
)
|
|
125
|
+
const selected = documents[selectedIndex] ?? documents[0]
|
|
126
|
+
const before = normalizeYamlForReview(
|
|
127
|
+
selected?.baselineYaml ||
|
|
128
|
+
(originalYaml
|
|
129
|
+
? (original[selected?.index] ?? '')
|
|
130
|
+
: selected?.submittedYaml || submitted[selected?.index] || ''),
|
|
131
|
+
)
|
|
132
|
+
const after = normalizeYamlForReview(
|
|
133
|
+
selected?.predictedYaml || selected?.submittedYaml || submitted[selected?.index] || '',
|
|
134
|
+
)
|
|
135
|
+
const unavailable = documents.filter((document) => document.status === 'unavailable')
|
|
136
|
+
const rejected = documents.filter((document) => document.status === 'rejected')
|
|
137
|
+
const accepted = documents.filter((document) => document.status === 'accepted')
|
|
138
|
+
const canApply = canApplyYamlPreview(documents, unavailableAcknowledged, diffReady)
|
|
139
|
+
const applyBlockedReason = isApplying
|
|
140
|
+
? 'Applying reviewed changes'
|
|
141
|
+
: rejected.length > 0
|
|
142
|
+
? `Fix ${rejected.length} rejected document${rejected.length === 1 ? '' : 's'} before applying`
|
|
143
|
+
: unavailable.length > 0 && !unavailableAcknowledged
|
|
144
|
+
? `Acknowledge ${unavailable.length} unverified document${unavailable.length === 1 ? '' : 's'} before applying`
|
|
145
|
+
: !diffReady
|
|
146
|
+
? 'Waiting for the review diff to render'
|
|
147
|
+
: undefined
|
|
148
|
+
const noChanges = selected?.status === 'accepted' && before === after
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
setUnavailableAcknowledged(false)
|
|
152
|
+
}, [reviewSignature])
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const review = reviewRef.current
|
|
156
|
+
if (!review) return
|
|
157
|
+
const adaptDiffLayout = () => {
|
|
158
|
+
if (!diffLayoutChosen.current) setUnified(review.clientWidth < 800)
|
|
159
|
+
}
|
|
160
|
+
adaptDiffLayout()
|
|
161
|
+
const observer = new ResizeObserver(adaptDiffLayout)
|
|
162
|
+
observer.observe(review)
|
|
163
|
+
return () => observer.disconnect()
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div ref={reviewRef} className="flex h-full min-h-0 flex-col bg-theme-base">
|
|
168
|
+
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-theme-border bg-theme-elevated/70 px-4 py-2.5">
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={onBack}
|
|
172
|
+
disabled={isApplying}
|
|
173
|
+
className="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
|
|
174
|
+
>
|
|
175
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
176
|
+
Back to edit
|
|
177
|
+
</button>
|
|
178
|
+
<div className="h-4 w-px bg-theme-border" />
|
|
179
|
+
<div>
|
|
180
|
+
<div className="text-sm font-semibold text-theme-text-primary">
|
|
181
|
+
Review cluster-admitted changes
|
|
182
|
+
</div>
|
|
183
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
184
|
+
{accepted.length} accepted · {rejected.length} rejected · {unavailable.length} not
|
|
185
|
+
previewable
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div className="ml-auto flex items-center gap-2">
|
|
189
|
+
<div className="flex rounded-md border border-theme-border bg-theme-base p-0.5 text-xs">
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => {
|
|
193
|
+
diffLayoutChosen.current = true
|
|
194
|
+
setUnified(false)
|
|
195
|
+
}}
|
|
196
|
+
aria-pressed={!unified}
|
|
197
|
+
className={`rounded px-2 py-1 ${!unified ? 'bg-theme-elevated text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'}`}
|
|
198
|
+
>
|
|
199
|
+
Side by side
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => {
|
|
204
|
+
diffLayoutChosen.current = true
|
|
205
|
+
setUnified(true)
|
|
206
|
+
}}
|
|
207
|
+
aria-pressed={unified}
|
|
208
|
+
className={`rounded px-2 py-1 ${unified ? 'bg-theme-elevated text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary'}`}
|
|
209
|
+
>
|
|
210
|
+
Unified
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
<label className="flex items-center gap-1.5 text-xs text-theme-text-secondary">
|
|
214
|
+
<input
|
|
215
|
+
type="checkbox"
|
|
216
|
+
checked={hideUnchanged}
|
|
217
|
+
onChange={(event) => setHideUnchanged(event.target.checked)}
|
|
218
|
+
className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
|
|
219
|
+
/>
|
|
220
|
+
Collapse unchanged
|
|
221
|
+
</label>
|
|
222
|
+
{onClose && (
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={onClose}
|
|
226
|
+
disabled={isApplying}
|
|
227
|
+
aria-label="Close review"
|
|
228
|
+
className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
|
|
229
|
+
>
|
|
230
|
+
<X className="h-4 w-4" />
|
|
231
|
+
</button>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
237
|
+
{documents.length > 1 && (
|
|
238
|
+
<nav
|
|
239
|
+
className="max-h-36 shrink-0 overflow-auto border-b border-theme-border bg-theme-surface p-2 md:max-h-none md:w-56 md:border-b-0 md:border-r"
|
|
240
|
+
aria-label="Previewed YAML documents"
|
|
241
|
+
>
|
|
242
|
+
{documents.map((document, index) => (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
key={`${document.index}:${document.kind}:${document.name}`}
|
|
246
|
+
onClick={() => setSelectedIndex(index)}
|
|
247
|
+
className={`mb-1 flex w-full items-start gap-2 rounded-md border px-2.5 py-2 text-left ${
|
|
248
|
+
index === selectedIndex
|
|
249
|
+
? 'border-theme-border bg-theme-elevated'
|
|
250
|
+
: 'border-transparent hover:bg-theme-hover'
|
|
251
|
+
}`}
|
|
252
|
+
>
|
|
253
|
+
<StatusDot
|
|
254
|
+
tone={
|
|
255
|
+
document.status === 'accepted'
|
|
256
|
+
? 'healthy'
|
|
257
|
+
: document.status === 'rejected'
|
|
258
|
+
? 'unhealthy'
|
|
259
|
+
: 'degraded'
|
|
260
|
+
}
|
|
261
|
+
size="md"
|
|
262
|
+
className="mt-1"
|
|
263
|
+
/>
|
|
264
|
+
<span className="min-w-0">
|
|
265
|
+
<span className="block truncate text-xs font-medium text-theme-text-primary">
|
|
266
|
+
{document.kind || `Document ${document.index + 1}`}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="block truncate text-[11px] text-theme-text-tertiary">
|
|
269
|
+
{document.namespace ? `${document.namespace}/` : ''}
|
|
270
|
+
{document.name || 'Unnamed'}
|
|
271
|
+
</span>
|
|
272
|
+
</span>
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
</nav>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
279
|
+
{selected && (
|
|
280
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-theme-border bg-theme-surface px-3 py-2">
|
|
281
|
+
<Badge severity={previewSeverity(selected.status)} size="sm">
|
|
282
|
+
{selected.status === 'accepted' ? (
|
|
283
|
+
<Check className="h-3 w-3" />
|
|
284
|
+
) : (
|
|
285
|
+
<AlertTriangle className="h-3 w-3" />
|
|
286
|
+
)}
|
|
287
|
+
{selected.status === 'unavailable' ? 'Not previewable' : selected.status}
|
|
288
|
+
</Badge>
|
|
289
|
+
{selected.action && selected.action !== 'unknown' && (
|
|
290
|
+
<Badge severity="neutral" size="sm">
|
|
291
|
+
Will {selected.action}
|
|
292
|
+
</Badge>
|
|
293
|
+
)}
|
|
294
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
295
|
+
{selected.baselineYaml
|
|
296
|
+
? 'Live at review → server preview'
|
|
297
|
+
: originalYaml
|
|
298
|
+
? 'Live when editing started → server preview'
|
|
299
|
+
: 'Effective submitted object → server preview'}
|
|
300
|
+
</span>
|
|
301
|
+
{selected.redacted && (
|
|
302
|
+
<Badge tone="note" size="sm">
|
|
303
|
+
Secret values masked
|
|
304
|
+
</Badge>
|
|
305
|
+
)}
|
|
306
|
+
{noChanges && (
|
|
307
|
+
<Badge severity="neutral" size="sm">
|
|
308
|
+
No effective changes
|
|
309
|
+
</Badge>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{(selected?.error || (selected?.warnings?.length ?? 0) > 0) && (
|
|
315
|
+
<div className="shrink-0 space-y-1 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs">
|
|
316
|
+
{selected.error && (
|
|
317
|
+
<div
|
|
318
|
+
className={
|
|
319
|
+
selected.status === 'rejected'
|
|
320
|
+
? 'text-red-600 dark:text-red-400'
|
|
321
|
+
: 'text-warning-text'
|
|
322
|
+
}
|
|
323
|
+
>
|
|
324
|
+
{selected.error}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
{selected.warnings?.map((warning) => (
|
|
328
|
+
<div key={warning} className="flex items-start gap-1.5 text-warning-text">
|
|
329
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
330
|
+
<span>{warning}</span>
|
|
331
|
+
</div>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
<div className="min-h-0 flex-1 bg-theme-surface">
|
|
337
|
+
<YamlDiffEditor
|
|
338
|
+
original={before}
|
|
339
|
+
modified={after}
|
|
340
|
+
height="100%"
|
|
341
|
+
unified={unified}
|
|
342
|
+
hideUnchanged={hideUnchanged}
|
|
343
|
+
bleed
|
|
344
|
+
onReadyChange={setDiffReady}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
<div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
|
|
351
|
+
{applyError && (
|
|
352
|
+
<div className="mb-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-600 dark:text-red-400">
|
|
353
|
+
{applyError}
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
{nonAtomic && (
|
|
357
|
+
<div className="mb-2 flex items-start gap-2 text-xs text-theme-text-secondary">
|
|
358
|
+
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-text" />
|
|
359
|
+
<span>
|
|
360
|
+
Resources apply sequentially, not atomically. Earlier documents can succeed before a
|
|
361
|
+
later document fails.
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
{unavailable.length > 0 && rejected.length === 0 && (
|
|
366
|
+
<label className="mb-2 flex items-start gap-2 text-xs text-theme-text-secondary">
|
|
367
|
+
<input
|
|
368
|
+
type="checkbox"
|
|
369
|
+
checked={unavailableAcknowledged}
|
|
370
|
+
onChange={(event) => setUnavailableAcknowledged(event.target.checked)}
|
|
371
|
+
className="mt-0.5 h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
|
|
372
|
+
/>
|
|
373
|
+
<span>
|
|
374
|
+
Apply with {unavailable.length} unverified document
|
|
375
|
+
{unavailable.length === 1 ? '' : 's'}. This can happen when documents depend on
|
|
376
|
+
earlier resources, an API does not support dry-run, or live state could not be read.
|
|
377
|
+
Unverified documents are not protected against changes after review.
|
|
378
|
+
</span>
|
|
379
|
+
</label>
|
|
380
|
+
)}
|
|
381
|
+
{rejected.length > 0 && (
|
|
382
|
+
<div className="mb-2 flex items-center gap-2 text-xs text-red-600 dark:text-red-400">
|
|
383
|
+
<ShieldAlert className="h-3.5 w-3.5" />
|
|
384
|
+
Fix the rejected document{rejected.length === 1 ? '' : 's'} before applying.
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
<div className="flex items-center justify-between gap-3">
|
|
388
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-theme-text-tertiary">
|
|
389
|
+
<span className="flex items-center gap-2">
|
|
390
|
+
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
|
391
|
+
Preview used Kubernetes server-side dry-run. Admission can still change before apply.
|
|
392
|
+
</span>
|
|
393
|
+
<Badge severity={force ? 'warning' : 'neutral'} size="sm">
|
|
394
|
+
Force {force ? 'on' : 'off'}
|
|
395
|
+
</Badge>
|
|
396
|
+
<span>
|
|
397
|
+
{force
|
|
398
|
+
? 'Other managers’ fields can be overwritten.'
|
|
399
|
+
: 'Other-manager conflicts block the save.'}
|
|
400
|
+
</span>
|
|
401
|
+
</div>
|
|
402
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
403
|
+
{applyBlockedReason && !isApplying && (
|
|
404
|
+
<span
|
|
405
|
+
aria-live="polite"
|
|
406
|
+
className="max-w-48 text-right text-[11px] text-theme-text-tertiary"
|
|
407
|
+
>
|
|
408
|
+
{applyBlockedReason}
|
|
409
|
+
</span>
|
|
410
|
+
)}
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={onApply}
|
|
414
|
+
disabled={!canApply || isApplying}
|
|
415
|
+
title={applyBlockedReason}
|
|
416
|
+
className="btn-brand flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg px-4 py-1.5 text-xs font-medium disabled:cursor-not-allowed"
|
|
417
|
+
>
|
|
418
|
+
{isApplying && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
419
|
+
{isApplying ? 'Applying…' : applyLabel}
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
@@ -28,6 +28,15 @@ export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
|
28
28
|
export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
|
|
29
29
|
export { CodeViewer } from './CodeViewer'
|
|
30
30
|
export { YamlEditor, YamlDiffEditor } from './YamlEditor'
|
|
31
|
+
export type {
|
|
32
|
+
YamlDiagnostic,
|
|
33
|
+
YamlDocumentIdentity,
|
|
34
|
+
YamlEditorProps,
|
|
35
|
+
YamlSchemaLoader,
|
|
36
|
+
YamlSchemaLoadResult,
|
|
37
|
+
} from './YamlEditor'
|
|
38
|
+
export { YamlReview } from './YamlReview'
|
|
39
|
+
export type { YamlPreviewResult, YamlReviewProps } from './YamlReview'
|
|
31
40
|
export { RowActionMenu } from './RowActionMenu'
|
|
32
41
|
export type { RowActionItem } from './RowActionMenu'
|
|
33
42
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference path="../../monaco-deep.d.ts" />
|
|
2
|
+
|
|
3
|
+
import { loader } from '@monaco-editor/react'
|
|
4
|
+
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
|
|
5
|
+
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
6
|
+
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js'
|
|
7
|
+
import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'
|
|
8
|
+
import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js'
|
|
9
|
+
import 'monaco-editor/esm/vs/editor/contrib/gotoError/browser/gotoError.js'
|
|
10
|
+
import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js'
|
|
11
|
+
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js'
|
|
12
|
+
|
|
13
|
+
type MonacoGlobal = typeof globalThis & {
|
|
14
|
+
MonacoEnvironment?: {
|
|
15
|
+
getWorker(moduleId: string, label: string): Worker
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let monacoReady = false
|
|
20
|
+
|
|
21
|
+
export async function ensureMonaco() {
|
|
22
|
+
if (!monacoReady) {
|
|
23
|
+
;(globalThis as MonacoGlobal).MonacoEnvironment = {
|
|
24
|
+
getWorker() {
|
|
25
|
+
return new Worker(
|
|
26
|
+
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
|
|
27
|
+
{
|
|
28
|
+
name: 'radar-monaco-editor',
|
|
29
|
+
type: 'module',
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
loader.config({ monaco })
|
|
35
|
+
monacoReady = true
|
|
36
|
+
}
|
|
37
|
+
return monaco
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { monaco }
|
|
41
|
+
export type YamlMonaco = typeof monaco
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'monaco-yaml/yaml.worker.js'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { configureMonacoYaml, type JSONSchema, type MonacoYaml } from 'monaco-yaml'
|
|
2
|
+
import { ensureMonaco, monaco } from './monacoRuntime'
|
|
3
|
+
|
|
4
|
+
interface LegacyWorkerOptions {
|
|
5
|
+
createData: unknown
|
|
6
|
+
host?: unknown
|
|
7
|
+
keepIdleModels?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const schemaRegistrations = new Map<
|
|
11
|
+
string,
|
|
12
|
+
{ token: symbol; schema: JSONSchema; schemaUri: string }
|
|
13
|
+
>()
|
|
14
|
+
let yamlService: MonacoYaml | undefined
|
|
15
|
+
let schemaUpdate = Promise.resolve<unknown>(undefined)
|
|
16
|
+
let schemaSerial = 0
|
|
17
|
+
|
|
18
|
+
function createYamlFacade(): typeof monaco {
|
|
19
|
+
// monaco-yaml 5.5 still calls the worker factory shape that Monaco 0.55 replaced.
|
|
20
|
+
const nativeCreateWebWorker = monaco.editor.createWebWorker.bind(monaco.editor)
|
|
21
|
+
const createWebWorker = (options: LegacyWorkerOptions) => {
|
|
22
|
+
const worker = new Worker(new URL('./monacoYaml.worker.ts', import.meta.url), {
|
|
23
|
+
name: 'radar-monaco-yaml',
|
|
24
|
+
type: 'module',
|
|
25
|
+
})
|
|
26
|
+
worker.postMessage({ type: 'bootstrap' })
|
|
27
|
+
worker.postMessage(options.createData)
|
|
28
|
+
return nativeCreateWebWorker({
|
|
29
|
+
worker: Promise.resolve(worker),
|
|
30
|
+
host: options.host,
|
|
31
|
+
keepIdleModels: options.keepIdleModels,
|
|
32
|
+
} as Parameters<typeof monaco.editor.createWebWorker>[0])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...monaco,
|
|
37
|
+
editor: {
|
|
38
|
+
...monaco.editor,
|
|
39
|
+
createWebWorker,
|
|
40
|
+
},
|
|
41
|
+
} as unknown as typeof monaco
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function configuredSchemas() {
|
|
45
|
+
return [...schemaRegistrations.entries()].map(([modelUri, registration]) => ({
|
|
46
|
+
uri: registration.schemaUri,
|
|
47
|
+
fileMatch: [modelUri],
|
|
48
|
+
schema: registration.schema,
|
|
49
|
+
}))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function refreshSchemas() {
|
|
53
|
+
if (!yamlService) return Promise.resolve()
|
|
54
|
+
schemaUpdate = schemaUpdate
|
|
55
|
+
.catch(() => undefined)
|
|
56
|
+
.then(() => yamlService?.update({ schemas: configuredSchemas() }))
|
|
57
|
+
return schemaUpdate
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function registerYamlSchema(
|
|
61
|
+
modelUri: string,
|
|
62
|
+
schemaSequence: Array<Record<string, unknown> | null>,
|
|
63
|
+
) {
|
|
64
|
+
const token = Symbol(modelUri)
|
|
65
|
+
const rootSchema = schemaSequence.find((value) => value !== null)
|
|
66
|
+
if (!rootSchema) {
|
|
67
|
+
await refreshSchemas()
|
|
68
|
+
return () => undefined
|
|
69
|
+
}
|
|
70
|
+
const definitions = Object.assign({}, ...schemaSequence.map((value) => value?.definitions ?? {}))
|
|
71
|
+
const schema = (
|
|
72
|
+
schemaSequence.length === 1
|
|
73
|
+
? { ...rootSchema, title: 'Kubernetes cluster schema' }
|
|
74
|
+
: {
|
|
75
|
+
title: 'Kubernetes cluster schema',
|
|
76
|
+
definitions,
|
|
77
|
+
schemaSequence: schemaSequence.map((value) => {
|
|
78
|
+
if (!value) return {}
|
|
79
|
+
const documentSchema = { ...value }
|
|
80
|
+
delete documentSchema.definitions
|
|
81
|
+
return { ...documentSchema, title: 'Kubernetes cluster schema' }
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
) as JSONSchema
|
|
85
|
+
schemaRegistrations.set(modelUri, {
|
|
86
|
+
token,
|
|
87
|
+
schema,
|
|
88
|
+
schemaUri: `radar://schemas/schema-${++schemaSerial}.json`,
|
|
89
|
+
})
|
|
90
|
+
await refreshSchemas()
|
|
91
|
+
return () => {
|
|
92
|
+
if (schemaRegistrations.get(modelUri)?.token !== token) return
|
|
93
|
+
schemaRegistrations.delete(modelUri)
|
|
94
|
+
void refreshSchemas().catch(() => undefined)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function ensureYamlMonaco() {
|
|
99
|
+
await ensureMonaco()
|
|
100
|
+
if (!yamlService) {
|
|
101
|
+
yamlService = configureMonacoYaml(createYamlFacade(), {
|
|
102
|
+
enableSchemaRequest: false,
|
|
103
|
+
disableAdditionalProperties: true,
|
|
104
|
+
isKubernetes: true,
|
|
105
|
+
format: { enable: true, printWidth: 100 },
|
|
106
|
+
schemas: configuredSchemas(),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return monaco
|
|
110
|
+
}
|