@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.
@@ -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 { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
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?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
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
  />
@@ -0,0 +1,5 @@
1
+ declare module 'monaco-editor/esm/vs/editor/editor.api' {
2
+ export * from 'monaco-editor'
3
+ }
4
+
5
+ declare module 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
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.
@@ -1,6 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { parse as yamlParse } from 'yaml'
3
- import { cleanResourceForYaml, resourceToYaml } from './yaml'
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({ kind: 'Pod', metadata: null, spec: {} })
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
+ }