@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.
@@ -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
+ }