@skyhook-io/k8s-ui 1.3.2 → 1.4.1

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 (78) hide show
  1. package/package.json +2 -2
  2. package/src/components/audit/AuditAlerts.tsx +88 -0
  3. package/src/components/audit/AuditCard.tsx +130 -0
  4. package/src/components/audit/AuditFindingsTable.tsx +526 -0
  5. package/src/components/audit/index.ts +3 -0
  6. package/src/components/dock/DockContext.tsx +5 -2
  7. package/src/components/dock/LocalTerminalTab.tsx +11 -0
  8. package/src/components/resources/ResourcesView.tsx +126 -49
  9. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  10. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  12. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  13. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  14. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  15. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +445 -0
  16. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  17. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  18. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  19. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +160 -0
  20. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  21. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  22. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  24. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  25. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  26. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  27. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  28. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  29. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  30. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  31. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +17 -57
  34. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  35. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  36. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  38. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  39. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  41. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  42. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  43. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  44. package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
  45. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  47. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  49. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  50. package/src/components/resources/renderers/index.ts +3 -0
  51. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  52. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  53. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  54. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  55. package/src/components/resources/resource-utils.ts +131 -33
  56. package/src/components/shared/CreateResourceDialog.tsx +236 -0
  57. package/src/components/shared/EditableYamlView.tsx +16 -1
  58. package/src/components/shared/ResourceActionsBar.tsx +9 -7
  59. package/src/components/shared/ResourceRendererDispatch.tsx +13 -6
  60. package/src/components/shared/index.ts +1 -0
  61. package/src/components/timeline/TimelineList.tsx +11 -4
  62. package/src/components/topology/K8sResourceNode.tsx +15 -0
  63. package/src/components/topology/TopologyControls.tsx +21 -1
  64. package/src/components/topology/TopologyGraph.tsx +1 -1
  65. package/src/components/ui/Badge.tsx +8 -0
  66. package/src/components/ui/drawer-components.tsx +68 -5
  67. package/src/components/workload/WorkloadView.tsx +55 -29
  68. package/src/index.ts +3 -0
  69. package/src/theme/variables.css +1 -1
  70. package/src/types/core.ts +2 -1
  71. package/src/types/gitops.ts +8 -0
  72. package/src/utils/badge-colors.ts +7 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/k8s-errors.ts +142 -0
  75. package/src/utils/navigation.test.ts +29 -2
  76. package/src/utils/navigation.ts +45 -24
  77. package/src/utils/resource-icons.ts +3 -0
  78. package/src/utils/skeleton-yaml.ts +302 -0
@@ -166,6 +166,43 @@ export function getCNPGClusterPostgresParams(resource: any): Record<string, stri
166
166
  return resource.spec?.postgresql?.parameters || {}
167
167
  }
168
168
 
169
+ /** Per-instance replication state from status.instancesReportedState */
170
+ export interface CNPGInstanceReportedState {
171
+ podName: string
172
+ isPrimary: boolean
173
+ timelineID?: number
174
+ }
175
+
176
+ export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanceReportedState[] {
177
+ const reported = resource.status?.instancesReportedState
178
+ if (!reported || typeof reported !== 'object') return []
179
+ return Object.entries(reported).map(([podName, state]: [string, any]) => ({
180
+ podName,
181
+ isPrimary: state?.isPrimary === true,
182
+ timelineID: state?.timelineID,
183
+ }))
184
+ }
185
+
186
+ /** Certificate expirations from status.certificates.expirations */
187
+ export interface CNPGCertificateExpiry {
188
+ secretName: string
189
+ expiryDate: string
190
+ daysUntilExpiry: number
191
+ }
192
+
193
+ export function getCNPGClusterCertificateExpirations(resource: any): CNPGCertificateExpiry[] {
194
+ const expirations = resource.status?.certificates?.expirations
195
+ if (!expirations || typeof expirations !== 'object') return []
196
+ const now = new Date()
197
+ return Object.entries(expirations).map(([secretName, expiryDate]: [string, any]) => {
198
+ const expiry = new Date(expiryDate)
199
+ const daysUntilExpiry = isNaN(expiry.getTime())
200
+ ? -1 // treat unparseable dates as expired/critical
201
+ : Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
202
+ return { secretName, expiryDate: String(expiryDate), daysUntilExpiry }
203
+ })
204
+ }
205
+
169
206
  // ============================================================================
