@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.
- package/package.json +2 -2
- package/src/components/audit/AuditAlerts.tsx +88 -0
- package/src/components/audit/AuditCard.tsx +130 -0
- package/src/components/audit/AuditFindingsTable.tsx +526 -0
- package/src/components/audit/index.ts +3 -0
- package/src/components/dock/DockContext.tsx +5 -2
- package/src/components/dock/LocalTerminalTab.tsx +11 -0
- package/src/components/resources/ResourcesView.tsx +126 -49
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +445 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +160 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +17 -57
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/PodRenderer.tsx +22 -74
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
- package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
- package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
- package/src/components/resources/resource-utils-cnpg.ts +47 -0
- package/src/components/resources/resource-utils-karpenter.ts +6 -0
- package/src/components/resources/resource-utils-prometheus.ts +50 -6
- package/src/components/resources/resource-utils.ts +131 -33
- package/src/components/shared/CreateResourceDialog.tsx +236 -0
- package/src/components/shared/EditableYamlView.tsx +16 -1
- package/src/components/shared/ResourceActionsBar.tsx +9 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -6
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +11 -4
- package/src/components/topology/K8sResourceNode.tsx +15 -0
- package/src/components/topology/TopologyControls.tsx +21 -1
- package/src/components/topology/TopologyGraph.tsx +1 -1
- package/src/components/ui/Badge.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +68 -5
- package/src/components/workload/WorkloadView.tsx +55 -29
- package/src/index.ts +3 -0
- package/src/theme/variables.css +1 -1
- package/src/types/core.ts +2 -1
- package/src/types/gitops.ts +8 -0
- package/src/utils/badge-colors.ts +7 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/k8s-errors.ts +142 -0
- package/src/utils/navigation.test.ts +29 -2
- package/src/utils/navigation.ts +45 -24
- package/src/utils/resource-icons.ts +3 -0
- 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
|
|
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
|
|
111
|
-
const alertCount =
|
|
112
|
-
const recordCount =
|
|
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:
|
|
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 (
|
|
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
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|