@skyhook-io/k8s-ui 1.8.14 → 1.9.0
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 +4 -1
- package/src/components/applications/ApplicationDetail.test.tsx +28 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -0
- package/src/components/audit/AuditAlerts.tsx +13 -11
- package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
- package/src/components/audit/AuditCard.tsx +15 -13
- package/src/components/audit/AuditFindingsTable.tsx +33 -31
- package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
- package/src/components/checks/types.ts +4 -4
- package/src/components/issues/types.ts +4 -0
- package/src/components/resources/ResourcesView.tsx +9 -7
- 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/timeline/TimelineSwimlanes.tsx +28 -15
- package/src/components/timeline/index.ts +0 -2
- package/src/components/timeline/timeline-live.test.ts +0 -37
- package/src/components/timeline/timeline-live.ts +2 -24
- package/src/components/topology/K8sResourceNode.tsx +11 -9
- package/src/components/ui/FilterPill.tsx +3 -1
- 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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RefreshCw,
|
|
7
7
|
Pencil,
|
|
8
8
|
Save,
|
|
9
|
+
Eye,
|
|
9
10
|
XCircle,
|
|
10
11
|
AlertTriangle,
|
|
11
12
|
} from 'lucide-react'
|
|
@@ -13,6 +14,8 @@ import { Download } from 'lucide-react'
|
|
|
13
14
|
import { stringify as yamlStringify } from 'yaml'
|
|
14
15
|
import { CodeViewer } from '../ui/CodeViewer'
|
|
15
16
|
import { YamlEditor } from '../ui/YamlEditor'
|
|
17
|
+
import type { YamlSchemaLoader } from '../ui/YamlEditor'
|
|
18
|
+
import { YamlReview, type YamlPreviewResult } from '../ui/YamlReview'
|
|
16
19
|
import { Tooltip } from '../ui/Tooltip'
|
|
17
20
|
import type { SelectedResource } from '../../types'
|
|
18
21
|
import { resourceToYaml } from '../../utils/yaml'
|
|
@@ -40,20 +43,23 @@ export function SaveSuccessAnimation() {
|
|
|
40
43
|
// ============================================================================
|
|
41
44
|
|
|
42
45
|
// Get edit warning for resource types with limited editability
|
|
43
|
-
function getEditWarning(
|
|
46
|
+
function getEditWarning(
|
|
47
|
+
kind: string,
|
|
48
|
+
): { message: string; tip: string; learnMoreUrl?: string } | null {
|
|
44
49
|
const k = kind.toLowerCase()
|
|
45
50
|
if (k === 'pods' || k === 'pod') {
|
|
46
51
|
return {
|
|
47
52
|
message: 'Pods have limited editability.',
|
|
48
53
|
tip: 'Green highlighted lines can be changed. Edit the parent Deployment instead for other fields.',
|
|
49
|
-
learnMoreUrl:
|
|
54
|
+
learnMoreUrl:
|
|
55
|
+
'https://kubernetes.io/docs/concepts/workloads/pods/#pod-update-and-replacement',
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
if (k === 'jobs' || k === 'job') {
|
|
53
59
|
return {
|
|
54
60
|
message: 'Jobs cannot be modified after creation.',
|
|
55
61
|
tip: 'Delete and recreate the Job to make changes.',
|
|
56
|
-
learnMoreUrl: 'https://kubernetes.io/docs/concepts/workloads/controllers/job/'
|
|
62
|
+
learnMoreUrl: 'https://kubernetes.io/docs/concepts/workloads/controllers/job/',
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
return null
|
|
@@ -77,7 +83,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
77
83
|
const message = errorPart.slice(messageStart, messageEnd).trim()
|
|
78
84
|
return {
|
|
79
85
|
summary: `Cannot update ${target}: ${message}`,
|
|
80
|
-
details: error.length > 200 ? error : undefined
|
|
86
|
+
details: error.length > 200 ? error : undefined,
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
}
|
|
@@ -87,7 +93,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
87
93
|
if (summary) {
|
|
88
94
|
return {
|
|
89
95
|
summary,
|
|
90
|
-
details: error.length > 200 ? error : undefined
|
|
96
|
+
details: error.length > 200 ? error : undefined,
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -95,7 +101,7 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
95
101
|
if (error.length > 150) {
|
|
96
102
|
return {
|
|
97
103
|
summary: error.substring(0, 150) + '...',
|
|
98
|
-
details: error
|
|
104
|
+
details: error,
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -105,16 +111,28 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
105
111
|
// Safe sessionStorage wrappers — storage can throw QuotaExceededError or be
|
|
106
112
|
// blocked by browser security policies. Draft persistence is best-effort.
|
|
107
113
|
function safeSessionGet(key: string): string | null {
|
|
108
|
-
try {
|
|
114
|
+
try {
|
|
115
|
+
return sessionStorage.getItem(key)
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
109
119
|
}
|
|
110
120
|
function safeSessionSet(key: string, value: string): void {
|
|
111
|
-
try {
|
|
121
|
+
try {
|
|
122
|
+
sessionStorage.setItem(key, value)
|
|
123
|
+
} catch {
|
|
124
|
+
/* best-effort */
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
function safeSessionRemove(key: string): void {
|
|
114
|
-
try {
|
|
128
|
+
try {
|
|
129
|
+
sessionStorage.removeItem(key)
|
|
130
|
+
} catch {
|
|
131
|
+
/* best-effort */
|
|
132
|
+
}
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
interface EditableYamlViewProps {
|
|
135
|
+
export interface EditableYamlViewProps {
|
|
118
136
|
resource: SelectedResource
|
|
119
137
|
data: any
|
|
120
138
|
onCopy: (text: string) => void
|
|
@@ -124,11 +142,32 @@ interface EditableYamlViewProps {
|
|
|
124
142
|
/** Called after a successful save so the parent can refetch */
|
|
125
143
|
onSaved?: () => void
|
|
126
144
|
/** Save handler — injected by the platform wrapper */
|
|
127
|
-
onSave?: (params: {
|
|
145
|
+
onSave?: (params: {
|
|
146
|
+
kind: string
|
|
147
|
+
namespace: string
|
|
148
|
+
name: string
|
|
149
|
+
yaml: string
|
|
150
|
+
force: boolean
|
|
151
|
+
reviewedResourceVersion?: string
|
|
152
|
+
reviewedContext?: string
|
|
153
|
+
}) => Promise<void>
|
|
128
154
|
/** Whether a save is in progress */
|
|
129
155
|
isSaving?: boolean
|
|
130
156
|
/** Error message from the last save attempt */
|
|
131
157
|
saveError?: string | null
|
|
158
|
+
onPreview?: (params: {
|
|
159
|
+
yaml: string
|
|
160
|
+
mode: 'update'
|
|
161
|
+
force: boolean
|
|
162
|
+
target: { kind: string; namespace: string; name: string }
|
|
163
|
+
}) => Promise<{
|
|
164
|
+
documents: YamlPreviewResult[]
|
|
165
|
+
nonAtomic: boolean
|
|
166
|
+
context?: string
|
|
167
|
+
}>
|
|
168
|
+
isPreviewing?: boolean
|
|
169
|
+
previewError?: string | null
|
|
170
|
+
schemaLoader?: YamlSchemaLoader
|
|
132
171
|
/** Duplicate handler — opens create dialog with this resource's YAML */
|
|
133
172
|
onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
|
|
134
173
|
/**
|
|
@@ -138,7 +177,23 @@ interface EditableYamlViewProps {
|
|
|
138
177
|
onDownload?: (content: string, mime: string, filename: string) => void
|
|
139
178
|
}
|
|
140
179
|
|
|
141
|
-
export function EditableYamlView({
|
|
180
|
+
export function EditableYamlView({
|
|
181
|
+
resource,
|
|
182
|
+
data,
|
|
183
|
+
onCopy,
|
|
184
|
+
copied,
|
|
185
|
+
readOnly = false,
|
|
186
|
+
onSaved,
|
|
187
|
+
onSave,
|
|
188
|
+
isSaving,
|
|
189
|
+
saveError,
|
|
190
|
+
onPreview,
|
|
191
|
+
isPreviewing,
|
|
192
|
+
previewError,
|
|
193
|
+
schemaLoader,
|
|
194
|
+
onDuplicate,
|
|
195
|
+
onDownload,
|
|
196
|
+
}: EditableYamlViewProps) {
|
|
142
197
|
const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
|
|
143
198
|
|
|
144
199
|
// Restore draft from sessionStorage (e.g., after session-expiry redirect).
|
|
@@ -152,6 +207,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
152
207
|
// Default on: the editor resubmits the full live manifest, so an unforced
|
|
153
208
|
// save would conflict on every field owned by Helm/Flux/Argo/a controller.
|
|
154
209
|
const [force, setForce] = useState(true)
|
|
210
|
+
const [preview, setPreview] = useState<{
|
|
211
|
+
yaml: string
|
|
212
|
+
force: boolean
|
|
213
|
+
originalYaml: string
|
|
214
|
+
documents: YamlPreviewResult[]
|
|
215
|
+
nonAtomic: boolean
|
|
216
|
+
context?: string
|
|
217
|
+
} | null>(null)
|
|
155
218
|
|
|
156
219
|
// Clean up restored draft flag
|
|
157
220
|
useEffect(() => {
|
|
@@ -190,6 +253,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
190
253
|
if (readOnly) return
|
|
191
254
|
setEditedYaml(resourceToYaml(data))
|
|
192
255
|
setYamlErrors([])
|
|
256
|
+
setPreview(null)
|
|
193
257
|
setIsEditing(true)
|
|
194
258
|
}, [data, readOnly])
|
|
195
259
|
|
|
@@ -197,12 +261,34 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
197
261
|
setIsEditing(false)
|
|
198
262
|
setEditedYaml('')
|
|
199
263
|
setYamlErrors([])
|
|
264
|
+
setPreview(null)
|
|
200
265
|
}, [])
|
|
201
266
|
|
|
202
267
|
const handleSaveEdit = useCallback(async () => {
|
|
203
268
|
if (yamlErrors.length > 0 || !onSave) return
|
|
204
269
|
|
|
205
270
|
try {
|
|
271
|
+
if (onPreview) {
|
|
272
|
+
const result = await onPreview({
|
|
273
|
+
yaml: editedYaml,
|
|
274
|
+
mode: 'update',
|
|
275
|
+
force,
|
|
276
|
+
target: {
|
|
277
|
+
kind: resource.kind,
|
|
278
|
+
namespace: resource.namespace,
|
|
279
|
+
name: resource.name,
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
setPreview({
|
|
283
|
+
yaml: editedYaml,
|
|
284
|
+
force,
|
|
285
|
+
originalYaml: resourceToYaml(data),
|
|
286
|
+
documents: result.documents,
|
|
287
|
+
nonAtomic: result.nonAtomic,
|
|
288
|
+
context: result.context,
|
|
289
|
+
})
|
|
290
|
+
return
|
|
291
|
+
}
|
|
206
292
|
await onSave({
|
|
207
293
|
kind: resource.kind,
|
|
208
294
|
namespace: resource.namespace,
|
|
@@ -216,7 +302,49 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
216
302
|
} catch {
|
|
217
303
|
// Error handled by caller via saveError prop
|
|
218
304
|
}
|
|
219
|
-
}, [onSave, resource, editedYaml, yamlErrors, onSaved, force])
|
|
305
|
+
}, [onSave, onPreview, resource, data, editedYaml, yamlErrors, onSaved, force])
|
|
306
|
+
|
|
307
|
+
const handleApplyReviewed = useCallback(async () => {
|
|
308
|
+
if (!preview || !onSave) return
|
|
309
|
+
try {
|
|
310
|
+
await onSave({
|
|
311
|
+
kind: resource.kind,
|
|
312
|
+
namespace: resource.namespace,
|
|
313
|
+
name: resource.name,
|
|
314
|
+
yaml: preview.yaml,
|
|
315
|
+
force: preview.force,
|
|
316
|
+
reviewedResourceVersion: preview.documents[0]?.reviewedResourceVersion,
|
|
317
|
+
reviewedContext: preview.context,
|
|
318
|
+
})
|
|
319
|
+
setPreview(null)
|
|
320
|
+
setIsEditing(false)
|
|
321
|
+
setEditedYaml('')
|
|
322
|
+
setTimeout(() => onSaved?.(), 1000)
|
|
323
|
+
} catch {
|
|
324
|
+
if (onPreview) {
|
|
325
|
+
try {
|
|
326
|
+
const refreshed = await onPreview({
|
|
327
|
+
yaml: preview.yaml,
|
|
328
|
+
mode: 'update',
|
|
329
|
+
force: preview.force,
|
|
330
|
+
target: {
|
|
331
|
+
kind: resource.kind,
|
|
332
|
+
namespace: resource.namespace,
|
|
333
|
+
name: resource.name,
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
setPreview({
|
|
337
|
+
...preview,
|
|
338
|
+
documents: refreshed.documents,
|
|
339
|
+
nonAtomic: refreshed.nonAtomic,
|
|
340
|
+
context: refreshed.context,
|
|
341
|
+
})
|
|
342
|
+
} catch {
|
|
343
|
+
// Keep the last review visible when refresh is unavailable.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}, [preview, onSave, onPreview, resource, onSaved])
|
|
220
348
|
|
|
221
349
|
const handleYamlValidate = useCallback((_isValid: boolean, errors: string[]) => {
|
|
222
350
|
setYamlErrors(errors)
|
|
@@ -225,9 +353,24 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
225
353
|
const yamlContent = yamlStringify(data, { lineWidth: 0, indent: 2 })
|
|
226
354
|
const editWarning = getEditWarning(resource.kind)
|
|
227
355
|
const formattedError = saveError ? formatSaveError(saveError) : null
|
|
228
|
-
const isPending = isSaving ?? false
|
|
356
|
+
const isPending = (isSaving ?? false) || (isPreviewing ?? false)
|
|
229
357
|
|
|
230
358
|
if (isEditing) {
|
|
359
|
+
if (preview) {
|
|
360
|
+
return (
|
|
361
|
+
<YamlReview
|
|
362
|
+
submittedYaml={preview.yaml}
|
|
363
|
+
originalYaml={preview.originalYaml}
|
|
364
|
+
documents={preview.documents}
|
|
365
|
+
nonAtomic={preview.nonAtomic}
|
|
366
|
+
force={preview.force}
|
|
367
|
+
isApplying={isSaving}
|
|
368
|
+
applyError={saveError}
|
|
369
|
+
onBack={() => setPreview(null)}
|
|
370
|
+
onApply={handleApplyReviewed}
|
|
371
|
+
/>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
231
374
|
return (
|
|
232
375
|
<div className="flex flex-col h-full">
|
|
233
376
|
<div className="flex items-center justify-between px-4 py-2 border-b border-theme-border bg-theme-elevated/50">
|
|
@@ -249,10 +392,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
249
392
|
<div className="space-y-1.5 max-w-[19rem]">
|
|
250
393
|
<p>Override field ownership (server-side apply).</p>
|
|
251
394
|
<p>
|
|
252
|
-
<span className="font-medium text-theme-text-primary">On (default):</span> your
|
|
395
|
+
<span className="font-medium text-theme-text-primary">On (default):</span> your
|
|
396
|
+
edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active
|
|
397
|
+
controller may reconcile them back on its next sync.
|
|
253
398
|
</p>
|
|
254
399
|
<p>
|
|
255
|
-
<span className="font-medium text-theme-text-primary">Off:</span> if another
|
|
400
|
+
<span className="font-medium text-theme-text-primary">Off:</span> if another
|
|
401
|
+
manager owns a field you changed, the whole save is rejected with a conflict
|
|
402
|
+
(nothing is applied).
|
|
256
403
|
</p>
|
|
257
404
|
</div>
|
|
258
405
|
}
|
|
@@ -276,10 +423,18 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
276
423
|
>
|
|
277
424
|
{isPending ? (
|
|
278
425
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
426
|
+
) : onPreview ? (
|
|
427
|
+
<Eye className="w-3.5 h-3.5" />
|
|
279
428
|
) : (
|
|
280
429
|
<Save className="w-3.5 h-3.5" />
|
|
281
430
|
)}
|
|
282
|
-
{
|
|
431
|
+
{isPreviewing
|
|
432
|
+
? 'Preparing review…'
|
|
433
|
+
: isSaving
|
|
434
|
+
? 'Saving…'
|
|
435
|
+
: onPreview
|
|
436
|
+
? 'Review changes'
|
|
437
|
+
: 'Save'}
|
|
283
438
|
</button>
|
|
284
439
|
</div>
|
|
285
440
|
</div>
|
|
@@ -289,8 +444,12 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
289
444
|
<div className="flex items-start gap-2">
|
|
290
445
|
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-yellow-300 mt-0.5 shrink-0" />
|
|
291
446
|
<div className="text-xs">
|
|
292
|
-
<span className="font-medium text-amber-700 dark:text-yellow-300">
|
|
293
|
-
|
|
447
|
+
<span className="font-medium text-amber-700 dark:text-yellow-300">
|
|
448
|
+
{editWarning.message}
|
|
449
|
+
</span>
|
|
450
|
+
<span className="text-amber-600 dark:text-yellow-300/80 ml-1">
|
|
451
|
+
{editWarning.tip}
|
|
452
|
+
</span>
|
|
294
453
|
{editWarning.learnMoreUrl && (
|
|
295
454
|
<a
|
|
296
455
|
href={editWarning.learnMoreUrl}
|
|
@@ -319,14 +478,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
319
478
|
</div>
|
|
320
479
|
)}
|
|
321
480
|
|
|
322
|
-
{formattedError && (
|
|
481
|
+
{(formattedError || previewError) && (
|
|
323
482
|
<div className="px-4 py-2 bg-red-500/10 border-b border-red-300 dark:border-red-500/30">
|
|
324
483
|
<div className="flex items-start gap-2">
|
|
325
484
|
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-300 mt-0.5 shrink-0" />
|
|
326
485
|
<div className="text-xs text-red-600 dark:text-red-300 flex-1">
|
|
327
|
-
<div className="font-medium">Save failed</div>
|
|
328
|
-
<div className="mt-1">{formattedError
|
|
329
|
-
{formattedError
|
|
486
|
+
<div className="font-medium">{previewError ? 'Preview failed' : 'Save failed'}</div>
|
|
487
|
+
<div className="mt-1">{previewError || formattedError?.summary}</div>
|
|
488
|
+
{formattedError?.details && (
|
|
330
489
|
<button
|
|
331
490
|
onClick={() => setShowErrorDetails(!showErrorDetails)}
|
|
332
491
|
className="mt-1 text-red-500 dark:text-red-300 hover:text-red-700 dark:hover:text-red-200 underline"
|
|
@@ -334,7 +493,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
334
493
|
{showErrorDetails ? 'Hide details' : 'Show details'}
|
|
335
494
|
</button>
|
|
336
495
|
)}
|
|
337
|
-
{showErrorDetails && formattedError
|
|
496
|
+
{showErrorDetails && formattedError?.details && (
|
|
338
497
|
<pre className="mt-2 p-2 bg-red-500/10 rounded text-[10px] whitespace-pre-wrap break-all max-h-40 overflow-auto">
|
|
339
498
|
{formattedError.details}
|
|
340
499
|
</pre>
|
|
@@ -349,6 +508,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
349
508
|
value={editedYaml}
|
|
350
509
|
onChange={setEditedYaml}
|
|
351
510
|
onValidate={handleYamlValidate}
|
|
511
|
+
schemaLoader={schemaLoader}
|
|
352
512
|
height="100%"
|
|
353
513
|
kind={resource.kind}
|
|
354
514
|
/>
|
|
@@ -376,7 +536,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
376
536
|
onClick={() => onCopy(yamlContent)}
|
|
377
537
|
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"
|
|
378
538
|
>
|
|
379
|
-
{copied ?
|
|
539
|
+
{copied ? (
|
|
540
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
541
|
+
) : (
|
|
542
|
+
<Copy className="w-3.5 h-3.5" />
|
|
543
|
+
)}
|
|
380
544
|
Copy
|
|
381
545
|
</button>
|
|
382
546
|
<Tooltip content="Download manifest as YAML (server-generated fields stripped)">
|
|
@@ -391,7 +555,14 @@ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = fa
|
|
|
391
555
|
{onDuplicate && (
|
|
392
556
|
<Tooltip content="Duplicate as new resource">
|
|
393
557
|
<button
|
|
394
|
-
onClick={() =>
|
|
558
|
+
onClick={() =>
|
|
559
|
+
onDuplicate({
|
|
560
|
+
kind: resource.kind,
|
|
561
|
+
namespace: resource.namespace,
|
|
562
|
+
name: resource.name,
|
|
563
|
+
yaml: yamlContent,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
395
566
|
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"
|
|
396
567
|
>
|
|
397
568
|
<CopyPlus className="w-3.5 h-3.5" />
|
|
@@ -1077,16 +1077,29 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1077
1077
|
return { start, end, windowMs, now: effectiveNow }
|
|
1078
1078
|
}, [zoom, panOffset, effectiveNow, viewWindow])
|
|
1079
1079
|
|
|
1080
|
+
// The visible window WITHOUT the live-`now` field. Keyed on the primitive
|
|
1081
|
+
// bounds so its identity changes only when the window actually moves — a live
|
|
1082
|
+
// `now` tick (30s) advances `visibleTimeRange.now` for the now-line but must
|
|
1083
|
+
// NOT invalidate the lane-ordering / window-clip memos below, which only care
|
|
1084
|
+
// about [start,end]. This stops the heavy build from re-running on each tick
|
|
1085
|
+
// while the window is static — the controlled retained lens or a paused view.
|
|
1086
|
+
// (In uncontrolled live mode start/end track `effectiveNow`, so they advance
|
|
1087
|
+
// every tick regardless; the win is for the static-window cases this targets.)
|
|
1088
|
+
const visibleWindow = useMemo(
|
|
1089
|
+
() => ({ start: visibleTimeRange.start, end: visibleTimeRange.end, windowMs: visibleTimeRange.windowMs }),
|
|
1090
|
+
[visibleTimeRange.start, visibleTimeRange.end, visibleTimeRange.windowMs],
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1080
1093
|
// Apply the chosen ordering to the top-level lanes. 'recent' needs the visible
|
|
1081
1094
|
// window (newest-in-view first), so this lives after visibleTimeRange. The
|
|
1082
1095
|
// pinned section is ordered separately (strict pin order) and never flows here.
|
|
1083
1096
|
const orderedLanes = useMemo(
|
|
1084
1097
|
() => sortTimelineLanes(lanes, sort, {
|
|
1085
|
-
windowStart:
|
|
1086
|
-
windowEnd:
|
|
1098
|
+
windowStart: visibleWindow.start,
|
|
1099
|
+
windowEnd: visibleWindow.end,
|
|
1087
1100
|
scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
|
|
1088
1101
|
}),
|
|
1089
|
-
[lanes, sort,
|
|
1102
|
+
[lanes, sort, visibleWindow],
|
|
1090
1103
|
)
|
|
1091
1104
|
|
|
1092
1105
|
// Live-mode order hysteresis. The importance rank recomputes every poll (recency
|
|
@@ -1147,17 +1160,17 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1147
1160
|
// "events" count so it's view-scoped like "resources" — a lens sitting in a
|
|
1148
1161
|
// recording gap reads "0 resources · 0 events", not "0 resources · N events".
|
|
1149
1162
|
const eventsInWindow = useMemo(() => {
|
|
1150
|
-
const { start, end } =
|
|
1163
|
+
const { start, end } = visibleWindow
|
|
1151
1164
|
return filteredEvents.filter((e) => {
|
|
1152
1165
|
const t = new Date(e.timestamp).getTime()
|
|
1153
1166
|
return t >= start && t <= end
|
|
1154
1167
|
})
|
|
1155
|
-
}, [filteredEvents,
|
|
1168
|
+
}, [filteredEvents, visibleWindow])
|
|
1156
1169
|
|
|
1157
1170
|
// Recording gaps clipped to the visible window, for the hatched lane bands.
|
|
1158
1171
|
const visibleGaps = useMemo(() => {
|
|
1159
1172
|
if (!gaps || gaps.length === 0) return []
|
|
1160
|
-
const { start, end } =
|
|
1173
|
+
const { start, end } = visibleWindow
|
|
1161
1174
|
const out: TimeWindow[] = []
|
|
1162
1175
|
for (const g of gaps) {
|
|
1163
1176
|
const fromMs = Math.max(g.fromMs, start)
|
|
@@ -1165,7 +1178,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1165
1178
|
if (toMs > fromMs) out.push({ fromMs, toMs })
|
|
1166
1179
|
}
|
|
1167
1180
|
return out
|
|
1168
|
-
}, [gaps,
|
|
1181
|
+
}, [gaps, visibleWindow])
|
|
1169
1182
|
|
|
1170
1183
|
// Pin MOVES a row: pinned lanes (and pinned children inside groups) leave
|
|
1171
1184
|
// the regular list entirely — the pinned section is their only home.
|
|
@@ -1181,7 +1194,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1181
1194
|
|
|
1182
1195
|
// Filter out lanes with no events in the visible time window
|
|
1183
1196
|
const visibleLanes = useMemo(() => {
|
|
1184
|
-
const { start, end } =
|
|
1197
|
+
const { start, end } = visibleWindow
|
|
1185
1198
|
return unpinnedLanes.filter(lane => {
|
|
1186
1199
|
const allLaneEvents = lane.allEventsSorted || []
|
|
1187
1200
|
return allLaneEvents.some(e => {
|
|
@@ -1189,7 +1202,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1189
1202
|
return t >= start && t <= end
|
|
1190
1203
|
})
|
|
1191
1204
|
})
|
|
1192
|
-
}, [unpinnedLanes,
|
|
1205
|
+
}, [unpinnedLanes, visibleWindow])
|
|
1193
1206
|
|
|
1194
1207
|
// Pinned rows: resolved from the FULL lane list (pre-window-filter, any
|
|
1195
1208
|
// grouping) so they stay put while the lens moves and even when they have no
|
|
@@ -1203,7 +1216,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1203
1216
|
// Honest counts while filtered to pins: only pinned rows' in-window events.
|
|
1204
1217
|
const pinnedEventsInWindow = useMemo(() => {
|
|
1205
1218
|
if (pinnedLaneRows.length === 0) return 0
|
|
1206
|
-
const { start, end } =
|
|
1219
|
+
const { start, end } = visibleWindow
|
|
1207
1220
|
let n = 0
|
|
1208
1221
|
for (const lane of pinnedLaneRows) {
|
|
1209
1222
|
for (const e of lane.allEventsSorted || []) {
|
|
@@ -1212,7 +1225,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1212
1225
|
}
|
|
1213
1226
|
}
|
|
1214
1227
|
return n
|
|
1215
|
-
}, [pinnedLaneRows,
|
|
1228
|
+
}, [pinnedLaneRows, visibleWindow])
|
|
1216
1229
|
|
|
1217
1230
|
const pinnedIdSet = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
|
|
1218
1231
|
// A pin button for a lane, or null when the host wired no pin handler. A pinned
|
|
@@ -1236,7 +1249,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1236
1249
|
|
|
1237
1250
|
// Generate time axis ticks
|
|
1238
1251
|
const axisTicks = useMemo(() => {
|
|
1239
|
-
const { start, end } =
|
|
1252
|
+
const { start, end } = visibleWindow
|
|
1240
1253
|
const ticks: { time: number; label: string }[] = []
|
|
1241
1254
|
const span = end - start
|
|
1242
1255
|
if (span <= 0) return ticks
|
|
@@ -1262,18 +1275,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1262
1275
|
}
|
|
1263
1276
|
|
|
1264
1277
|
return ticks
|
|
1265
|
-
}, [
|
|
1278
|
+
}, [visibleWindow])
|
|
1266
1279
|
|
|
1267
1280
|
// Convert timestamp to X position (0-100%)
|
|
1268
1281
|
const timeToX = useCallback(
|
|
1269
1282
|
(timestamp: number): number => {
|
|
1270
|
-
const { start, windowMs } =
|
|
1283
|
+
const { start, windowMs } = visibleWindow
|
|
1271
1284
|
// A zero-width window (host bounds with fromMs === toMs) would divide by
|
|
1272
1285
|
// zero and emit NaN into `left:` CSS — pin everything to the left edge.
|
|
1273
1286
|
if (windowMs <= 0) return 0
|
|
1274
1287
|
return ((timestamp - start) / windowMs) * 100
|
|
1275
1288
|
},
|
|
1276
|
-
[
|
|
1289
|
+
[visibleWindow]
|
|
1277
1290
|
)
|
|
1278
1291
|
|
|
1279
1292
|
// Vertical grid line positions (x-percent) shared by every lane backdrop.
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
-
BASE_QUANTIZE_STEP_MS,
|
|
4
3
|
LENS_LATCH_EPSILON_MS,
|
|
5
4
|
STALE_AMBER_AFTER_MS,
|
|
6
5
|
advanceLatchedLens,
|
|
7
6
|
deriveLiveSelection,
|
|
8
7
|
isLensLatched,
|
|
9
|
-
quantizeBaseWindow,
|
|
10
8
|
} from './timeline-live'
|
|
11
9
|
import { clampSelection, type ScrubberRange } from './scrubber-math'
|
|
12
10
|
|
|
@@ -20,41 +18,6 @@ describe('deriveLiveSelection', () => {
|
|
|
20
18
|
})
|
|
21
19
|
})
|
|
22
20
|
|
|
23
|
-
describe('quantizeBaseWindow', () => {
|
|
24
|
-
it('floors both edges to the step', () => {
|
|
25
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
26
|
-
const q = quantizeBaseWindow(step * 3 + 12_345, step * 10 + 4_000, step)
|
|
27
|
-
expect(q).toEqual({ fromMs: step * 3, toMs: step * 10 })
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('is stable across two ticks inside the same step', () => {
|
|
31
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
32
|
-
const base = step * 100
|
|
33
|
-
// Two "now" values 30s apart but inside the same 5-minute step.
|
|
34
|
-
const a = quantizeBaseWindow(base - HOUR + 10_000, base + 10_000, step)
|
|
35
|
-
const b = quantizeBaseWindow(base - HOUR + 40_000, base + 40_000, step)
|
|
36
|
-
expect(a).toEqual(b)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('advances once the edge crosses a step boundary', () => {
|
|
40
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
41
|
-
const before = quantizeBaseWindow(0, step - 1, step)
|
|
42
|
-
const after = quantizeBaseWindow(0, step + 1, step)
|
|
43
|
-
expect(before.toMs).toBe(0)
|
|
44
|
-
expect(after.toMs).toBe(step)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('trailing seam never exceeds one step (covered by the 10-min live poll)', () => {
|
|
48
|
-
const step = BASE_QUANTIZE_STEP_MS
|
|
49
|
-
const now = step * 42 + 137_000
|
|
50
|
-
const q = quantizeBaseWindow(now - HOUR, now, step)
|
|
51
|
-
// Gap between the quantized right edge and now is < one step, and one step
|
|
52
|
-
// (5min) is well inside the 10-min live poll window ⇒ no data hole.
|
|
53
|
-
expect(now - q.toMs).toBeLessThan(step)
|
|
54
|
-
expect(step).toBeLessThan(10 * MIN)
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
|
|
58
21
|
describe('isLensLatched', () => {
|
|
59
22
|
const sel: ScrubberRange = { fromMs: 0, toMs: 100 * HOUR }
|
|
60
23
|
|
|
@@ -18,8 +18,8 @@ export type TimelineLiveState =
|
|
|
18
18
|
| { kind: 'frozen'; asOfMs: number; newEventCount?: number }
|
|
19
19
|
|
|
20
20
|
// Cadence the host slides a live selection at. Coarse on purpose: the precise
|
|
21
|
-
// [from,to] is re-derived every tick
|
|
22
|
-
//
|
|
21
|
+
// [from,to] is re-derived every tick without refetching — data arrives via the
|
|
22
|
+
// source's own delta polling, so the tick only moves the visible window.
|
|
23
23
|
export const LIVE_TICK_MS = 30_000
|
|
24
24
|
|
|
25
25
|
// A frozen selection older than this reads as stale — the chip turns amber.
|
|
@@ -30,33 +30,11 @@ export const STALE_AMBER_AFTER_MS = 15 * 60_000
|
|
|
30
30
|
// LIVE_TICK_MS so a lens that just slid with the tick still reads as latched.
|
|
31
31
|
export const LENS_LATCH_EPSILON_MS = 60_000
|
|
32
32
|
|
|
33
|
-
// Base-fetch quantization step. The react-query key is keyed on the quantized
|
|
34
|
-
// window, so it only changes when the live edge crosses a 5-minute boundary —
|
|
35
|
-
// the seam between the quantized edge and now is covered by the 10-minute live
|
|
36
|
-
// poll (5min quantization lag < 10min poll window ⇒ no hole).
|
|
37
|
-
export const BASE_QUANTIZE_STEP_MS = 5 * 60_000
|
|
38
|
-
|
|
39
33
|
/** LIVE selection: a width pinned to now. */
|
|
40
34
|
export function deriveLiveSelection(widthMs: number, nowMs: number): ScrubberRange {
|
|
41
35
|
return { fromMs: nowMs - widthMs, toMs: nowMs }
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
/**
|
|
45
|
-
* Quantize a sliding [from,to] down to fixed steps so the value is stable across
|
|
46
|
-
* ticks within one step (identical output ⇒ stable react-query key). Both edges
|
|
47
|
-
* floor to the step; the trailing seam to `now` is filled by the live poll.
|
|
48
|
-
*/
|
|
49
|
-
export function quantizeBaseWindow(
|
|
50
|
-
fromMs: number,
|
|
51
|
-
toMs: number,
|
|
52
|
-
stepMs: number = BASE_QUANTIZE_STEP_MS,
|
|
53
|
-
): ScrubberRange {
|
|
54
|
-
return {
|
|
55
|
-
fromMs: Math.floor(fromMs / stepMs) * stepMs,
|
|
56
|
-
toMs: Math.floor(toMs / stepMs) * stepMs,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
38
|
/**
|
|
61
39
|
* True when the lens rides the selection's live edge (so it should slide with
|
|
62
40
|
* the tick). A lens the user dragged into the past sits further than epsilon
|