170
207
  // CNPG BACKUP UTILITIES
171
208
  // ============================================================================
@@ -342,3 +379,13 @@ export function getCNPGPoolerInstances(resource: any): string {
342
379
  export function getCNPGPoolerParameters(resource: any): Record<string, string> {
343
380
  return resource.spec?.pgbouncer?.parameters || {}
344
381
  }
382
+
383
+ export function getCNPGPoolerAuthQuery(resource: any): string | undefined {
384
+ return resource.spec?.pgbouncer?.authQuery
385
+ }
386
+
387
+ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } | undefined {
388
+ const secret = resource.spec?.pgbouncer?.authQuerySecret
389
+ if (!secret?.name) return undefined
390
+ return { name: secret.name }
391
+ }
@@ -3,6 +3,12 @@
3
3
  import type { StatusBadge } from './resource-utils'
4
4
  import { healthColors } from './resource-utils'
5
5
 
6
+ // Shared capacity type badge colors for NodeClaim renderer and table cells
7
+ export const CAPACITY_TYPE_BADGE: Record<string, string> = {
8
+ spot: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
9
+ 'on-demand': 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
10
+ }
11
+
6
12
  // ============================================================================
7
13
  // KARPENTER NODEPOOL UTILITIES
8
14
  // ============================================================================
@@ -99,23 +99,67 @@ export function getPrometheusRuleTotalRules(resource: any): number {
99
99
  return groups.reduce((sum: number, g: any) => sum + (g.rules?.length || 0), 0)
100
100
  }
101
101
 
