@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.
@@ -1,7 +1,12 @@
1
- import { useState, useCallback, useEffect } from 'react'
2
- import { X, Loader2, Check, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
3
3
  import { DialogPortal } from '../ui/DialogPortal'
4
- import { YamlEditor } from '../ui/YamlEditor'
4
+ import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
5
+ import {
6
+ reviewedResourceVersionsForPreview,
7
+ YamlReview,
8
+ type YamlPreviewResult,
9
+ } from '../ui/YamlReview'
5
10
  import { Tooltip } from '../ui/Tooltip'
6
11
  import { formatApplyError } from '../../utils/k8s-errors'
7
12
 
@@ -17,14 +22,39 @@ export interface CreateResourceDialogProps {
17
22
  onClose: () => void
18
23
  initialYaml?: string
19
24
  title?: string
20
- // Injected by platform (decouples from data-fetching hooks)
21
- onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean; force: boolean }) => Promise<ApplyResult[]>
25
+ onApply: (params: {
26
+ yaml: string
27
+ mode: 'apply' | 'create'
28
+ dryRun: boolean
29
+ force: boolean
30
+ reviewedResourceVersions?: Record<number, string>
31
+ reviewedContext?: string
32
+ }) => Promise<ApplyResult[]>
22
33
  isApplying: boolean
23
- /** Called after a successful non-dry-run apply with the first created resource */
34
+ onPreview?: (params: { yaml: string; mode: 'apply' | 'create'; force: boolean }) => Promise<{
35
+ documents: YamlPreviewResult[]
36
+ nonAtomic: boolean
37
+ context?: string
38
+ }>
39
+ isPreviewing?: boolean
40
+ previewError?: string | null
41
+ schemaLoader?: YamlSchemaLoader
24
42
  onCreated?: (result: ApplyResult) => void
25
43
  }
26
44
 
