@skyhook-io/k8s-ui 1.14.4 → 1.14.6

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.
Files changed (85) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +173 -0
  5. package/src/components/charts/AreaChart.tsx +208 -53
  6. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  7. package/src/components/charts/PrometheusChartsView.tsx +205 -76
  8. package/src/components/charts/SeriesLegend.tsx +8 -3
  9. package/src/components/charts/annotations.ts +59 -0
  10. package/src/components/charts/axis.test.ts +48 -0
  11. package/src/components/charts/axis.ts +62 -0
  12. package/src/components/charts/colors.test.ts +27 -0
  13. package/src/components/charts/colors.ts +23 -0
  14. package/src/components/charts/format.test.ts +19 -0
  15. package/src/components/charts/format.ts +21 -1
  16. package/src/components/charts/index.ts +3 -1
  17. package/src/components/charts/nearestSample.test.ts +31 -0
  18. package/src/components/charts/nearestSample.ts +15 -0
  19. package/src/components/charts/types.ts +12 -0
  20. package/src/components/checks/ChecksView.tsx +20 -2
  21. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  22. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  23. package/src/components/gitops/health-provenance.test.ts +54 -0
  24. package/src/components/gitops/health-provenance.ts +24 -0
  25. package/src/components/gitops/index.ts +2 -0
  26. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  28. package/src/components/gitops/tree/merge.test.ts +34 -0
  29. package/src/components/gitops/tree/merge.ts +7 -0
  30. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  31. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  32. package/src/components/resources/ResourcesView.tsx +67 -2
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/index.ts +7 -0
  35. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  36. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  37. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  38. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  39. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  40. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  41. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  42. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  45. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  46. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  47. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  48. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  49. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  50. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
  52. package/src/components/resources/renderers/index.ts +3 -0
  53. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  54. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  55. package/src/components/resources/resource-utils.ts +18 -9
  56. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  57. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  58. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  59. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  60. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  61. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  62. package/src/components/shared/index.ts +4 -0
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/ui/Badge.tsx +5 -1
  66. package/src/components/ui/FetchResult.test.tsx +20 -2
  67. package/src/components/ui/FetchResult.tsx +33 -3
  68. package/src/components/ui/FreshnessControl.tsx +8 -2
  69. package/src/components/ui/index.ts +5 -0
  70. package/src/components/ui/monacoRuntime.ts +14 -0
  71. package/src/components/workload/WorkloadView.tsx +2 -2
  72. package/src/monaco-deep.d.ts +10 -0
  73. package/src/theme/components.css +16 -0
  74. package/src/types/core.ts +38 -11
  75. package/src/types/gitops-insights.ts +23 -0
  76. package/src/types/gitops-tree.ts +29 -0
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/navigation.test.ts +20 -1
  79. package/src/utils/navigation.ts +25 -0
  80. package/src/utils/network-policy.test.ts +42 -0
  81. package/src/utils/network-policy.ts +59 -0
  82. package/src/utils/reflector.ts +139 -0
  83. package/src/utils/yaml-file-import.test.ts +189 -0
  84. package/src/utils/yaml-file-import.ts +95 -0
  85. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -0,0 +1,199 @@