102
- export function getPrometheusRuleGroups(resource: any): Array<{
102
+ export interface PrometheusAlertRule {
103
+ type: 'alert'
104
+ alert: string
105
+ expr: string
106
+ for?: string
107
+ severity?: string
108
+ summary?: string
109
+ description?: string
110
+ labels?: Record<string, string>
111
+ }
112
+
113
+ export interface PrometheusRecordingRule {
114
+ type: 'recording'
115
+ record: string
116
+ expr: string
117
+ labels?: Record<string, string>
118
+ }
119
+
120
+ export type PrometheusRule = PrometheusAlertRule | PrometheusRecordingRule
121
+
122
+ export interface PrometheusRuleGroup {
103
123
  name: string
104
124
  interval?: string
105
125
  ruleCount: number
106
126
  alertCount: number
107
127
  recordCount: number
108
- }> {
128
+ rules: PrometheusRule[]
129
+ }
130
+
131
+ export function getPrometheusRuleGroups(resource: any): PrometheusRuleGroup[] {
109
132
  return (resource.spec?.groups || []).map((g: any) => {
110
- const rules = g.rules || []
111
- const alertCount = rules.filter((r: any) => r.alert).length
112
- const recordCount = rules.filter((r: any) => r.record).length
133
+ const rawRules = g.rules || []
134
+ const alertCount = rawRules.filter((r: any) => r.alert).length
135
+ const recordCount = rawRules.filter((r: any) => r.record).length
136
+ const rules: PrometheusRule[] = rawRules.map((r: any) => {
137
+ if (r.alert) {
138
+ return {
139
+ type: 'alert' as const,
140
+ alert: r.alert,
141
+ expr: r.expr || '',
142
+ for: r.for,
143
+ severity: r.labels?.severity,
144
+ summary: r.annotations?.summary,
145
+ description: r.annotations?.description,
146
+ labels: r.labels,
147
+ }
148
+ }
149
+ return {
150
+ type: 'recording' as const,
151
+ record: r.record || '',
152
+ expr: r.expr || '',
153
+ labels: r.labels,
154
+ }
155
+ })
113
156
  return {
114
157
  name: g.name,
115
158
  interval: g.interval,
116
- ruleCount: rules.length,
159
+ ruleCount: rawRules.length,
117
160
  alertCount,
118
161
  recordCount,
162
+ rules,
119
163
  }
120
164
  })
121
165
  }
@@ -42,6 +42,44 @@ export interface PodProblem {
42
42
  message: string
43
43
  }
44
44
 
45
+ /** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
46
+ export const SEVERITY_DOT_COLOR: Record<PodProblem['severity'], string> = {
47
+ critical: 'bg-red-400',
48
+ high: 'bg-orange-400',
49
+ medium: 'bg-yellow-400',
50
+ }
51
+
52
+ /** Check whether a pod's problems match a given problem category (filter chip label) */
53
+ export function podMatchesProblemCategory(problems: PodProblem[], restarts: number, category: string): boolean {
54
+ const msgs = problems.map(p => p.message)
55
+ switch (category) {
56
+ case 'CrashLoopBackOff':
57
+ return msgs.includes('CrashLoopBackOff')
58
+ case 'ImagePullBackOff':
59
+ return msgs.some(m => m.includes('ImagePull') && !m.startsWith('Init:'))
60
+ case 'OOMKilled':
61
+ return msgs.includes('OOMKilled')
62
+ case 'Unschedulable':
63
+ return msgs.includes('Unschedulable')
64
+ case 'Not Ready':
65
+ return msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))
66
+ case 'High Restarts':
67
+ return restarts > 5
68
+ case 'Init Failed':
69
+ return msgs.some(m => m.startsWith('Init:'))
70
+ case 'Exit Code Error':
71
+ return msgs.some(m => m.startsWith('Exit Code'))
72
+ case 'Failed':
73
+ return msgs.includes('Failed') || msgs.includes('Unknown')
74
+ case 'Other': {
75
+ const knownPatterns = ['CrashLoopBackOff', 'ImagePull', 'ErrImagePull', 'OOMKilled', 'Unschedulable', 'Not Ready', 'Probe', 'Init:', 'Exit Code', 'Failed', 'Unknown']
76
+ return problems.some(p => !knownPatterns.some(pat => p.message.includes(pat)) && p.message !== `${restarts} restarts`)
77
+ }
78
+ default:
79
+ return false
80
+ }
81
+ }
82
+
45
83
  export function getPodStatus(pod: any): StatusBadge {
46
84
  const phase = pod.status?.phase || 'Unknown'
47
85
  const containerStatuses = pod.status?.containerStatuses || []
@@ -87,7 +125,31 @@ export function getPodStatus(pod: any): StatusBadge {
87
125
  export function getPodProblems(pod: any): PodProblem[] {
88
126
  const problems: PodProblem[] = []
89
127
  const containerStatuses = pod.status?.containerStatuses || []
128
+ const initContainerStatuses = pod.status?.initContainerStatuses || []
90
129
  const conditions = pod.status?.conditions || []
130
+ const phase = pod.status?.phase
131
+
132
+ // Failed or Unknown phase
133
+ if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
134
+ problems.push({ severity: 'critical', message: 'Failed' })
135
+ } else if (phase === 'Unknown') {
136
+ problems.push({ severity: 'high', message: 'Unknown' })
137
+ }
138
+
139
+ // Init container failures
140
+ for (const cs of initContainerStatuses) {
141
+ if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
142
+ const reason = cs.state.waiting.reason
143
+ if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
144
+ problems.push({ severity: 'critical', message: `Init: ${reason}` })
145
+ } else {
146
+ problems.push({ severity: 'high', message: `Init: ${reason}` })
147
+ }
148
+ }
149
+ if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
150
+ problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}` })
151
+ }
152
+ }
91
153
 
92
154
  for (const cs of containerStatuses) {
93
155
  // Check waiting state
@@ -99,11 +161,15 @@ export function getPodProblems(pod: any): PodProblem[] {
99
161
  problems.push({ severity: 'critical', message: 'Config Error' })
100
162
  } else if (reason === 'ContainerCannotRun') {
101
163
  problems.push({ severity: 'critical', message: 'Cannot Run' })
164
+ } else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
165
+ problems.push({ severity: 'high', message: reason })
102
166
  }
103
167
  }
104
168
  // Check terminated state
105
169
  if (cs.state?.terminated?.reason === 'OOMKilled') {
106
170
  problems.push({ severity: 'critical', message: 'OOMKilled' })
171
+ } else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
172
+ problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}` })
107
173
  }
108
174
  // High restart count