27
- export function CreateResourceDialog({ open, onClose, initialYaml = '', title, onApply, isApplying, onCreated }: CreateResourceDialogProps) {
45
+ export function CreateResourceDialog({
46
+ open,
47
+ onClose,
48
+ initialYaml = '',
49
+ title,
50
+ onApply,
51
+ isApplying,
52
+ onPreview,
53
+ isPreviewing = false,
54
+ previewError,
55
+ schemaLoader,
56
+ onCreated,
57
+ }: CreateResourceDialogProps) {
28
58
  const [yaml, setYaml] = useState(initialYaml)
29
59
  const [mode, setMode] = useState<'apply' | 'create'>('apply')
30
60
  const [dryRun, setDryRun] = useState(false)
@@ -32,31 +62,66 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
32
62
  const [yamlValid, setYamlValid] = useState(true)
33
63
  const [error, setError] = useState<string | null>(null)
34
64
  const [success, setSuccess] = useState<string | null>(null)
65
+ const [preview, setPreview] = useState<{
66
+ yaml: string
67
+ mode: 'apply' | 'create'
68
+ force: boolean
69
+ documents: YamlPreviewResult[]
70
+ nonAtomic: boolean
71
+ context?: string
72
+ } | null>(null)
35
73
 
36
- // Reset state when dialog opens or initialYaml changes
37
74
  useEffect(() => {
38
- if (open) {
39
- setYaml(initialYaml)
40
- setMode('apply')
41
- setDryRun(false)
42
- setForce(false)
43
- setError(null)
44
- setSuccess(null)
45
- }
75
+ if (!open) return
76
+ setYaml(initialYaml)
77
+ setMode('apply')
78
+ setDryRun(false)
79
+ setForce(false)
80
+ setYamlValid(true)
81
+ setError(null)
82
+ setSuccess(null)
83
+ setPreview(null)
46
84
  }, [open, initialYaml])
47
85
 
48
- const handleClose = useCallback(() => {
49
- if (isApplying) return
86
+ const pending = isApplying || isPreviewing
87
+ const closeNow = useCallback(() => {
50
88
  setError(null)
51
89
  setSuccess(null)
90
+ setPreview(null)
52
91
  setYaml('')
53
92
  onClose()
54
- }, [onClose, isApplying])
93
+ }, [onClose])
94
+ const handleClose = useCallback(() => {
95
+ if (!pending) closeNow()
96
+ }, [closeNow, pending])
55
97
 
56
98
  const handleValidate = useCallback((_isValid: boolean, errors: string[]) => {
57
99
  setYamlValid(errors.length === 0)
58
100
  }, [])
59
101
 
102
+ const finishApply = useCallback(
103
+ (results: ApplyResult[], appliedMode: 'apply' | 'create', wasDryRun: boolean) => {
104
+ const action = appliedMode === 'create' ? 'Created' : 'Applied'
105
+ const dryRunLabel = wasDryRun ? ' (dry run)' : ''
106
+ if (results.length === 1) {
107
+ const result = results[0]
108
+ setSuccess(
109
+ `${action} ${result.kind} ${result.namespace ? `${result.namespace}/` : ''}${result.name}${dryRunLabel}`,
110
+ )
111
+ } else {
112
+ setSuccess(`${action} ${results.length} resources${dryRunLabel}`)
113
+ }
114
+ if (wasDryRun) return
115
+ if (onCreated && results.length > 0) {
116
+ closeNow()
117
+ onCreated(results[0])
118
+ } else {
119
+ window.setTimeout(closeNow, 1200)
120
+ }
121
+ },
122
+ [closeNow, onCreated],
123
+ )
124
+
60
125
  const handleSubmit = useCallback(async () => {
61
126
  if (!yaml.trim()) {
62
127
  setError('YAML content is required')
@@ -64,167 +129,238 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
64
129
  }
65
130
  setError(null)
66
131
  setSuccess(null)
67
-
68
132
  try {
69
- const results = await onApply({ yaml, mode, dryRun, force: mode === 'apply' && force })
70
- const action = mode === 'create' ? 'Created' : 'Applied'
71
- const dryRunLabel = dryRun ? ' (dry run)' : ''
72
-
73
- if (results.length === 1) {
74
- const r = results[0]
75
- setSuccess(`${action} ${r.kind} ${r.namespace ? r.namespace + '/' : ''}${r.name}${dryRunLabel}`)
76
- } else {
77
- setSuccess(`${action} ${results.length} resources${dryRunLabel}`)
133
+ if (onPreview) {
134
+ const reviewedForce = mode === 'apply' && force
135
+ const result = await onPreview({ yaml, mode, force: reviewedForce })
136
+ setPreview({
137
+ yaml,
138
+ mode,
139
+ force: reviewedForce,
140
+ documents: result.documents,
141
+ nonAtomic: result.nonAtomic,
142
+ context: result.context,
143
+ })
144
+ return
78
145
  }
146
+ const results = await onApply({
147
+ yaml,
148
+ mode,
149
+ dryRun,
150
+ force: mode === 'apply' && force,
151
+ })
152
+ finishApply(results, mode, dryRun)
153
+ } catch (caught) {
154
+ setError(caught instanceof Error ? caught.message : 'Unknown error')
155
+ }
156
+ }, [yaml, mode, dryRun, force, onApply, onPreview, finishApply])
79
157
 
80
- if (!dryRun) {
81
- if (onCreated && results.length > 0) {
82
- // Close immediately and navigate to the created resource
83
- handleClose()
84
- onCreated(results[0])
85
- } else {
86
- setTimeout(handleClose, 1200)
158
+ const handleApplyReviewed = useCallback(async () => {
159
+ if (!preview) return
160
+ setError(null)
161
+ try {
162
+ const reviewedResourceVersions = reviewedResourceVersionsForPreview(preview.documents)
163
+ const results = await onApply({
164
+ yaml: preview.yaml,
165
+ mode: preview.mode,
166
+ dryRun: false,
167
+ force: preview.force,
168
+ reviewedResourceVersions,
169
+ reviewedContext: preview.context,
170
+ })
171
+ finishApply(results, preview.mode, false)
172
+ } catch (caught) {
173
+ const message = caught instanceof Error ? caught.message : 'Unknown error'
174
+ const appliedResults =
175
+ caught instanceof Error &&
176
+ 'appliedResults' in caught &&
177
+ Array.isArray(caught.appliedResults)
178
+ ? caught.appliedResults
179
+ : []
180
+ if (preview.mode === 'create' && appliedResults.length > 0) {
181
+ setYaml(preview.yaml)
182
+ setMode('apply')
183
+ setPreview(null)
184
+ setError(
185
+ `${message} Radar switched to Apply mode so you can review and continue from the current cluster state.`,
186
+ )
187
+ return
188
+ }
189
+ setError(message)
190
+ if (onPreview) {
191
+ try {
192
+ const refreshed = await onPreview({
193
+ yaml: preview.yaml,
194
+ mode: preview.mode,
195
+ force: preview.force,
196
+ })
197
+ setPreview({
198
+ ...preview,
199
+ documents: refreshed.documents,
200
+ nonAtomic: refreshed.nonAtomic,
201
+ context: refreshed.context,
202
+ })
203
+ } catch {
204
+ // Keep the last review visible when refresh is unavailable.
87
205
  }
88
206
  }
89
- } catch (err) {
90
- setError(err instanceof Error ? err.message : 'Unknown error')
91
207
  }
92
- }, [yaml, mode, dryRun, force, onApply, onCreated, handleClose])
208
+ }, [preview, onApply, onPreview, finishApply])
93
209
 
94
210
  const dialogTitle = title || 'Create Resource'
95
- const submitLabel = mode === 'create' ? 'Create' : 'Apply'
211
+ const submitLabel = onPreview ? 'Review' : mode === 'create' ? 'Create' : 'Apply'
96
212
 
97
213
  return (
98
- <DialogPortal open={open} onClose={handleClose} closable={!isApplying} className="w-[700px] max-h-[85vh] flex flex-col">
99
- {/* Header */}
100
- <div className="flex items-center justify-between px-5 py-3.5 border-b border-theme-border shrink-0">
101
- <h2 className="text-sm font-semibold text-theme-text-primary">{dialogTitle}</h2>
102
- <Tooltip content="Close">
103
- <button
104
- onClick={handleClose}
105
- className="p-1 rounded hover:bg-theme-hover text-theme-text-secondary transition-colors"
106
- >
107
- <X className="w-4 h-4" />
108
- </button>
109
- </Tooltip>
110
- </div>
111
-
112
- {/* Editor */}
113
- <div className="flex-1 min-h-0 px-5 py-3">
114
- <YamlEditor
115
- value={yaml}
116
- onChange={setYaml}
117
- height="400px"
118
- onValidate={handleValidate}
214
+ <DialogPortal
215
+ open={open}
216
+ onClose={handleClose}
217
+ closable={!pending}
218
+ className={
219
+ preview
220
+ ? 'w-[min(1200px,calc(100vw-2rem))] h-[min(860px,calc(100vh-2rem))] flex flex-col'
221
+ : 'w-[700px] max-h-[85vh] flex flex-col'
222
+ }
223
+ >
224
+ {preview ? (
225
+ <YamlReview
226
+ submittedYaml={preview.yaml}
227
+ documents={preview.documents}
228
+ nonAtomic={preview.nonAtomic}
229
+ force={preview.force}
230
+ isApplying={isApplying}
231
+ applyError={error}
232
+ applyLabel={
233
+ preview.mode === 'create' ? 'Create reviewed resources' : 'Apply reviewed changes'
234
+ }
235
+ onClose={handleClose}
236
+ onBack={() => {
237
+ setError(null)
238
+ setPreview(null)
239
+ }}
240
+ onApply={handleApplyReviewed}
119
241
  />
120
- </div>
242
+ ) : (
243
+ <>
244
+ <div className="flex shrink-0 items-center justify-between border-b border-theme-border px-5 py-3.5">
245
+ <h2 className="text-sm font-semibold text-theme-text-primary">{dialogTitle}</h2>
246
+ <Tooltip content="Close">
247
+ <button
248
+ type="button"
249
+ onClick={handleClose}
250
+ className="rounded p-1 text-theme-text-secondary transition-colors hover:bg-theme-hover"
251
+ >
252
+ <X className="h-4 w-4" />
253
+ </button>
254
+ </Tooltip>
255
+ </div>
121
256
 
122
- {/* Status messages single location for feedback (no toast) */}
123
- {error && <ApplyErrorBanner error={error} />}
124
- {success && (
125
- <div className="mx-5 mb-2 px-3 py-2 rounded-md bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-xs flex items-center gap-2">
126
- <Check className="w-3.5 h-3.5 shrink-0" />
127
- <span>{success}</span>
128
- </div>
129
- )}
257
+ <div className="min-h-0 flex-1 px-5 py-3">
258
+ <YamlEditor
259
+ value={yaml}
260
+ onChange={setYaml}
261
+ height="400px"
262
+ onValidate={handleValidate}
263
+ schemaLoader={schemaLoader}
264
+ />
265
+ </div>
266
+
267
+ {(error || previewError) && (
268
+ <ApplyErrorBanner error={error || previewError || 'Preview failed'} />
269
+ )}
270
+ {success && (
271
+ <div className="mx-5 mb-2 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">
272
+ <Check className="h-3.5 w-3.5 shrink-0" />
273
+ <span>{success}</span>
274
+ </div>
275
+ )}
276
+
277
+ <div className="flex shrink-0 items-center justify-between border-t border-theme-border px-5 py-3">
278
+ <div className="flex items-center gap-3">
279
+ <Tooltip
280
+ content="Apply: create or update (idempotent). Create: fail if exists."
281
+ position="bottom"
282
+ >
283
+ <div
284
+ className="flex items-center rounded-md border border-theme-border bg-theme-base p-0.5"
285
+ role="radiogroup"
286
+ aria-label="Apply mode"
287
+ >
288
+ {(['apply', 'create'] as const).map((option) => (
289
+ <button
290
+ type="button"
291
+ key={option}
292
+ onClick={() => setMode(option)}
293
+ role="radio"
294
+ aria-checked={mode === option}
295
+ className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
296
+ mode === option
297
+ ? 'bg-theme-elevated text-theme-text-primary shadow-theme-sm'
298
+ : 'text-theme-text-tertiary hover:text-theme-text-secondary'
299
+ }`}
300
+ >
301
+ {option}
302
+ </button>
303
+ ))}
304
+ </div>
305
+ </Tooltip>
306
+
307
+ {!onPreview && (
308
+ <Tooltip
309
+ content="Validate against the cluster without persisting changes"
310
+ position="bottom"
311
+ >
312
+ <label className="flex cursor-pointer items-center gap-1.5 text-xs text-theme-text-secondary">
313
+ <input
314
+ type="checkbox"
315
+ checked={dryRun}
316
+ onChange={(event) => setDryRun(event.target.checked)}
317
+ className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
318
+ />
319
+ Dry run
320
+ </label>
321
+ </Tooltip>
322
+ )}
130
323
 
131
- {/* Footer */}
132
- <div className="flex items-center justify-between px-5 py-3 border-t border-theme-border shrink-0">
133
- <div className="flex items-center gap-3">
134
- {/* Mode toggle — pill segmented control */}
135
- <Tooltip content="Apply: create or update (idempotent). Create: fail if exists." position="bottom">
136
- <div className="flex items-center rounded-md bg-theme-base border border-theme-border p-0.5" role="radiogroup" aria-label="Apply mode">
324
+ <Tooltip
325
+ content="Override field ownership conflicts. An active controller may reconcile those fields back."
326
+ position="bottom"
327
+ >
328
+ <label
329
+ className={`flex items-center gap-1.5 text-xs ${mode === 'apply' ? 'cursor-pointer text-theme-text-secondary' : 'cursor-not-allowed text-theme-text-tertiary'}`}
330
+ >
331
+ <input
332
+ type="checkbox"
333
+ checked={mode === 'apply' && force}
334
+ disabled={mode !== 'apply'}
335
+ onChange={(event) => setForce(event.target.checked)}
336
+ className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
337
+ />
338
+ Force
339
+ </label>
340
+ </Tooltip>
341
+ </div>
342
+
343
+ <div className="flex items-center gap-2">
137
344
  <button
138
- onClick={() => setMode('apply')}
139
- role="radio"
140
- aria-checked={mode === 'apply'}
141
- className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
142
- mode === 'apply'
143
- ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
144
- : 'text-theme-text-tertiary hover:text-theme-text-secondary'
145
- }`}
345
+ type="button"
346
+ onClick={handleClose}
347
+ className="rounded-lg px-3 py-1.5 text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover"
146
348
  >
147
- Apply
349
+ Cancel
148
350
  </button>
149
351
  <button
150
- onClick={() => setMode('create')}
151
- role="radio"
152
- aria-checked={mode === 'create'}
153
- className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
154
- mode === 'create'
155
- ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
156
- : 'text-theme-text-tertiary hover:text-theme-text-secondary'
157
- }`}
352
+ type="button"
353
+ onClick={handleSubmit}
354
+ disabled={pending || !yaml.trim() || !yamlValid}
355
+ className="btn-brand flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-xs font-medium disabled:cursor-not-allowed"
158
356
  >
159
- Create
357
+ {pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
358
+ {isPreviewing ? 'Previewing…' : isApplying ? `${submitLabel}…` : submitLabel}
160
359
  </button>
161
360
  </div>
162
- </Tooltip>
163
-
164
- {/* Dry run checkbox */}
165
- <Tooltip content="Validate against the cluster without persisting changes" position="bottom">
166
- <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary cursor-pointer">
167
- <input
168
- type="checkbox"
169
- checked={dryRun}
170
- onChange={(e) => setDryRun(e.target.checked)}
171
- className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
172
- />
173
- Dry run
174
- </label>
175
- </Tooltip>
176
-
177
- {/* Force checkbox — only meaningful for server-side apply */}
178
- <Tooltip
179
- content={
180
- <div className="space-y-1.5 max-w-[19rem]">
181
- <p>Override field ownership (server-side apply).</p>
182
- <p>
183
- <span className="font-medium text-theme-text-primary">Off (default):</span> if a field is owned by Helm/Flux/Argo/kubectl, the whole apply is rejected with a conflict instead of overwriting.
184
- </p>
185
- <p>
186
- <span className="font-medium text-theme-text-primary">On:</span> your manifest overwrites those fields — but an active controller may reconcile them back on its next sync.
187
- </p>
188
- </div>
189
- }
190
- position="bottom"
191
- >
192
- <label className={`flex items-center gap-1.5 text-xs cursor-pointer ${mode === 'apply' ? 'text-theme-text-secondary' : 'text-theme-text-tertiary cursor-not-allowed'}`}>
193
- <input
194
- type="checkbox"
195
- checked={mode === 'apply' && force}
196
- disabled={mode !== 'apply'}
197
- onChange={(e) => setForce(e.target.checked)}
198
- className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
199
- />
200
- Force
201
- </label>
202
- </Tooltip>
203
- </div>
204
-
205
- <div className="flex items-center gap-2">
206
- <button
207
- onClick={handleClose}
208
- className="px-3 py-1.5 text-xs rounded-lg hover:bg-theme-hover text-theme-text-secondary transition-colors"
209
- >
210
- Cancel
211
- </button>
212
- <button
213
- onClick={handleSubmit}
214
- disabled={isApplying || !yaml.trim() || !yamlValid}
215
- className="px-4 py-1.5 text-xs rounded-lg btn-brand font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
216
- >
217
- {isApplying ? (
218
- <>
219
- <Loader2 className="w-3.5 h-3.5 animate-spin" />
220
- {submitLabel === 'Apply' ? 'Applying...' : 'Creating...'}
221
- </>
222
- ) : (
223
- submitLabel
224
- )}
225
- </button>
226
- </div>
227
- </div>
361
+ </div>
362
+ </>
363
+ )}
228
364
  </DialogPortal>
229
365
  )
230
366
  }
@@ -232,31 +368,30 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
232
368
  function ApplyErrorBanner({ error }: { error: string }) {
233
369
  const [expanded, setExpanded] = useState(false)
234
370
  const parsed = formatApplyError(error)
235
- const hasFriendly = !!parsed.suggestion
236
-
371
+ const hasFriendly = Boolean(parsed.suggestion)
237
372
  return (
238
- <div className="mx-5 mb-2 rounded-md bg-red-500/10 border border-red-500/30 text-xs">
239
- <div className="px-3 py-2 flex items-start gap-2 text-red-400">
240
- <AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
373
+ <div className="mx-5 mb-2 rounded-md border border-red-500/30 bg-red-500/10 text-xs">
374
+ <div className="flex items-start gap-2 px-3 py-2 text-red-600 dark:text-red-400">
375
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
241
376
  <div className="min-w-0 flex-1">
242
377
  <span className="font-medium">{parsed.summary}</span>
243
378
  {parsed.suggestion && (
244
- <p className="mt-1 text-red-400/80">{parsed.suggestion}</p>
379
+ <p className="mt-1 text-red-500/80 dark:text-red-400/80">{parsed.suggestion}</p>
245
380
  )}
246
381
  </div>
247
382
  </div>
248
383
  {hasFriendly && (
249
384
  <button
250
385
  type="button"
251
- onClick={() => setExpanded(!expanded)}
252
- className="flex items-center gap-1 px-3 pb-2 text-red-400/60 hover:text-red-400/80 transition-colors"
386
+ onClick={() => setExpanded((value) => !value)}
387
+ className="flex items-center gap-1 px-3 pb-2 text-red-500/60 hover:text-red-500/80 dark:text-red-400/60 dark:hover:text-red-400/80"
253
388
  >
254
- {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
255
- <span>Details</span>
389
+ {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
390
+ Details
256
391
  </button>
257
392
  )}
258
- {(expanded || !hasFriendly) && hasFriendly && (
259
- <div className="px-3 pb-2 text-red-400/60 break-all font-mono leading-relaxed">
393
+ {expanded && hasFriendly && (
394
+ <div className="break-all px-3 pb-2 font-mono leading-relaxed text-red-500/60 dark:text-red-400/60">
260
395
  {parsed.raw}
261
396
  </div>
262
397
  )}