@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.
@@ -6,6 +6,7 @@ import {
6
6
  RefreshCw,
7
7
  Pencil,
8
8
  Save,
9
+ Eye,
9
10
  XCircle,
10
11
  AlertTriangle,
11
12
  } from 'lucide-react'
@@ -13,6 +14,8 @@ import { Download } from 'lucide-react'
13
14
  import { stringify as yamlStringify } from 'yaml'
14
15
  import { CodeViewer } from '../ui/CodeViewer'
15
16
  import { YamlEditor } from '../ui/YamlEditor'
17
+ import type { YamlSchemaLoader } from '../ui/YamlEditor'
18
+ import { YamlReview, type YamlPreviewResult } from '../ui/YamlReview'
16
19
  import { Tooltip } from '../ui/Tooltip'
17
20
  import type { SelectedResource } from '../../types'
18
21
  import { resourceToYaml } from '../../utils/yaml'
@@ -40,20 +43,23 @@ export function SaveSuccessAnimation() {
40
43
  // ============================================================================
41
44
 
42
45
  // Get edit warning for resource types with limited editability
43
- function getEditWarning(kind: string): { message: string; tip: string; learnMoreUrl?: string } | null {
46
+ function getEditWarning(
47
+ kind: string,
48
+ ): { message: string; tip: string; learnMoreUrl?: string } | null {
44
49
  const k = kind.toLowerCase()
45
50
  if (k === 'pods' || k === 'pod') {
46
51
  return {
47
52
  message: 'Pods have limited editability.',
48
53
  tip: 'Green highlighted lines can be changed. Edit the parent Deployment instead for other fields.',
49
- learnMoreUrl: 'https://kubernetes.io/docs/concepts/workloads/pods/#pod-update-and-replacement'
54
+ learnMoreUrl:
55
+ 'https://kubernetes.io/docs/concepts/workloads/pods/#pod-update-and-replacement',
50
56
  }
51
57
  }
52
58
  if (k === 'jobs' || k === 'job') {
53
59
  return {
54
60
  message: 'Jobs cannot be modified after creation.',
55
61
  tip: 'Delete and recreate the Job to make changes.',
56
- learnMoreUrl: 'https://kubernetes.io/docs/concepts/workloads/controllers/job/'
62
+ learnMoreUrl: 'https://kubernetes.io/docs/concepts/workloads/controllers/job/',
57
63
  }
58
64
  }
59
65
  return null
@@ -77,7 +83,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
77
83
  const message = errorPart.slice(messageStart, messageEnd).trim()
78
84
  return {
79
85
  summary: `Cannot update ${target}: ${message}`,
80
- details: error.length > 200 ? error : undefined
86
+ details: error.length > 200 ? error : undefined,
81
87
  }
82
88
  }
83
89
  }
@@ -87,7 +93,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
87
93
  if (summary) {
88
94
  return {
89
95
  summary,
90
- details: error.length > 200 ? error : undefined
96
+ details: error.length > 200 ? error : undefined,
91
97
  }
92
98
  }
93
99
  }
@@ -95,7 +101,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
95
101
  if (error.length > 150) {
96
102
  return {
97
103
  summary: error.substring(0, 150) + '...',
98
- details: error
104
+ details: error,
99
105
  }
100
106
  }
101
107
 