109
175
  if (cs.restartCount > 5) {
@@ -142,7 +208,7 @@ export function getPodProblems(pod: any): PodProblem[] {
142
208
  }
143
209
 
144
210
  // Evicted pods
145
- if (pod.status?.phase === 'Failed' && pod.status?.reason === 'Evicted') {
211
+ if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
146
212
  problems.push({ severity: 'high', message: 'Evicted' })
147
213
  }
148
214
 
@@ -156,7 +222,6 @@ export function getPodProblems(pod: any): PodProblem[] {
156
222
  }
157
223
 
158
224
  // Not ready (Running but containers not ready)
159
- const phase = pod.status?.phase
160
225
  if (phase === 'Running') {
161
226
  const readyContainers = containerStatuses.filter((c: any) => c.ready).length
162
227
  const totalContainers = containerStatuses.length
@@ -335,6 +400,70 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
335
400
  return { text: `${ready}/${desired}`, color: healthColors.unhealthy, level: 'unhealthy' }
336
401
  }
337
402
 
403
+ /** Detect problems for Deployments, StatefulSets, DaemonSets. Parallel to getPodProblems. */
404
+ export function getWorkloadProblems(resource: any, kind: string): PodProblem[] {
405
+ const problems: PodProblem[] = []
406
+ const status = resource.status || {}
407
+ const spec = resource.spec || {}
408
+ const k = kind.toLowerCase()
409
+
410
+ if (k === 'daemonsets') {
411
+ const desired = status.desiredNumberScheduled || 0
412
+ const ready = status.numberReady || 0
413
+ if (desired > 0 && ready === 0) {
414
+ problems.push({ severity: 'critical', message: 'No pods ready' })
415
+ } else if (desired > 0 && ready < desired) {
416
+ problems.push({ severity: 'high', message: `${desired - ready} pods unavailable` })
417
+ }
418
+ return problems
419
+ }
420
+
421
+ // Deployment, StatefulSet
422
+ const desired = spec.replicas ?? status.replicas ?? 0
423
+ const ready = status.readyReplicas || 0
424
+ const available = status.availableReplicas ?? ready
425
+ const updated = status.updatedReplicas || 0
426
+
427
+ if (desired === 0) return problems
428
+
429
+ if (ready === 0 && desired > 0) {
430
+ problems.push({ severity: 'critical', message: 'No pods ready' })
431
+ } else if (available < desired) {
432
+ problems.push({ severity: 'high', message: `${desired - available} pods unavailable` })
433
+ }
434
+
435
+ if (updated > 0 && updated < desired) {
436
+ problems.push({ severity: 'medium', message: 'Rollout in progress' })
437
+ }
438
+
439
+ // Check conditions for stuck rollouts (Deployment only)
440
+ if (k === 'deployments') {
441
+ const conditions = status.conditions || []
442
+ for (const cond of conditions) {
443
+ if (cond.type === 'Progressing' && cond.status === 'False' && cond.reason === 'ProgressDeadlineExceeded') {
444
+ problems.push({ severity: 'critical', message: 'Rollout stuck' })
445
+ }
446
+ }
447
+ }
448
+
449
+ return problems
450
+ }
451
+
452
+ /** Check whether a workload's problems match a given problem category */
453
+ export function workloadMatchesProblemCategory(problems: PodProblem[], category: string): boolean {
454
+ const msgs = problems.map(p => p.message)
455
+ switch (category) {
456
+ case 'Unavailable':
457
+ return msgs.some(m => m.includes('unavailable') || m.includes('No pods ready'))
458
+ case 'Rollout Stuck':
459
+ return msgs.includes('Rollout stuck')
460
+ case 'Rollout In Progress':
461
+ return msgs.includes('Rollout in progress')
462
+ default:
463
+ return false
464
+ }
465
+ }
466
+
338
467
  export function getWorkloadImages(resource: any): string[] {
339
468
  const containers = resource.spec?.template?.spec?.containers || []
340
469
  return containers.map((c: any) => {
@@ -1325,37 +1454,6 @@ export function getRoleBindingSubjectCount(rb: any): number {
1325
1454
  // WORKLOAD PROBLEM DETECTION (for table row indicators)
1326
1455
  // ============================================================================
1327
1456
 
1328
- export function getWorkloadProblems(resource: any, kind: string): string[] {
1329
- const problems: string[] = []
1330
- const status = resource.status || {}
1331
- const spec = resource.spec || {}
1332
-
1333
- if (kind === 'daemonsets') {
1334
- const ready = status.numberReady || 0
1335
- const desired = status.desiredNumberScheduled || 0
1336
- if (desired > 0 && ready < desired) {
1337
- problems.push(`${desired - ready} pods not ready`)
1338
- }
1339
- } else {
1340
- const ready = status.readyReplicas || 0
1341
- const desired = spec.replicas ?? 0
1342
- if (desired > 0 && ready < desired) {
1343
- problems.push(`${desired - ready} replicas not ready`)
1344
- }
1345
- }
1346
-
1347
- const conditions = status.conditions || []
1348
- for (const cond of conditions) {
1349
- if (cond.status === 'True' && cond.type === 'ReplicaFailure') {
1350
- problems.push('ReplicaFailure')
1351
- }
1352
- if (cond.status === 'False' && cond.type === 'Available') {
1353
- problems.push('Unavailable')
1354
- }
1355
- }
1356
-
1357
- return problems
1358
- }
1359
1457
 
1360
1458
  // ============================================================================
1361
1459
  // FORMATTING UTILITIES
@@ -0,0 +1,236 @@
1
+ import { useState, useCallback, useEffect } from 'react'
2
+ import { X, Loader2, Check, AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'
3
+ import { DialogPortal } from '../ui/DialogPortal'
4
+ import { YamlEditor } from '../ui/YamlEditor'
5
+ import { Tooltip } from '../ui/Tooltip'
6
+ import { formatApplyError } from '../../utils/k8s-errors'
7
+
8
+ export interface ApplyResult {
9
+ name: string
10
+ namespace: string
11
+ kind: string
12
+ created: boolean
13
+ }
14
+
15
+ export interface CreateResourceDialogProps {
16
+ open: boolean
17
+ onClose: () => void
18
+ initialYaml?: string
19
+ title?: string
20
+ // Injected by platform (decouples from data-fetching hooks)
21
+ onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean }) => Promise<ApplyResult[]>
22
+ isApplying: boolean
23
+ /** Called after a successful non-dry-run apply with the first created resource */
24
+ onCreated?: (result: ApplyResult) => void
25
+ }
26
+
27
+ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, onApply, isApplying, onCreated }: CreateResourceDialogProps) {
28
+ const [yaml, setYaml] = useState(initialYaml)
29
+ const [mode, setMode] = useState<'apply' | 'create'>('apply')
30
+ const [dryRun, setDryRun] = useState(false)
31
+ const [yamlValid, setYamlValid] = useState(true)
32
+ const [error, setError] = useState<string | null>(null)
33
+ const [success, setSuccess] = useState<string | null>(null)
34
+
35
+ // Reset state when dialog opens or initialYaml changes
36
+ useEffect(() => {
37
+ if (open) {
38
+ setYaml(initialYaml)
39
+ setMode('apply')
40
+ setDryRun(false)
41
+ setError(null)
42
+ setSuccess(null)
43
+ }
44
+ }, [open, initialYaml])
45
+
46
+ const handleClose = useCallback(() => {
47
+ if (isApplying) return
48
+ setError(null)
49
+ setSuccess(null)
50
+ setYaml('')
51
+ onClose()
52
+ }, [onClose, isApplying])
53
+
54
+ const handleValidate = useCallback((_isValid: boolean, errors: string[]) => {
55
+ setYamlValid(errors.length === 0)
56
+ }, [])
57
+
58
+ const handleSubmit = useCallback(async () => {
59
+ if (!yaml.trim()) {
60
+ setError('YAML content is required')
61
+ return
62
+ }
63
+ setError(null)
64
+ setSuccess(null)
65
+
66
+ try {
67
+ const results = await onApply({ yaml, mode, dryRun })
68
+ const action = mode === 'create' ? 'Created' : 'Applied'
69
+ const dryRunLabel = dryRun ? ' (dry run)' : ''
70
+
71
+ if (results.length === 1) {
72
+ const r = results[0]
73
+ setSuccess(`${action} ${r.kind} ${r.namespace ? r.namespace + '/' : ''}${r.name}${dryRunLabel}`)
74
+ } else {
75
+ setSuccess(`${action} ${results.length} resources${dryRunLabel}`)
76
+ }
77
+
78
+ if (!dryRun) {
79
+ if (onCreated && results.length > 0) {
80
+ // Close immediately and navigate to the created resource
81
+ handleClose()
82
+ onCreated(results[0])
83
+ } else {
84
+ setTimeout(handleClose, 1200)
85
+ }
86
+ }
87
+ } catch (err) {
88
+ setError(err instanceof Error ? err.message : 'Unknown error')
89
+ }
90
+ }, [yaml, mode, dryRun, onApply, onCreated, handleClose])
91
+
92
+ const dialogTitle = title || 'Create Resource'
93
+ const submitLabel = mode === 'create' ? 'Create' : 'Apply'
94
+
95
+ return (
96
+ <DialogPortal open={open} onClose={handleClose} closable={!isApplying} className="w-[700px] max-h-[85vh] flex flex-col">
97
+ {/* Header */}
98
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-theme-border shrink-0">
99
+ <h2 className="text-sm font-semibold text-theme-text-primary">{dialogTitle}</h2>
100
+ <Tooltip content="Close">
101
+ <button
102
+ onClick={handleClose}
103
+ className="p-1 rounded hover:bg-theme-hover text-theme-text-secondary transition-colors"
104
+ >
105
+ <X className="w-4 h-4" />
106
+ </button>
107
+ </Tooltip>
108
+ </div>
109
+
110
+ {/* Editor */}
111
+ <div className="flex-1 min-h-0 px-5 py-3">
112
+ <YamlEditor
113
+ value={yaml}
114
+ onChange={setYaml}
115
+ height="400px"
116
+ onValidate={handleValidate}
117
+ />
118
+ </div>
119
+
120
+ {/* Status messages — single location for feedback (no toast) */}
121
+ {error && <ApplyErrorBanner error={error} />}
122
+ {success && (
123
+ <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">
124
+ <Check className="w-3.5 h-3.5 shrink-0" />
125
+ <span>{success}</span>
126
+ </div>
127
+ )}
128
+
129
+ {/* Footer */}
130
+ <div className="flex items-center justify-between px-5 py-3 border-t border-theme-border shrink-0">
131
+ <div className="flex items-center gap-3">
132
+ {/* Mode toggle — pill segmented control */}
133
+ <Tooltip content="Apply: create or update (idempotent). Create: fail if exists." position="bottom">
134
+ <div className="flex items-center rounded-md bg-theme-base border border-theme-border p-0.5" role="radiogroup" aria-label="Apply mode">
135
+ <button
136
+ onClick={() => setMode('apply')}
137
+ role="radio"
138
+ aria-checked={mode === 'apply'}
139
+ className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
140
+ mode === 'apply'
141
+ ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
142
+ : 'text-theme-text-tertiary hover:text-theme-text-secondary'
143
+ }`}
144
+ >
145
+ Apply
146
+ </button>
147
+ <button
148
+ onClick={() => setMode('create')}
149
+ role="radio"
150
+ aria-checked={mode === 'create'}
151
+ className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
152
+ mode === 'create'
153
+ ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
154
+ : 'text-theme-text-tertiary hover:text-theme-text-secondary'
155
+ }`}
156
+ >
157
+ Create
158
+ </button>
159
+ </div>
160
+ </Tooltip>
161
+
162
+ {/* Dry run checkbox */}
163
+ <Tooltip content="Validate against the cluster without persisting changes" position="bottom">
164
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary cursor-pointer">
165
+ <input
166
+ type="checkbox"
167
+ checked={dryRun}
168
+ onChange={(e) => setDryRun(e.target.checked)}
169
+ className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
170
+ />
171
+ Dry run
172
+ </label>
173
+ </Tooltip>
174
+ </div>
175
+
176
+ <div className="flex items-center gap-2">
177
+ <button
178
+ onClick={handleClose}
179
+ className="px-3 py-1.5 text-xs rounded-lg hover:bg-theme-hover text-theme-text-secondary transition-colors"
180
+ >
181
+ Cancel
182
+ </button>
183
+ <button
184
+ onClick={handleSubmit}
185
+ disabled={isApplying || !yaml.trim() || !yamlValid}
186
+ 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"
187
+ >
188
+ {isApplying ? (
189
+ <>
190
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
191
+ {submitLabel === 'Apply' ? 'Applying...' : 'Creating...'}
192
+ </>
193
+ ) : (
194
+ submitLabel
195
+ )}
196
+ </button>
197
+ </div>
198
+ </div>
199
+ </DialogPortal>
200
+ )
201
+ }
202
+
203
+ function ApplyErrorBanner({ error }: { error: string }) {
204
+ const [expanded, setExpanded] = useState(false)
205
+ const parsed = formatApplyError(error)
206
+ const hasFriendly = !!parsed.suggestion
207
+
208
+ return (
209
+ <div className="mx-5 mb-2 rounded-md bg-red-500/10 border border-red-500/30 text-xs">
210
+ <div className="px-3 py-2 flex items-start gap-2 text-red-400">
211
+ <AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
212
+ <div className="min-w-0 flex-1">
213
+ <span className="font-medium">{parsed.summary}</span>
214
+ {parsed.suggestion && (
215
+ <p className="mt-1 text-red-400/80">{parsed.suggestion}</p>
216
+ )}
217
+ </div>
218
+ </div>
219
+ {hasFriendly && (
220
+ <button
221
+ type="button"
222
+ onClick={() => setExpanded(!expanded)}
223
+ className="flex items-center gap-1 px-3 pb-2 text-red-400/60 hover:text-red-400/80 transition-colors"
224
+ >
225
+ {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
226
+ <span>Details</span>
227
+ </button>
228
+ )}
229
+ {(expanded || !hasFriendly) && hasFriendly && (
230
+ <div className="px-3 pb-2 text-red-400/60 break-all font-mono leading-relaxed">
231
+ {parsed.raw}
232
+ </div>
233
+ )}
234
+ </div>
235
+ )
236
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback } from 'react'
2
2
  import {
3
3
  Copy,
4
+ CopyPlus,
4
5
  Check,
5
6
  RefreshCw,
6
7
  Pencil,
@@ -11,6 +12,7 @@ import {
11
12
  import { stringify as yamlStringify } from 'yaml'
12
13
  import { CodeViewer } from '../ui/CodeViewer'
13
14
  import { YamlEditor } from '../ui/YamlEditor'
15
+ import { Tooltip } from '../ui/Tooltip'
14
16
  import type { SelectedResource } from '../../types'
15
17
 
16
18
  // ============================================================================
@@ -102,9 +104,11 @@ interface EditableYamlViewProps {
102
104
  isSaving?: boolean
103
105
  /** Error message from the last save attempt */
104
106
  saveError?: string | null
107
+ /** Duplicate handler — opens create dialog with this resource's YAML */
108
+ onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
105
109
  }
106
110
 
107
- export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError }: EditableYamlViewProps) {
111
+ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate }: EditableYamlViewProps) {
108
112
  const [isEditing, setIsEditing] = useState(false)
109
113
  const [editedYaml, setEditedYaml] = useState('')
110
114
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -289,6 +293,17 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
289
293
  {copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
290
294
  Copy
291
295
  </button>
296
+ {onDuplicate && (
297
+ <Tooltip content="Duplicate as new resource">
298
+ <button
299
+ onClick={() => onDuplicate({ kind: resource.kind, namespace: resource.namespace, name: resource.name, yaml: yamlContent })}
300
+ 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"
301
+ >
302
+ <CopyPlus className="w-3.5 h-3.5" />
303
+ Duplicate
304
+ </button>
305
+ </Tooltip>
306
+ )}
292
307
  </div>
293
308
  </div>
294
309
  <CodeViewer
@@ -18,6 +18,7 @@ import {
18
18
  } from 'lucide-react'
19
19
  import { createTwoFilesPatch } from 'diff'
20
20
  import { clsx } from 'clsx'
21
+ import { Tooltip } from '../ui/Tooltip'
21
22
  import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
22
23
  import { ConfirmDialog } from '../ui/ConfirmDialog'
23
24
  import { DialogPortal } from '../ui/DialogPortal'
@@ -483,13 +484,14 @@ export function ResourceActionsBar({
483
484
  )}
484
485
 
485
486
  {onDelete && (
486
- <button
487
- onClick={() => setShowDeleteConfirm(true)}
488
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-400 hover:text-white hover:bg-red-600 border border-red-400/50 hover:border-red-600 rounded-lg transition-colors"
489
- >
490
- <Trash2 className="w-3.5 h-3.5" />
491
- Delete
492
- </button>
487
+ <Tooltip content="Delete resource">
488
+ <button
489
+ onClick={() => setShowDeleteConfirm(true)}
490
+ className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-red-400 hover:border-red-400/50 hover:bg-red-500/10 transition-colors"
491
+ >
492
+ <Trash2 className="w-3.5 h-3.5" />
493
+ </button>
494
+ </Tooltip>
493
495
  )}
494
496
 
495
497
  <ForceDeleteConfirmDialog