@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.
Files changed (36) hide show
  1. package/README.md +9 -0
  2. package/package.json +4 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +28 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -0
  5. package/src/components/audit/AuditAlerts.tsx +13 -11
  6. package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
  7. package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
  8. package/src/components/audit/AuditCard.tsx +15 -13
  9. package/src/components/audit/AuditFindingsTable.tsx +33 -31
  10. package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
  11. package/src/components/checks/types.ts +4 -4
  12. package/src/components/issues/types.ts +4 -0
  13. package/src/components/resources/ResourcesView.tsx +9 -7
  14. package/src/components/resources/get-pod-phase-display.test.ts +204 -0
  15. package/src/components/resources/resource-utils.ts +101 -0
  16. package/src/components/shared/CreateResourceDialog.tsx +307 -172
  17. package/src/components/shared/EditableYamlView.tsx +197 -26
  18. package/src/components/timeline/TimelineSwimlanes.tsx +28 -15
  19. package/src/components/timeline/index.ts +0 -2
  20. package/src/components/timeline/timeline-live.test.ts +0 -37
  21. package/src/components/timeline/timeline-live.ts +2 -24
  22. package/src/components/topology/K8sResourceNode.tsx +11 -9
  23. package/src/components/ui/FilterPill.tsx +3 -1
  24. package/src/components/ui/YamlEditor.test.ts +148 -0
  25. package/src/components/ui/YamlEditor.tsx +759 -186
  26. package/src/components/ui/YamlReview.test.ts +45 -0
  27. package/src/components/ui/YamlReview.tsx +426 -0
  28. package/src/components/ui/index.ts +9 -0
  29. package/src/components/ui/monacoRuntime.ts +41 -0
  30. package/src/components/ui/monacoYaml.worker.ts +1 -0
  31. package/src/components/ui/yamlMonacoRuntime.ts +110 -0
  32. package/src/components/workload/WorkloadView.tsx +27 -2
  33. package/src/monaco-deep.d.ts +5 -0
  34. package/src/types/core.ts +8 -0
  35. package/src/utils/yaml.test.ts +100 -2
  36. 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(kind: string): { message: string; tip: string; learnMoreUrl?: string } | null {
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: 'https://kubernetes.io/docs/concepts/workloads/pods/#pod-update-and-replacement'
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 { return sessionStorage.getItem(key) } catch { return null }
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 { sessionStorage.setItem(key, value) } catch { /* best-effort */ }
121
+ try {
122
+ sessionStorage.setItem(key, value)
123
+ } catch {
124
+ /* best-effort */
125
+ }
112
126
  }
113
127
  function safeSessionRemove(key: string): void {
114
- try { sessionStorage.removeItem(key) } catch { /* best-effort */ }
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: { kind: string; namespace: string; name: string; yaml: string; force: boolean }) => Promise<void>
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({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
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 edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active controller may reconcile them back on its next sync.
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 manager owns a field you changed, the whole save is rejected with a conflict (nothing is applied).
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
- {isPending ? 'Saving...' : 'Save'}
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">{editWarning.message}</span>
293
- <span className="text-amber-600 dark:text-yellow-300/80 ml-1">{editWarning.tip}</span>
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.summary}</div>
329
- {formattedError.details && (
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.details && (
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 ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
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={() => onDuplicate({ kind: resource.kind, namespace: resource.namespace, name: resource.name, yaml: yamlContent })}
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: visibleTimeRange.start,
1086
- windowEnd: visibleTimeRange.end,
1098
+ windowStart: visibleWindow.start,
1099
+ windowEnd: visibleWindow.end,
1087
1100
  scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
1088
1101
  }),
1089
- [lanes, sort, visibleTimeRange],
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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, visibleTimeRange])
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 } = visibleTimeRange
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
- }, [visibleTimeRange])
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 } = visibleTimeRange
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
- [visibleTimeRange]
1289
+ [visibleWindow]
1277
1290
  )
1278
1291
 
1279
1292
  // Vertical grid line positions (x-percent) shared by every lane backdrop.
@@ -20,8 +20,6 @@ export {
20
20
  advanceLatchedLens,
21
21
  deriveLiveSelection,
22
22
  isLensLatched,
23
- quantizeBaseWindow,
24
23
  LIVE_TICK_MS,
25
- BASE_QUANTIZE_STEP_MS,
26
24
  type TimelineLiveState,
27
25
  } from './timeline-live'
@@ -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, but the base fetch is quantized so the
22
- // query key only churns every QUANTIZE step (see quantizeBaseWindow).
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