1
+ import { Check, X, AlertTriangle, Minus, Clock } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { canaryStepLabel, canaryStepTemplateRefs } from '../RolloutRenderer'
4
+ import { analysisPhaseLevel, healthColors, type HealthLevel } from '../../resource-utils'
5
+
6
+ export interface StepAnalysisStatus {
7
+ name?: string
8
+ status?: string
9
+ message?: string
10
+ }
11
+
12
+ interface CanaryStepTimelineProps {
13
+ steps: any[]
14
+ currentStepIndex?: number
15
+ // The live "Step analysis" slot from rolloutAnalysisRuns(status) — shown
16
+ // inline on the current step when it's an analysis step, instead of only
17
+ // in the separate Analysis section above.
18
+ stepAnalysisStatus?: StepAnalysisStatus
19
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
20
+ namespace?: string
21
+ }
22
+
23
+ type StepState = 'completed' | 'current' | 'pending'
24
+
25
+ // Only 'unhealthy' (Failed/Error) and 'alert' (Inconclusive) colour the dot.
26
+ // A Successful or still-running analysis deliberately gets no special colour:
27
+ // the step itself may still be paused or in progress regardless, so it keeps
28
+ // the same "current" tone as any other in-progress step rather than reading
29
+ // as done.
30
+ function stepDotTone(state: StepState, analysisLevel?: HealthLevel) {
31
+ if (state === 'current' && (analysisLevel === 'unhealthy' || analysisLevel === 'alert')) {
32
+ return healthColors[analysisLevel]
33
+ }
34
+ switch (state) {
35
+ case 'completed':
36
+ return healthColors.healthy
37
+ case 'current':
38
+ return healthColors.neutral
39
+ default:
40
+ return 'bg-theme-hover text-theme-text-tertiary'
41
+ }
42
+ }
43
+
44
+ function StepDot({ state, analysisLevel }: { state: StepState; analysisLevel?: HealthLevel }) {
45
+ const tone = stepDotTone(state, analysisLevel)
46
+ let Icon = Minus
47
+ if (state === 'current' && analysisLevel === 'unhealthy') Icon = X
48
+ else if (state === 'current' && analysisLevel === 'alert') Icon = AlertTriangle
49
+ else if (state === 'completed') Icon = Check
50
+ else if (state === 'current') Icon = Clock
51
+ return (
52
+ <span className={clsx('z-10 mt-1 flex h-3 w-3 shrink-0 items-center justify-center rounded-full ring-2 ring-theme-surface', tone)}>
53
+ <Icon className="h-2 w-2" strokeWidth={4} />
54
+ </span>
55
+ )
56
+ }
57
+
58
+ // Vertical connected stepper for a Rollout's canary steps — a linear
59
+ // sequence (no branching), so a simple top-to-bottom timeline reads more
60
+ // naturally than a graph. Same connecting-line visual as ConditionsSection.
61
+ export function CanaryStepTimeline({ steps, currentStepIndex, stepAnalysisStatus, onNavigate, namespace }: CanaryStepTimelineProps) {
62
+ if (steps.length === 0) return null
63
+
64
+ return (
65
+ <div className="relative">
66
+ <div className="absolute bottom-2 left-[9px] top-2 w-px bg-theme-border" />
67
+ <div className="space-y-0.5">
68
+ {steps.map((step, index) => {
69
+ const state: StepState =
70
+ currentStepIndex === undefined ? 'pending' : index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'current' : 'pending'
71
+ const isCurrent = state === 'current'
72
+ const label = canaryStepLabel(step)
73
+ const templateRefs = step.analysis ? canaryStepTemplateRefs(step) : []
74
+ const showAnalysisStatus = isCurrent && step.analysis && stepAnalysisStatus?.status
75
+ const analysisLevel: HealthLevel | undefined = showAnalysisStatus
76
+ ? analysisPhaseLevel(stepAnalysisStatus!.status)
77
+ : undefined
78
+
79
+ return (
80
+ <div key={index} className="relative flex items-start gap-2 py-1.5 pr-1 text-sm">
81
+ <StepDot state={state} analysisLevel={analysisLevel} />
82
+ <div className="min-w-0 flex-1 pl-1">
83
+ <div className="flex items-center gap-2">
84
+ <span className="text-[11px] text-theme-text-tertiary">#{index}</span>
85
+ <span className={clsx(isCurrent ? 'font-medium text-theme-text-primary' : state === 'completed' ? 'text-theme-text-secondary' : 'text-theme-text-tertiary')}>
86
+ {label}
87
+ </span>
88
+ {showAnalysisStatus && analysisLevel && (
89
+ onNavigate && stepAnalysisStatus!.name ? (
90
+ <button
91
+ onClick={() =>
92
+ onNavigate({ kind: 'AnalysisRun', namespace: namespace ?? '', name: stepAnalysisStatus!.name! })
93
+ }
94
+ className={clsx('badge-sm hover:underline', healthColors[analysisLevel])}
95
+ title={stepAnalysisStatus!.name}
96
+ >
97
+ {stepAnalysisStatus!.status}
98
+ </button>
99
+ ) : (
100
+ <span className={clsx('badge-sm', healthColors[analysisLevel])}>
101
+ {stepAnalysisStatus!.status}
102
+ </span>
103
+ )
104
+ )}
105
+ </div>
106
+ {templateRefs.length > 0 && (
107
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
108
+ {templateRefs.map((ref) => (
109
+ <button
110
+ key={ref.name}
111
+ onClick={() =>
112
+ onNavigate?.({
113
+ kind: ref.clusterScoped ? 'ClusterAnalysisTemplate' : 'AnalysisTemplate',
114
+ namespace: ref.clusterScoped ? '' : namespace ?? '',
115
+ name: ref.name,
116
+ })
117
+ }
118
+ disabled={!onNavigate}
119
+ className={clsx(
120
+ 'font-mono text-[11px]',
121
+ onNavigate ? 'text-brand hover:underline' : 'text-theme-text-tertiary'
122
+ )}
123
+ >
124
+ {ref.name}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ )}
129
+ </div>
130
+ </div>
131
+ )
132
+ })}
133
+ </div>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ export interface BlueGreenPhase {
139
+ label: string
140
+ state: 'completed' | 'current' | 'pending'
141
+ analysis?: { name?: string; status?: string }
142
+ }
143
+
144
+ // Same connected-dot visual as CanaryStepTimeline, for blueGreenPhases()'s
145
+ // derived phase list — blueGreen has no steps[] array to iterate, so there's
146
+ // nothing to reuse structurally, but the "linear sequence, top to bottom"
147
+ // visual language should match.
148
+ export function BlueGreenTimeline({
149
+ phases,
150
+ onNavigate,
151
+ namespace,
152
+ }: {
153
+ phases: BlueGreenPhase[]
154
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
155
+ namespace?: string
156
+ }) {
157
+ if (phases.length === 0) return null
158
+ return (
159
+ <div className="relative">
160
+ <div className="absolute bottom-2 left-[9px] top-2 w-px bg-theme-border" />
161
+ <div className="space-y-0.5">
162
+ {phases.map((phase, index) => {
163
+ const level = phase.analysis?.status ? analysisPhaseLevel(phase.analysis.status) : undefined
164
+ return (
165
+ <div key={index} className="relative flex items-start gap-2 py-1.5 pr-1 text-sm">
166
+ <StepDot state={phase.state} analysisLevel={level} />
167
+ <span
168
+ className={clsx(
169
+ 'pl-1',
170
+ phase.state === 'current'
171
+ ? 'font-medium text-theme-text-primary'
172
+ : phase.state === 'completed'
173
+ ? 'text-theme-text-secondary'
174
+ : 'text-theme-text-tertiary'
175
+ )}
176
+ >
177
+ {phase.label}
178
+ </span>
179
+ {level &&
180
+ (onNavigate && phase.analysis?.name ? (
181
+ <button
182
+ onClick={() =>
183
+ onNavigate({ kind: 'AnalysisRun', namespace: namespace ?? '', name: phase.analysis!.name! })
184
+ }
185
+ className={clsx('badge-sm hover:underline', healthColors[level])}
186
+ title={phase.analysis.name}
187
+ >
188
+ {phase.analysis.status}
189
+ </button>
190
+ ) : (
191
+ <span className={clsx('badge-sm', healthColors[level])}>{phase.analysis!.status}</span>
192
+ ))}
193
+ </div>
194
+ )
195
+ })}
196
+ </div>
197
+ </div>
198
+ )
199
+ }
@@ -0,0 +1,116 @@
1
+ import { useState } from 'react'
2
+ import { ChevronDown, ChevronRight, Box } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { formatAge } from '../../resource-utils'
5
+ import { revisionRoleBadges } from '../../../shared/ResourceActionsBar'
6
+ import type { WorkloadRevision, WorkloadPodInfo } from '../../../../types'
7
+
8
+ interface ReplicaSetProgressionProps {
9
+ revisions: WorkloadRevision[]
10
+ pods?: WorkloadPodInfo[]
11
+ isRollout: true
12
+ namespace: string
13
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
14
+ }
15
+
16
+ function imageTag(image: string): string {
17
+ const lastColon = image.lastIndexOf(':')
18
+ const lastSlash = image.lastIndexOf('/')
19
+ return lastColon > lastSlash ? image.slice(lastColon + 1) : image
20
+ }
21
+
22
+ function podToneClass(pod: WorkloadPodInfo): string {
23
+ switch (pod.healthLevel) {
24
+ case 'healthy':
25
+ return 'status-healthy'
26
+ case 'degraded':
27
+ return 'status-degraded'
28
+ case 'unhealthy':
29
+ return 'status-unhealthy'
30
+ default:
31
+ return 'status-unknown'
32
+ }
33
+ }
34
+
35
+ // Read-only revision tree: Rollout -> ReplicaSet revision -> pods, mirroring
36
+ // `kubectl argo rollouts get rollout`'s tree output. Newest first, matching
37
+ // the existing rollback-history dialog's ordering. Rollback stays exclusive
38
+ // to that dialog — this is purely informational.
39
+ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }: ReplicaSetProgressionProps) {
40
+ const [expanded, setExpanded] = useState<Set<number>>(new Set())
41
+
42
+ const podsByHash = new Map<string, WorkloadPodInfo[]>()
43
+ for (const pod of pods ?? []) {
44
+ if (!pod.revisionIdentity) continue
45
+ const list = podsByHash.get(pod.revisionIdentity) ?? []
46
+ list.push(pod)
47
+ podsByHash.set(pod.revisionIdentity, list)
48
+ }
49
+
50
+ const toggle = (number: number) =>
51
+ setExpanded((prev) => {
52
+ const next = new Set(prev)
53
+ if (next.has(number)) next.delete(number)
54
+ else next.add(number)
55
+ return next
56
+ })
57
+
58
+ return (
59
+ <div className="space-y-1">
60
+ {revisions.map((rev) => {
61
+ const revPods = rev.podHash ? podsByHash.get(rev.podHash) ?? [] : []
62
+ const isOpen = expanded.has(rev.number)
63
+ return (
64
+ <div key={rev.number} className="rounded border border-theme-border/50">
65
+ <button
66
+ type="button"
67
+ onClick={() => toggle(rev.number)}
68
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-theme-hover"
69
+ disabled={revPods.length === 0}
70
+ >
71
+ {revPods.length > 0 ? (
72
+ isOpen ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
73
+ ) : (
74
+ <span className="w-3.5 shrink-0" />
75
+ )}
76
+ <span className="font-mono text-xs text-theme-text-primary">#{rev.number}</span>
77
+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-theme-text-secondary">{imageTag(rev.image)}</span>
78
+ <div className="flex shrink-0 items-center gap-1">
79
+ {revisionRoleBadges(rev, true).map(({ label, tone, tip }) => (
80
+ <span key={label} title={tip} className={clsx('badge', tone)}>
81
+ {label}
82
+ </span>
83
+ ))}
84
+ </div>
85
+ <span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{rev.replicas ?? 0} replicas</span>
86
+ <span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(rev.createdAt)}</span>
87
+ </button>
88
+ {isOpen && revPods.length > 0 && (
89
+ <div className="space-y-0.5 border-t border-theme-border/50 px-2 py-1.5 pl-8">
90
+ {revPods.map((pod) => (
91
+ <div key={pod.name} className="flex items-center gap-2 py-0.5 text-xs">
92
+ <Box className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
93
+ {onNavigate ? (
94
+ <button
95
+ onClick={() => onNavigate({ kind: 'Pod', namespace, name: pod.name })}
96
+ className="min-w-0 flex-1 truncate text-left font-mono text-theme-text-secondary hover:text-brand hover:underline"
97
+ >
98
+ {pod.name}
99
+ </button>
100
+ ) : (
101
+ <span className="min-w-0 flex-1 truncate font-mono text-theme-text-secondary">{pod.name}</span>
102
+ )}
103
+ <span className={clsx('badge-sm', podToneClass(pod))}>{pod.phase || (pod.ready ? 'Ready' : 'Not Ready')}</span>
104
+ {(pod.restartCount ?? 0) > 0 && (
105
+ <span className="shrink-0 text-theme-text-tertiary">{pod.restartCount} restarts</span>
106
+ )}
107
+ </div>
108
+ ))}
109
+ </div>
110
+ )}
111
+ </div>
112
+ )
113
+ })}
114
+ </div>
115
+ )
116
+ }
@@ -1,5 +1,6 @@
1
1
  // Utility functions for resource display in tables
2
2
 
3
+ import { effectivePolicyTypeNames } from '../../utils/network-policy'
3
4
  import { formatCPUString, formatMemoryString, formatBytes } from '../../utils/format'
4
5
  import { pluralize } from '../../utils/pluralize'
5
6
  import type { WorkloadPodInfo } from '../../types/core'
@@ -1544,24 +1545,33 @@ export function getRolloutStatus(rollout: any): StatusBadge {
1544
1545
  return rolloutActivityBadge(getWorkloadRolloutActivity(rollout, 'rollout'))
1545
1546
  }
1546
1547
 
1547
- export function getAnalysisRunStatus(run: any): StatusBadge {
1548
- const phase = run.status?.phase || 'Unknown'
1548
+ /** The AnalysisPhase vocabulary (AnalysisRun, Experiment, and a Rollout's
1549
+ * analysis slots all report it) mapped onto HealthLevel. Shared so a caller
1550
+ * holding only the phase string lands on the same tone as one holding the
1551
+ * whole run. */
1552
+ export function analysisPhaseLevel(phase?: string): HealthLevel {
1549
1553
  switch (phase) {
1550
1554
  case 'Successful':
1551
- return { text: 'Successful', color: healthColors.healthy, level: 'healthy' }
1555
+ return 'healthy'
1552
1556
  case 'Running':
1553
1557
  case 'Pending':
1554
- return { text: phase, color: healthColors.degraded, level: 'degraded' }
1558
+ return 'degraded'
1555
1559
  case 'Inconclusive':
1556
- return { text: 'Inconclusive', color: healthColors.alert, level: 'alert' }
1560
+ return 'alert'
1557
1561
  case 'Failed':
1558
1562
  case 'Error':
1559
- return { text: phase, color: healthColors.unhealthy, level: 'unhealthy' }
1563
+ return 'unhealthy'
1560
1564
  default:
1561
- return { text: phase, color: healthColors.unknown, level: 'unknown' }
1565
+ return 'unknown'
1562
1566
  }
1563
1567
  }
1564
1568
 
1569
+ export function getAnalysisRunStatus(run: any): StatusBadge {
1570
+ const phase = run.status?.phase || 'Unknown'
1571
+ const level = analysisPhaseLevel(phase)
1572
+ return { text: phase, color: healthColors[level], level }
1573
+ }
1574
+
1565
1575
  // Counts only verdict-bearing metrics: Argo excludes dryRun results from the run's
1566
1576
  // phase, and Running/Pending have not passed yet.
1567
1577
  export function summarizeAnalysisMetrics(run: any): {
@@ -2068,8 +2078,7 @@ export function getWorkflowTemplateEntrypoint(wt: any): string {
2068
2078
  // ============================================================================
2069
2079
 
2070
2080
  export function getNetworkPolicyTypes(np: any): string {
2071
- const types = np.spec?.policyTypes || []
2072
- return types.join(', ') || '-'
2081
+ return effectivePolicyTypeNames(np.spec).join(', ')
2073
2082
  }
2074
2083
 
2075
2084
  export function getNetworkPolicyRuleCount(np: any): { ingress: number; egress: number } {
@@ -1,5 +1,6 @@
1
- import { useCallback, useEffect, useState } from 'react'
2
- import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
1
+ import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
2
+ import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, Upload, X } from 'lucide-react'
3
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
3
4
  import { DialogPortal } from '../ui/DialogPortal'
4
5
  import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
5
6
  import {
@@ -9,6 +10,17 @@ import {
9
10
  } from '../ui/YamlReview'
10
11
  import { Tooltip } from '../ui/Tooltip'
11
12
  import { formatApplyError } from '../../utils/k8s-errors'
13
+ import {
14
+ describeFileDrag,
15
+ needsReplaceConfirmation,
16
+ readYamlFile,
17
+ YAML_FILE_ACCEPT,
18
+ type FileDragState,
19
+ } from '../../utils/yaml-file-import'
20
+
21
+ // A frame this close to the last one means the main thread is keeping up; a
22
+ // blocked editor produces gaps far larger.
23
+ const responsiveFrameMs = 100
12
24
 
13
25
  export interface ApplyResult {
14
26
  name: string
@@ -71,6 +83,41 @@ export function CreateResourceDialog({
71
83
  nonAtomic: boolean
72
84
  context?: string
73
85
  } | null>(null)
86
+ const [drag, setDrag] = useState<FileDragState>('none')
87
+ const [pendingImport, setPendingImport] = useState<{ fileName: string; yaml: string } | null>(null)
88
+ const [importing, setImporting] = useState<string | null>(null)
89
+ // An import is several awaits and several frames long. Anything that changes
90
+ // what the result should land on — a newer import, a close, a reopen — bumps
91
+ // the generation, and every deferred step checks it before touching state.
92
+ const importGeneration = useRef(0)
93
+ const scheduledFrames = useRef(new Set<number>())
94
+ // The buffer as it stands now, not as it stood when a read began.
95
+ const yamlRef = useRef(yaml)
96
+ const fileInputRef = useRef<HTMLInputElement>(null)
97
+ // dragenter/dragleave also fire for the editor's own descendants, so the
98
+ // overlay tracks depth rather than the first dragleave it sees.
99
+ const dragDepth = useRef(0)
100
+
101
+ useEffect(() => {
102
+ yamlRef.current = yaml
103
+ }, [yaml])
104
+
105
+ const retireDeferredImports = useCallback(() => {
106
+ importGeneration.current += 1
107
+ for (const id of scheduledFrames.current) cancelAnimationFrame(id)
108
+ scheduledFrames.current.clear()
109
+ }, [])
110
+
111
+ useEffect(() => retireDeferredImports, [retireDeferredImports])
112
+
113
+ const scheduleFrame = useCallback((generation: number, run: () => void) => {
114
+ const id = requestAnimationFrame(() => {
115
+ scheduledFrames.current.delete(id)
116
+ if (generation !== importGeneration.current) return
117
+ run()
118
+ })
119
+ scheduledFrames.current.add(id)
120
+ }, [])
74
121
 
75
122
  useEffect(() => {
76
123
  if (!open) return
@@ -82,16 +129,24 @@ export function CreateResourceDialog({
82
129
  setError(null)
83
130
  setSuccess(null)
84
131
  setPreview(null)
85
- }, [open, initialYaml])
132
+ setDrag('none')
133
+ setPendingImport(null)
134
+ setImporting(null)
135
+ dragDepth.current = 0
136
+ retireDeferredImports()
137
+ }, [open, initialYaml, retireDeferredImports])
86
138
 
87
139
  const pending = isApplying || isPreviewing
88
140
  const closeNow = useCallback(() => {
141
+ retireDeferredImports()
89
142
  setError(null)
90
143
  setSuccess(null)
91
144
  setPreview(null)
145
+ setPendingImport(null)
146
+ setImporting(null)
92
147
  setYaml('')
93
148
  onClose()
94
- }, [onClose])
149
+ }, [onClose, retireDeferredImports])
95
150
  const handleClose = useCallback(() => {
96
151
  if (!pending) closeNow()
97
152
  }, [closeNow, pending])
@@ -123,6 +178,100 @@ export function CreateResourceDialog({
123
178
  [closeNow, onCreated],
124
179
  )
125
180
 
181
+ // Handing a document to the editor parses the whole buffer and blocks the
182
+ // main thread, so the status has to be on screen before that starts. Two
183
+ // frames: the first commits it, the second lets the browser paint it. A
184
+ // spinner would only freeze mid-turn, so this says what is happening instead
185
+ // of pretending to animate.
186
+ const loadIntoEditor = useCallback(
187
+ (fileName: string, content: string, generation: number) => {
188
+ setImporting(fileName)
189
+ scheduleFrame(generation, () => {
190
+ scheduleFrame(generation, () => {
191
+ setYaml(content)
192
+ // Clearing it in this same callback would batch it into the very
193
+ // render that blocks, retiring the status while the editor is still
194
+ // unusable. Wait for a frame to arrive on time instead: that is the
195
+ // editor answering again, which is what the status was promising.
196
+ let previous = performance.now()
197
+ const clearWhenResponsive = () => {
198
+ const now = performance.now()
199
+ if (now - previous < responsiveFrameMs) {
200
+ setImporting(null)
201
+ return
202
+ }
203
+ previous = now
204
+ scheduleFrame(generation, clearWhenResponsive)
205
+ }
206
+ scheduleFrame(generation, clearWhenResponsive)
207
+ })
208
+ })
209
+ },
210
+ [scheduleFrame],
211
+ )
212
+
213
+ const loadFiles = useCallback(
214
+ async (files: File[]) => {
215
+ const generation = ++importGeneration.current
216
+ const result = await readYamlFile(files)
217
+ // A newer import, a close or a reopen happened while this one was
218
+ // reading; its result describes an editor that has moved on.
219
+ if (generation !== importGeneration.current) return
220
+ // This import owns the status from here. Taking the generation retired
221
+ // the frames that would have cleared a previous import's overlay, so
222
+ // every path out of this function has to leave it in a sane state — the
223
+ // ones that return early below would otherwise strand it on screen.
224
+ setImporting(null)
225
+ if (!result.ok) {
226
+ setSuccess(null)
227
+ setError(result.message)
228
+ return
229
+ }
230
+ setError(null)
231
+ setSuccess(null)
232
+ if (needsReplaceConfirmation(yamlRef.current, initialYaml)) {
233
+ setPendingImport({ fileName: result.fileName, yaml: result.yaml })
234
+ return
235
+ }
236
+ loadIntoEditor(result.fileName, result.yaml, generation)
237
+ },
238
+ [initialYaml, loadIntoEditor],
239
+ )
240
+
241
+ const handleDragEnter = useCallback((event: ReactDragEvent) => {
242
+ const state = describeFileDrag(event.dataTransfer)
243
+ if (state === 'none') return
244
+ event.preventDefault()
245
+ dragDepth.current += 1
246
+ setDrag(state)
247
+ }, [])
248
+
249
+ const handleDragOver = useCallback((event: ReactDragEvent) => {
250
+ if (describeFileDrag(event.dataTransfer) === 'none') return
251
+ // Without this the browser navigates away to the dropped file.
252
+ event.preventDefault()
253
+ event.dataTransfer.dropEffect = 'copy'
254
+ }, [])
255
+
256
+ const handleDragLeave = useCallback((event: ReactDragEvent) => {
257
+ if (describeFileDrag(event.dataTransfer) === 'none') return
258
+ dragDepth.current = Math.max(0, dragDepth.current - 1)
259
+ if (dragDepth.current === 0) setDrag('none')
260
+ }, [])
261
+
262
+ const handleDrop = useCallback(
263
+ (event: ReactDragEvent) => {
264
+ if (describeFileDrag(event.dataTransfer) === 'none') return
265
+ event.preventDefault()
266
+ dragDepth.current = 0
267
+ setDrag('none')
268
+ // The DataTransfer is neutered once this handler returns, so take the
269
+ // files off it before anything awaits.
270
+ void loadFiles(Array.from(event.dataTransfer.files))
271
+ },
272
+ [loadFiles],
273
+ )
274
+
126
275
  const handleSubmit = useCallback(async () => {
127
276
  if (!yaml.trim()) {
128
277
  setError('YAML content is required')
@@ -256,13 +405,64 @@ export function CreateResourceDialog({
256
405
  </div>
257
406
 
258
407
  <div className="min-h-0 flex-1 px-5 py-3">
259
- <YamlEditor
260
- value={yaml}
261
- onChange={setYaml}
262
- height="400px"
263
- onValidate={handleValidate}
264
- schemaLoader={schemaLoader}
265
- />
408
+ <div className="mb-2 flex items-center gap-2">
409
+ <button
410
+ type="button"
411
+ onClick={() => fileInputRef.current?.click()}
412
+ className="flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 text-xs font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
413
+ >
414
+ <Upload className="h-3.5 w-3.5" />
415
+ Import YAML file
416
+ </button>
417
+ <span className="text-xs text-theme-text-tertiary">
418
+ or drop a YAML file into the editor
419
+ </span>
420
+ <input
421
+ ref={fileInputRef}
422
+ type="file"
423
+ accept={YAML_FILE_ACCEPT}
424
+ className="hidden"
425
+ onChange={(event) => {
426
+ const picked = Array.from(event.target.files ?? [])
427
+ // Clearing the input empties its FileList, so read it first.
428
+ event.target.value = ''
429
+ void loadFiles(picked)
430
+ }}
431
+ />
432
+ </div>
433
+
434
+ <div
435
+ className="relative h-[400px]"
436
+ onDragEnter={handleDragEnter}
437
+ onDragOver={handleDragOver}
438
+ onDragLeave={handleDragLeave}
439
+ onDrop={handleDrop}
440
+ >
441
+ <YamlEditor
442
+ value={yaml}
443
+ onChange={setYaml}
444
+ height="400px"
445
+ onValidate={handleValidate}
446
+ schemaLoader={schemaLoader}
447
+ />
448
+ {importing && (
449
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border border-theme-border bg-theme-base/85">
450
+ <span className="text-sm font-medium text-theme-text-primary">
451
+ Loading {importing}…
452
+ </span>
453
+ </div>
454
+ )}
455
+ {drag !== 'none' && (
456
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-accent bg-theme-base/85">
457
+ <span className="flex items-center gap-2 text-sm font-medium text-theme-text-primary">
458
+ <Upload className="h-4 w-4" />
459
+ {drag === 'multiple-files'
460
+ ? 'Drop a single YAML file'
461
+ : 'Drop YAML file to load'}
462
+ </span>
463
+ </div>
464
+ )}
465
+ </div>
266
466
  </div>
267
467
 
268
468
  {(error || previewError) && (
@@ -362,6 +562,26 @@ export function CreateResourceDialog({
362
562
  </div>
363
563
  </>
364
564
  )}
565
+
566
+ <ConfirmDialog
567
+ open={pendingImport !== null}
568
+ onClose={() => setPendingImport(null)}
569
+ onConfirm={() => {
570
+ // Confirming is its own decision about the editor as it stands now,
571
+ // so it loads under a fresh generation rather than the one the read
572
+ // started under, which anything since may have retired.
573
+ if (pendingImport) {
574
+ const generation = ++importGeneration.current
575
+ loadIntoEditor(pendingImport.fileName, pendingImport.yaml, generation)
576
+ }
577
+ setPendingImport(null)
578
+ }}
579
+ variant="warning"
580
+ showWarning={false}
581
+ title="Replace editor contents?"
582
+ message={`Loading ${pendingImport?.fileName ?? 'this file'} discards the YAML currently in the editor.`}
583
+ confirmLabel="Replace"
584
+ />
365
585
  </DialogPortal>
366
586
  )
367
587
  }