@@ -105,16 +111,28 @@ function formatSaveError(error: string): { summary: string; details?: string } {
105
111
  // Safe sessionStorage wrappers — storage can throw QuotaExceededError or be
106
112
  // blocked by browser security policies. Draft persistence is best-effort.
107
113
  function safeSessionGet(key: string): string | null {
108
- try { return sessionStorage.getItem(key) } catch { return null }
114
+ try {
115
+ return sessionStorage.getItem(key)
116
+ } catch {
117
+ return null
118
+ }
109
119
  }
110
120
  function safeSessionSet(key: string, value: string): void {
111
- try { sessionStorage.setItem(key, value) } catch { /* best-effort */ }
121
+ try {
122
+ sessionStorage.setItem(key, value)
123
+ } catch {
124
+ /* best-effort */
125
+ }
112
126
  }
113
127
  function safeSessionRemove(key: string): void {
114
- try { sessionStorage.removeItem(key) } catch { /* best-effort */ }
128
+ try {
129
+ sessionStorage.removeItem(key)
130
+ } catch {
131
+ /* best-effort */
132
+ }
115
133
  }
116
134
 
117
- interface EditableYamlViewProps {
135
+ export interface EditableYamlViewProps {
118
136
  resource: SelectedResource
119
137
  data: any
120
138
  onCopy: (text: string) => void
@@ -124,11 +142,32 @@ interface EditableYamlViewProps {
124
142
  /** Called after a successful save so the parent can refetch */
125
143
  onSaved?: () => void
126
144
  /** Save handler — injected by the platform wrapper */
127
- onSave?: (params: { kind: string; namespace: string; name: string; yaml: string; force: boolean }) => Promise<void>
145
+ onSave?: (params: {
146
+ kind: string
147
+ namespace: string
148
+ name: string
149
+ yaml: string
150
+ force: boolean
151
+ reviewedResourceVersion?: string
152
+ reviewedContext?: string
153
+ }) => Promise<void>
128
154
  /** Whether a save is in progress */
129
155
  isSaving?: boolean
130
156
  /** Error message from the last save attempt */
131
157
  saveError?: string | null
158
+ onPreview?: (params: {
159
+ yaml: string
160
+ mode: 'update'
161
+ force: boolean
162
+ target: { kind: string; namespace: string; name: string }
163
+ }) => Promise<{
164
+ documents: YamlPreviewResult[]
165
+ nonAtomic: boolean
166
+ context?: string
167
+ }>
168
+ isPreviewing?: boolean
169
+ previewError?: string | null
170
+ schemaLoader?: YamlSchemaLoader
132
171
  /** Duplicate handler — opens create dialog with this resource's YAML */
133
172
  onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
134
173
  /**
@@ -138,7 +177,23 @@ interface EditableYamlViewProps {
138
177
  onDownload?: (content: string, mime: string, filename: string) => void
139
178
  }
140
179
 
141
- export function EditableYamlView({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
180
+ export function EditableYamlView({
181
+ resource,
182
+ data,
183
+ onCopy,
184
+ copied,
185
+ readOnly = false,
186
+ onSaved,
187
+ onSave,
188
+ isSaving,
189
+ saveError,
190
+ onPreview,
191
+ isPreviewing,
192
+ previewError,
193
+ schemaLoader,
194
+ onDuplicate,
195
+ onDownload,
196
+ }: EditableYamlViewProps) {
142
197
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
143
198
 
144
199
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
@@ -152,6 +207,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
152
207
  // Default on: the editor resubmits the full live manifest, so an unforced
153
208
  // save would conflict on every field owned by Helm/Flux/Argo/a controller.
154
209
  const [force, setForce] = useState(true)
210
+ const [preview, setPreview] = useState<{
211
+ yaml: string
212
+ force: boolean
213
+ originalYaml: string
214
+ documents: YamlPreviewResult[]
215
+ nonAtomic: boolean
216
+ context?: string
217
+ } | null>(null)
155
218
 
156
219
  // Clean up restored draft flag
157
220
  useEffect(() => {
@@ -190,6 +253,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
190
253
  if (readOnly) return
191
254
  setEditedYaml(resourceToYaml(data))
192
255
  setYamlErrors([])
256
+ setPreview(null)
193
257
  setIsEditing(true)
194
258
  }, [data, readOnly])
195
259
 
@@ -197,12 +261,34 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
197
261
  setIsEditing(false)
198
262
  setEditedYaml('')
199
263
  setYamlErrors([])
264
+ setPreview(null)
200
265
  }, [])
201
266
 
202
267
  const handleSaveEdit = useCallback(async () => {
203
268
  if (yamlErrors.length > 0 || !onSave) return
204
269
 
205
270
  try {
271
+ if (onPreview) {
272
+ const result = await onPreview({
273
+ yaml: editedYaml,
274
+ mode: 'update',
275
+ force,
276
+ target: {
277
+ kind: resource.kind,
278
+ namespace: resource.namespace,
279
+ name: resource.name,
280
+ },
281
+ })
282
+ setPreview({
283
+ yaml: editedYaml,
284
+ force,
285
+ originalYaml: resourceToYaml(data),
286
+ documents: result.documents,
287
+ nonAtomic: result.nonAtomic,
288
+ context: result.context,
289
+ })
290
+ return
291
+ }
206
292
  await onSave({
207
293
  kind: resource.kind,
208
294
  namespace: resource.namespace,
@@ -216,7 +302,49 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
216
302
  } catch {
217
303
  // Error handled by caller via saveError prop
218
304
  }
219
- }, [onSave, resource, editedYaml, yamlErrors, onSaved, force])
305
+ }, [onSave, onPreview, resource, data, editedYaml, yamlErrors, onSaved, force])
306
+
307
+ const handleApplyReviewed = useCallback(async () => {
308
+ if (!preview || !onSave) return
309
+ try {
310
+ await onSave({
311
+ kind: resource.kind,
312
+ namespace: resource.namespace,
313
+ name: resource.name,
314
+ yaml: preview.yaml,
315
+ force: preview.force,
316
+ reviewedResourceVersion: preview.documents[0]?.reviewedResourceVersion,
317
+ reviewedContext: preview.context,
318
+ })
319
+ setPreview(null)
320
+ setIsEditing(false)
321
+ setEditedYaml('')
322
+ setTimeout(() => onSaved?.(), 1000)
323
+ } catch {
324
+ if (onPreview) {
325
+ try {
326
+ const refreshed = await onPreview({
327
+ yaml: preview.yaml,
328
+ mode: 'update',
329
+ force: preview.force,
330
+ target: {
331
+ kind: resource.kind,
332
+ namespace: resource.namespace,
333
+ name: resource.name,
334
+ },
335
+ })
336
+ setPreview({
337
+ ...preview,
338
+ documents: refreshed.documents,
339
+ nonAtomic: refreshed.nonAtomic,
340
+ context: refreshed.context,
341
+ })
342
+ } catch {
343
+ // Keep the last review visible when refresh is unavailable.
344
+ }
345
+ }
346
+ }
347
+ }, [preview, onSave, onPreview, resource, onSaved])
220
348
 
221
349
  const handleYamlValidate = useCallback((_isValid: boolean, errors: string[]) => {
222
350
  setYamlErrors(errors)
@@ -225,9 +353,24 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
225
353
  const yamlContent = yamlStringify(data, { lineWidth: 0, indent: 2 })
226
354
  const editWarning = getEditWarning(resource.kind)
227
355
  const formattedError = saveError ? formatSaveError(saveError) : null
228
- const isPending = isSaving ?? false
356
+ const isPending = (isSaving ?? false) || (isPreviewing ?? false)
229
357
 
230
358
  if (isEditing) {
359
+ if (preview) {
360
+ return (
361
+ <YamlReview
362
+ submittedYaml={preview.yaml}
363
+ originalYaml={preview.originalYaml}
364
+ documents={preview.documents}
365
+ nonAtomic={preview.nonAtomic}
366
+ force={preview.force}
367
+ isApplying={isSaving}
368
+ applyError={saveError}
369
+ onBack={() => setPreview(null)}
370
+ onApply={handleApplyReviewed}
371
+ />
372
+ )
373
+ }
231
374
  return (
232
375
  <div className="flex flex-col h-full">
233
376
  <div className="flex items-center justify-between px-4 py-2 border-b border-theme-border bg-theme-elevated/50">
@@ -249,10 +392,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
249
392
  <div className="space-y-1.5 max-w-[19rem]">
250
393
  <p>Override field ownership (server-side apply).</p>
251
394
  <p>
252
- <span className="font-medium text-theme-text-primary">On (default):</span> your edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active controller may reconcile them back on its next sync.
395
+ <span className="font-medium text-theme-text-primary">On (default):</span> your
396
+ edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active
397
+ controller may reconcile them back on its next sync.
253
398
  </p>
254
399
  <p>
255
- <span className="font-medium text-theme-text-primary">Off:</span> if another manager owns a field you changed, the whole save is rejected with a conflict (nothing is applied).
400
+ <span className="font-medium text-theme-text-primary">Off:</span> if another
401
+ manager owns a field you changed, the whole save is rejected with a conflict
402
+ (nothing is applied).
256
403
  </p>
257
404
  </div>
258
405
  }
@@ -276,10 +423,18 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
276
423
  >
277
424
  {isPending ? (
278
425
  <RefreshCw className="w-3.5 h-3.5 animate-spin" />
426
+ ) : onPreview ? (
427
+ <Eye className="w-3.5 h-3.5" />
279
428
  ) : (
280
429
  <Save className="w-3.5 h-3.5" />
281
430
  )}
282
- {isPending ? 'Saving...' : 'Save'}
431
+ {isPreviewing
432
+ ? 'Preparing review…'
433
+ : isSaving
434
+ ? 'Saving…'
435
+ : onPreview
436
+ ? 'Review changes'
437
+ : 'Save'}
283
438
  </button>
284
439
  </div>
285
440
  </div>
@@ -289,8 +444,12 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
289
444
  <div className="flex items-start gap-2">
290
445
  <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-yellow-300 mt-0.5 shrink-0" />
291
446
  <div className="text-xs">
292
- <span className="font-medium text-amber-700 dark:text-yellow-300">{editWarning.message}</span>
293
- <span className="text-amber-600 dark:text-yellow-300/80 ml-1">{editWarning.tip}</span>
447
+ <span className="font-medium text-amber-700 dark:text-yellow-300">
448
+ {editWarning.message}
449
+ </span>
450
+ <span className="text-amber-600 dark:text-yellow-300/80 ml-1">
451
+ {editWarning.tip}
452
+ </span>
294
453
  {editWarning.learnMoreUrl && (
295
454
  <a
296
455
  href={editWarning.learnMoreUrl}
@@ -319,14 +478,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
319
478
  </div>
320
479
  )}
321
480
 
322
- {formattedError && (
481
+ {(formattedError || previewError) && (
323
482
  <div className="px-4 py-2 bg-red-500/10 border-b border-red-300 dark:border-red-500/30">
324
483
  <div className="flex items-start gap-2">
325
484
  <AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-300 mt-0.5 shrink-0" />
326
485
  <div className="text-xs text-red-600 dark:text-red-300 flex-1">
327
- <div className="font-medium">Save failed</div>
328
- <div className="mt-1">{formattedError.summary}</div>
329
- {formattedError.details && (
486
+ <div className="font-medium">{previewError ? 'Preview failed' : 'Save failed'}</div>
487
+ <div className="mt-1">{previewError || formattedError?.summary}</div>
488
+ {formattedError?.details && (
330
489
  <button
331
490
  onClick={() => setShowErrorDetails(!showErrorDetails)}
332
491
  className="mt-1 text-red-500 dark:text-red-300 hover:text-red-700 dark:hover:text-red-200 underline"
@@ -334,7 +493,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
334
493
  {showErrorDetails ? 'Hide details' : 'Show details'}
335
494
  </button>
336
495
  )}
337
- {showErrorDetails && formattedError.details && (
496
+ {showErrorDetails && formattedError?.details && (
338
497
  <pre className="mt-2 p-2 bg-red-500/10 rounded text-[10px] whitespace-pre-wrap break-all max-h-40 overflow-auto">
339
498
  {formattedError.details}
340
499
  </pre>
@@ -349,6 +508,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
349
508
  value={editedYaml}
350
509
  onChange={setEditedYaml}
351
510
  onValidate={handleYamlValidate}
511
+ schemaLoader={schemaLoader}
352
512
  height="100%"
353
513
  kind={resource.kind}
354
514
  />
@@ -376,7 +536,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
376
536
  onClick={() => onCopy(yamlContent)}
377
537
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
378
538
  >
379
- {copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
539
+ {copied ? (
540
+ <Check className="w-3.5 h-3.5 text-green-400" />
541
+ ) : (
542
+ <Copy className="w-3.5 h-3.5" />
543
+ )}
380
544
  Copy
381
545
  </button>
382
546
  <Tooltip content="Download manifest as YAML (server-generated fields stripped)">
@@ -391,7 +555,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
391
555
  {onDuplicate && (
392
556
  <Tooltip content="Duplicate as new resource">
393
557
  <button
394
- onClick={() => onDuplicate({ kind: resource.kind, namespace: resource.namespace, name: resource.name, yaml: yamlContent })}
558
+ onClick={() =>
559
+ onDuplicate({
560
+ kind: resource.kind,
561
+ namespace: resource.namespace,
562
+ name: resource.name,
563
+ yaml: yamlContent,
564
+ })
565
+ }
395
566
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
396
567
  >
397
568
  <CopyPlus className="w-3.5 h-3.5" />
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ isBlockingYamlDiagnostic,
4
+ parseFallbackYamlDiagnostics,
5
+ parseYamlDocumentIdentities,
6
+ shouldAutoTriggerYamlSuggestions,
7
+ } from './YamlEditor'
8
+
9
+ describe('parseYamlDocumentIdentities', () => {
10
+ it('preserves ordered identities and document start lines', () => {
11
+ const documents = parseYamlDocumentIdentities(`apiVersion: apps/v1
12
+ kind: Deployment
13
+ metadata:
14
+ name: api
15
+ ---
16
+ apiVersion: monitoring.coreos.com/v1
17
+ kind: ServiceMonitor
18
+ metadata:
19
+ name: api
20
+ `)
21
+ expect(documents).toEqual([
22
+ {
23
+ index: 0,
24
+ schemaIndex: 0,
25
+ apiVersion: 'apps/v1',
26
+ kind: 'Deployment',
27
+ startLine: 1,
28
+ },
29
+ {
30
+ index: 1,
31
+ schemaIndex: 1,
32
+ apiVersion: 'monitoring.coreos.com/v1',
33
+ kind: 'ServiceMonitor',
34
+ startLine: 5,
35
+ },
36
+ ])
37
+ })
38
+
39
+ it('keeps incomplete documents addressable without inventing an identity', () => {
40
+ expect(parseYamlDocumentIdentities('apiVersion: v1\nkind:')).toEqual([
41
+ { index: 0, schemaIndex: 0, apiVersion: 'v1', kind: '', startLine: 1 },
42
+ ])
43
+ })
44
+
45
+ it('compacts empty document segments to match preview and apply indices', () => {
46
+ expect(
47
+ parseYamlDocumentIdentities(`apiVersion: apps/v1
48
+ kind: Deployment
49
+ ---
50
+
51
+ ---
52
+ apiVersion: v1
53
+ kind: ConfigMap
54
+ `),
55
+ ).toEqual([
56
+ {
57
+ index: 0,
58
+ schemaIndex: 0,
59
+ apiVersion: 'apps/v1',
60
+ kind: 'Deployment',
61
+ startLine: 1,
62
+ },
63
+ {
64
+ index: 1,
65
+ schemaIndex: 2,
66
+ apiVersion: 'v1',
67
+ kind: 'ConfigMap',
68
+ startLine: 5,
69
+ },
70
+ ])
71
+ })
72
+ })
73
+
74
+ describe('isBlockingYamlDiagnostic', () => {
75
+ it('blocks syntax failures when no schema source is present', () => {
76
+ expect(isBlockingYamlDiagnostic('error')).toBe(true)
77
+ expect(isBlockingYamlDiagnostic('warning', '0', 'Incorrect type. Expected integer.')).toBe(true)
78
+ })
79
+
80
+ it('keeps cluster schema diagnostics advisory', () => {
81
+ expect(
82
+ isBlockingYamlDiagnostic(
83
+ 'warning',
84
+ '513',
85
+ 'Property contianers is not allowed.',
86
+ 'yaml-schema: Kubernetes cluster schema',
87
+ ),
88
+ ).toBe(false)
89
+ })
90
+
91
+ it('keeps deprecations visible but advisory', () => {
92
+ expect(isBlockingYamlDiagnostic('warning', '2', 'Use spec.newField instead.')).toBe(false)
93
+ expect(
94
+ isBlockingYamlDiagnostic(
95
+ 'warning',
96
+ undefined,
97
+ '[radar-advisory:deprecated] Use spec.newField instead.',
98
+ ),
99
+ ).toBe(false)
100
+ })
101
+ })
102
+
103
+ describe('parseFallbackYamlDiagnostics', () => {
104
+ it('accepts valid multi-document YAML', () => {
105
+ expect(
106
+ parseFallbackYamlDiagnostics(`apiVersion: v1
107
+ kind: ConfigMap
108
+ ---
109
+ apiVersion: apps/v1
110
+ kind: Deployment
111
+ `),
112
+ ).toEqual([])
113
+ })
114
+
115
+ it('reports a blocking syntax error against the correct document', () => {
116
+ const diagnostics = parseFallbackYamlDiagnostics(`apiVersion: v1
117
+ kind: ConfigMap
118
+ ---
119
+ apiVersion: v1
120
+ kind: [`)
121
+
122
+ expect(diagnostics).toHaveLength(1)
123
+ expect(diagnostics[0]).toMatchObject({
124
+ severity: 'error',
125
+ line: 5,
126
+ column: 8,
127
+ documentIndex: 1,
128
+ blocking: true,
129
+ })
130
+ })
131
+ })
132
+
133
+ describe('shouldAutoTriggerYamlSuggestions', () => {
134
+ it('triggers after Enter creates an indented blank line', () => {
135
+ expect(shouldAutoTriggerYamlSuggestions(['\n '], ' ', 7)).toBe(true)
136
+ expect(shouldAutoTriggerYamlSuggestions(['\r\n '], ' ', 3)).toBe(true)
137
+ })
138
+
139
+ it('does not trigger for typing, pasted content, or a non-blank line', () => {
140
+ expect(shouldAutoTriggerYamlSuggestions([' '], ' ', 7)).toBe(false)
141
+ expect(shouldAutoTriggerYamlSuggestions(['\n image:'], ' image:', 13)).toBe(false)
142
+ expect(shouldAutoTriggerYamlSuggestions(['\n '], ' ports:', 13)).toBe(false)
143
+ })
144
+
145
+ it('waits until the cursor is at the end of the new indentation', () => {
146
+ expect(shouldAutoTriggerYamlSuggestions(['\n '], ' ', 4)).toBe(false)
147
+ })
148
+ })