@skyhook-io/k8s-ui 1.8.13 → 1.8.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -3
- package/src/components/resources/get-pod-phase-display.test.ts +204 -0
- package/src/components/resources/resource-utils.ts +101 -0
- package/src/components/shared/CreateResourceDialog.tsx +307 -172
- package/src/components/shared/EditableYamlView.tsx +197 -26
- package/src/components/ui/YamlEditor.test.ts +148 -0
- package/src/components/ui/YamlEditor.tsx +759 -186
- package/src/components/ui/YamlReview.test.ts +45 -0
- package/src/components/ui/YamlReview.tsx +426 -0
- package/src/components/ui/index.ts +9 -0
- package/src/components/ui/monacoRuntime.ts +41 -0
- package/src/components/ui/monacoYaml.worker.ts +1 -0
- package/src/components/ui/yamlMonacoRuntime.ts +110 -0
- package/src/components/workload/WorkloadView.tsx +27 -2
- package/src/monaco-deep.d.ts +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/yaml.test.ts +100 -2
- package/src/utils/yaml.ts +86 -1
|
@@ -43,7 +43,11 @@ import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent,
|
|
|
43
43
|
import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
|
|
44
44
|
import { TimelineList } from '../timeline/TimelineList'
|
|
45
45
|
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
EditableYamlView,
|
|
48
|
+
SaveSuccessAnimation,
|
|
49
|
+
type EditableYamlViewProps,
|
|
50
|
+
} from '../shared/EditableYamlView'
|
|
47
51
|
import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
48
52
|
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
49
53
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
@@ -188,11 +192,19 @@ interface WorkloadViewProps {
|
|
|
188
192
|
|
|
189
193
|
// ── Mutations ────────────────────────────────────────────────────────────
|
|
190
194
|
/** Update a resource from YAML */
|
|
191
|
-
onUpdateResource?:
|
|
195
|
+
onUpdateResource?: EditableYamlViewProps['onSave']
|
|
192
196
|
/** Whether the resource is being updated */
|
|
193
197
|
isUpdatingResource?: boolean
|
|
194
198
|
/** Error message from the last update attempt */
|
|
195
199
|
updateResourceError?: string | null
|
|
200
|
+
/** Preview a resource update against Kubernetes before saving. */
|
|
201
|
+
onPreviewResource?: EditableYamlViewProps['onPreview']
|
|
202
|
+
/** Whether the resource preview is being prepared. */
|
|
203
|
+
isPreviewingResource?: boolean
|
|
204
|
+
/** Error message from the last preview attempt. */
|
|
205
|
+
previewResourceError?: string | null
|
|
206
|
+
/** Load cluster schemas for YAML validation and completion. */
|
|
207
|
+
yamlSchemaLoader?: EditableYamlViewProps['schemaLoader']
|
|
196
208
|
|
|
197
209
|
// ── Tab state (optional URL sync) ────────────────────────────────────────
|
|
198
210
|
/** Controlled active tab. If not provided, managed internally. */
|
|
@@ -360,6 +372,10 @@ export function WorkloadView({
|
|
|
360
372
|
onUpdateResource,
|
|
361
373
|
isUpdatingResource,
|
|
362
374
|
updateResourceError,
|
|
375
|
+
onPreviewResource,
|
|
376
|
+
isPreviewingResource,
|
|
377
|
+
previewResourceError,
|
|
378
|
+
yamlSchemaLoader,
|
|
363
379
|
// Tab state
|
|
364
380
|
activeTab: controlledTab,
|
|
365
381
|
onTabChange,
|
|
@@ -572,6 +588,7 @@ export function WorkloadView({
|
|
|
572
588
|
namespace,
|
|
573
589
|
name,
|
|
574
590
|
yaml,
|
|
591
|
+
force: true,
|
|
575
592
|
})
|
|
576
593
|
setTimeout(() => refetch(), 1000)
|
|
577
594
|
} catch {
|
|
@@ -806,6 +823,10 @@ export function WorkloadView({
|
|
|
806
823
|
onSave={onUpdateResource}
|
|
807
824
|
isSaving={isUpdatingResource}
|
|
808
825
|
saveError={updateResourceError}
|
|
826
|
+
onPreview={onPreviewResource}
|
|
827
|
+
isPreviewing={isPreviewingResource}
|
|
828
|
+
previewError={previewResourceError}
|
|
829
|
+
schemaLoader={yamlSchemaLoader}
|
|
809
830
|
onDuplicate={onDuplicate}
|
|
810
831
|
onDownload={onDownload}
|
|
811
832
|
/>
|
|
@@ -1099,6 +1120,10 @@ export function WorkloadView({
|
|
|
1099
1120
|
onSave={onUpdateResource}
|
|
1100
1121
|
isSaving={isUpdatingResource}
|
|
1101
1122
|
saveError={updateResourceError}
|
|
1123
|
+
onPreview={onPreviewResource}
|
|
1124
|
+
isPreviewing={isPreviewingResource}
|
|
1125
|
+
previewError={previewResourceError}
|
|
1126
|
+
schemaLoader={yamlSchemaLoader}
|
|
1102
1127
|
onDuplicate={onDuplicate}
|
|
1103
1128
|
onDownload={onDownload}
|
|
1104
1129
|
/>
|
package/src/types/core.ts
CHANGED
|
@@ -74,11 +74,19 @@ export interface Capabilities {
|
|
|
74
74
|
// doesn't crash against an older backend that hasn't shipped the field yet —
|
|
75
75
|
// consumers should default to { mode: 'local' } when absent.
|
|
76
76
|
deployment?: Deployment
|
|
77
|
+
// Optional because Radar Hub can embed a newer frontend against an older
|
|
78
|
+
// in-cluster Radar agent. Features require an explicit server advertisement.
|
|
79
|
+
features?: FeatureCapabilities
|
|
77
80
|
resources?: ResourcePermissions // Per-resource-type permissions
|
|
78
81
|
authEnabled?: boolean // Auth is enabled on the backend
|
|
79
82
|
username?: string // Authenticated user's username (when auth enabled)
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
export interface FeatureCapabilities {
|
|
86
|
+
yamlReview?: boolean
|
|
87
|
+
yamlSchemas?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
// DeploymentMode is the closed set of topologies Radar can run in.
|
|
83
91
|
// `local` is a developer's machine with a kubeconfig (most OSS use).
|
|
84
92
|
// `in-cluster` is a Radar pod inside the cluster, no kubeconfig.
|
package/src/utils/yaml.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { parse as yamlParse } from 'yaml'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
cleanResourceForYaml,
|
|
5
|
+
normalizeYamlForReview,
|
|
6
|
+
resourceToYaml,
|
|
7
|
+
splitYamlDocuments,
|
|
8
|
+
} from './yaml'
|
|
4
9
|
|
|
5
10
|
function makePod() {
|
|
6
11
|
return {
|
|
@@ -82,7 +87,11 @@ describe('cleanResourceForYaml', () => {
|
|
|
82
87
|
})
|
|
83
88
|
|
|
84
89
|
it('handles metadata present but null', () => {
|
|
85
|
-
const cleaned = cleanResourceForYaml({
|
|
90
|
+
const cleaned = cleanResourceForYaml({
|
|
91
|
+
kind: 'Pod',
|
|
92
|
+
metadata: null,
|
|
93
|
+
spec: {},
|
|
94
|
+
})
|
|
86
95
|
expect(cleaned).toEqual({ kind: 'Pod', metadata: null, spec: {} })
|
|
87
96
|
})
|
|
88
97
|
})
|
|
@@ -99,3 +108,92 @@ describe('resourceToYaml', () => {
|
|
|
99
108
|
expect(yamlParse(yaml)).toEqual(cleaned)
|
|
100
109
|
})
|
|
101
110
|
})
|
|
111
|
+
|
|
112
|
+
describe('splitYamlDocuments', () => {
|
|
113
|
+
it('uses the server document boundaries and drops empty segments', () => {
|
|
114
|
+
expect(
|
|
115
|
+
splitYamlDocuments(`apiVersion: v1
|
|
116
|
+
kind: ConfigMap
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
apiVersion: v1
|
|
121
|
+
kind: Service
|
|
122
|
+
`),
|
|
123
|
+
).toEqual([
|
|
124
|
+
{
|
|
125
|
+
content: 'apiVersion: v1\nkind: ConfigMap',
|
|
126
|
+
startLine: 1,
|
|
127
|
+
schemaIndex: 0,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
content: 'apiVersion: v1\nkind: Service',
|
|
131
|
+
startLine: 5,
|
|
132
|
+
schemaIndex: 2,
|
|
133
|
+
},
|
|
134
|
+
])
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('normalizeYamlForReview', () => {
|
|
139
|
+
it('strips server noise while preserving admitted defaults', () => {
|
|
140
|
+
const normalized = normalizeYamlForReview(`apiVersion: apps/v1
|
|
141
|
+
kind: Deployment
|
|
142
|
+
metadata:
|
|
143
|
+
name: api
|
|
144
|
+
resourceVersion: "42"
|
|
145
|
+
spec:
|
|
146
|
+
replicas: 2
|
|
147
|
+
status:
|
|
148
|
+
availableReplicas: 2
|
|
149
|
+
`)
|
|
150
|
+
expect(normalized).not.toContain('resourceVersion')
|
|
151
|
+
expect(normalized).not.toContain('status:')
|
|
152
|
+
expect(normalized).toContain('replicas: 2')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('masks Secret payloads and annotations on every document', () => {
|
|
156
|
+
const normalized = normalizeYamlForReview(`apiVersion: v1
|
|
157
|
+
kind: Secret
|
|
158
|
+
metadata:
|
|
159
|
+
name: credentials
|
|
160
|
+
annotations:
|
|
161
|
+
token: annotation-secret
|
|
162
|
+
data:
|
|
163
|
+
password: c2VjcmV0
|
|
164
|
+
`)
|
|
165
|
+
expect(normalized).not.toContain('annotation-secret')
|
|
166
|
+
expect(normalized).not.toContain('c2VjcmV0')
|
|
167
|
+
expect(normalized).toContain('<redacted:unchanged>')
|
|
168
|
+
expect(normalized).toContain('password:')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('preserves server-issued differential markers while masking malformed Secret fields', () => {
|
|
172
|
+
const normalized = normalizeYamlForReview(`apiVersion: v1
|
|
173
|
+
kind: Secret
|
|
174
|
+
metadata:
|
|
175
|
+
name: credentials
|
|
176
|
+
annotations: annotation-secret
|
|
177
|
+
data:
|
|
178
|
+
changed: <redacted:after>
|
|
179
|
+
raw: c2VjcmV0
|
|
180
|
+
stringData: plain-secret
|
|
181
|
+
`)
|
|
182
|
+
expect(normalized).toContain('<redacted:after>')
|
|
183
|
+
expect(normalized).toContain('<redacted:unchanged>')
|
|
184
|
+
expect(normalized).not.toContain('annotation-secret')
|
|
185
|
+
expect(normalized).not.toContain('c2VjcmV0')
|
|
186
|
+
expect(normalized).not.toContain('plain-secret')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('never returns an unparseable Secret draft verbatim', () => {
|
|
190
|
+
const normalized = normalizeYamlForReview(`apiVersion: v1
|
|
191
|
+
kind: Secret
|
|
192
|
+
data:
|
|
193
|
+
password: c2VjcmV0
|
|
194
|
+
broken: [
|
|
195
|
+
`)
|
|
196
|
+
expect(normalized).toBe('# YAML could not be normalized safely for review.\n')
|
|
197
|
+
expect(normalized).not.toContain('c2VjcmV0')
|
|
198
|
+
})
|
|
199
|
+
})
|
package/src/utils/yaml.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stringify as yamlStringify } from 'yaml'
|
|
1
|
+
import { parseAllDocuments, stringify as yamlStringify } from 'yaml'
|
|
2
2
|
|
|
3
3
|
const SERVER_GENERATED_METADATA = [
|
|
4
4
|
'managedFields',
|
|
@@ -8,6 +8,40 @@ const SERVER_GENERATED_METADATA = [
|
|
|
8
8
|
'generation',
|
|
9
9
|
] as const
|
|
10
10
|
|
|
11
|
+
export interface YamlDocumentSource {
|
|
12
|
+
content: string
|
|
13
|
+
startLine: number
|
|
14
|
+
schemaIndex: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function splitYamlDocuments(content: string): YamlDocumentSource[] {
|
|
18
|
+
const documents: YamlDocumentSource[] = []
|
|
19
|
+
let lines: string[] = []
|
|
20
|
+
let startLine = 1
|
|
21
|
+
let schemaIndex = 0
|
|
22
|
+
let sawSeparator = false
|
|
23
|
+
|
|
24
|
+
const append = () => {
|
|
25
|
+
const document = lines.join('\n').trim()
|
|
26
|
+
if (document) documents.push({ content: document, startLine, schemaIndex })
|
|
27
|
+
return document !== ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
content.split('\n').forEach((line, index) => {
|
|
31
|
+
if (/^---(?:[ \t]+#.*|[ \t]*)\r?$/.test(line)) {
|
|
32
|
+
const hadContent = append()
|
|
33
|
+
if (sawSeparator || hadContent) schemaIndex += 1
|
|
34
|
+
sawSeparator = true
|
|
35
|
+
lines = []
|
|
36
|
+
startLine = index + 1
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
lines.push(line)
|
|
40
|
+
})
|
|
41
|
+
append()
|
|
42
|
+
return documents
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
export function cleanResourceForYaml<T = any>(data: T): T {
|
|
12
46
|
if (!data || typeof data !== 'object') return data
|
|
13
47
|
const cleaned = structuredClone(data) as any
|
|
@@ -24,3 +58,54 @@ export function resourceToYaml(data: any): string {
|
|
|
24
58
|
if (!data) return ''
|
|
25
59
|
return yamlStringify(cleanResourceForYaml(data), { lineWidth: 0, indent: 2 })
|
|
26
60
|
}
|
|
61
|
+
|
|
62
|
+
export function normalizeYamlForReview(content: string): string {
|
|
63
|
+
try {
|
|
64
|
+
const documents = parseAllDocuments(content)
|
|
65
|
+
if (documents.some((document) => document.errors.length > 0)) {
|
|
66
|
+
return '# YAML could not be normalized safely for review.\n'
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
documents
|
|
70
|
+
.map((document) => {
|
|
71
|
+
const value = document.toJS({ maxAliasCount: 100 })
|
|
72
|
+
const cleaned = cleanResourceForYaml(value) as Record<string, any>
|
|
73
|
+
if (typeof cleaned?.kind === 'string' && cleaned.kind.toLowerCase() === 'secret') {
|
|
74
|
+
for (const field of ['data', 'stringData', 'binaryData']) {
|
|
75
|
+
if (cleaned[field] === undefined) continue
|
|
76
|
+
if (
|
|
77
|
+
!cleaned[field] ||
|
|
78
|
+
typeof cleaned[field] !== 'object' ||
|
|
79
|
+
Array.isArray(cleaned[field])
|
|
80
|
+
) {
|
|
81
|
+
cleaned[field] = safeSecretReviewMarker(cleaned[field])
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(cleaned[field])) {
|
|
85
|
+
cleaned[field][key] = safeSecretReviewMarker(cleaned[field][key])
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const annotations = cleaned.metadata?.annotations
|
|
89
|
+
if (annotations && typeof annotations === 'object' && !Array.isArray(annotations)) {
|
|
90
|
+
for (const key of Object.keys(annotations)) {
|
|
91
|
+
annotations[key] = safeSecretReviewMarker(annotations[key])
|
|
92
|
+
}
|
|
93
|
+
} else if (annotations !== undefined) {
|
|
94
|
+
cleaned.metadata.annotations = safeSecretReviewMarker(annotations)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return yamlStringify(cleaned, { lineWidth: 0, indent: 2 }).trimEnd()
|
|
98
|
+
})
|
|
99
|
+
.join('\n---\n') + '\n'
|
|
100
|
+
)
|
|
101
|
+
} catch {
|
|
102
|
+
return '# YAML could not be normalized safely for review.\n'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function safeSecretReviewMarker(value: unknown) {
|
|
107
|
+
if (typeof value === 'string' && /^<redacted:(unchanged|before|after)>$/.test(value)) {
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
return '<redacted:unchanged>'
|
|
111
|
+
}
|