@skyhook-io/radar-app 0.2.2 → 0.3.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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,17 +1,24 @@
1
1
  import { type ComponentProps } from 'react'
2
2
  import { CreateResourceDialog as BaseCreateResourceDialog } from '@skyhook-io/k8s-ui'
3
- import { useApplyResource } from '../../api/client'
3
+ import { fetchYamlSchemas, useApplyResource, usePreviewResources } from '../../api/client'
4
+ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
4
5
 
5
6
  type BaseProps = ComponentProps<typeof BaseCreateResourceDialog>
6
7
 
7
- export function CreateResourceDialog(props: Omit<BaseProps, 'onApply' | 'isApplying'>) {
8
+ export function CreateResourceDialog(props: Omit<BaseProps, 'onApply' | 'isApplying' | 'onPreview' | 'isPreviewing' | 'previewError' | 'schemaLoader'>) {
8
9
  const applyResource = useApplyResource()
10
+ const previewResources = usePreviewResources()
11
+ const { features } = useCapabilitiesContext()
9
12
 
10
13
  return (
11
14
  <BaseCreateResourceDialog
12
15
  {...props}
13
16
  onApply={(params) => applyResource.mutateAsync(params)}
14
17
  isApplying={applyResource.isPending}
18
+ onPreview={features?.yamlReview ? (params) => previewResources.mutateAsync(params) : undefined}
19
+ isPreviewing={previewResources.isPending}
20
+ previewError={previewResources.error?.message ?? null}
21
+ schemaLoader={features?.yamlSchemas ? fetchYamlSchemas : undefined}
15
22
  />
16
23
  )
17
24
  }
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useEffect, useMemo } from 'react'
2
2
  import { Search } from 'lucide-react'
3
+ import { Input } from '@skyhook-io/k8s-ui'
3
4
 
4
5
  export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
5
6
  namespaces: { name: string }[] | undefined
@@ -27,9 +28,8 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
27
28
  <div className="text-left">
28
29
  <div className="relative mb-2">
29
30
  <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
30
- <input
31
+ <Input
31
32
  ref={inputRef}
32
- type="text"
33
33
  value={search}
34
34
  onChange={(e) => setSearch(e.target.value)}
35
35
  placeholder="Search namespaces..."
@@ -39,7 +39,7 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
39
39
  <div className="max-h-[240px] overflow-y-auto rounded-lg border border-theme-border bg-theme-base">
40
40
  {!namespaces ? (
41
41
  <div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
42
- Loading namespaces...
42
+ Loading namespaces
43
43
  </div>
44
44
  ) : filtered.length === 0 ? (
45
45
  <div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
@@ -0,0 +1,212 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ TimelineStrip,
4
+ clampSelection,
5
+ countEventsAfter,
6
+ pickDisplayBucketSizeMs,
7
+ presetToSelection,
8
+ type ScrubberPreset,
9
+ type ScrubberRange,
10
+ type TimelineLiveState,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import type { TimelineEvent } from '../../types'
13
+ import { localOverviewFromEvents } from '../../api/timelineSource'
14
+ import { groupBuckets, buildPresets, type ScrubberDomainInfo } from './RetainedTimelineScrubber'
15
+
16
+ const HOUR_MS = 60 * 60 * 1000
17
+ const MINUTE_MS = 60_000
18
+ // Cap on how many bars the strip draws for the full query, so window-driven fine
19
+ // bucketing can't explode into thousands of divs on a wide query + tiny window.
20
+ // Generous (thin bars are cheap) so even a 24h query keeps ~3min bars — fine
21
+ // enough that a narrow window spans many, and an empty window shows none.
22
+ const MAX_STRIP_BARS = 512
23
+
24
+ interface LocalTimelineScrubberProps {
25
+ // The loaded event ring. The local store ships the whole ring to the browser,
26
+ // so the histogram + domain are derived from it entirely client-side.
27
+ events: TimelineEvent[]
28
+ loading?: boolean
29
+ selection: ScrubberRange
30
+ onSelectionChange: (sel: ScrubberRange) => void
31
+ // A domain clamp (the selection outgrew or fell outside the ring's span) is
32
+ // NOT a user range action: routed here so the host can preserve LIVE mode
33
+ // (narrowing the width) instead of freezing on first load of a short ring.
34
+ onSelectionClamp?: (sel: ScrubberRange) => void
35
+ onPresetSelect?: (widthMs: number) => void
36
+ lens?: ScrubberRange
37
+ onLensChange?: (lens: ScrubberRange) => void
38
+ lensResizable?: boolean
39
+ onDomainChange?: (info: ScrubberDomainInfo) => void
40
+ liveState?: TimelineLiveState
41
+ onLiveChipClick?: () => void
42
+ // The host ring fetch failed. The strip has no fetch of its own — it derives
43
+ // its histogram from `events` — so on failure it would paint a hollow
44
+ // "0 events" strip beside the host's error pane. Hide it instead; the host owns
45
+ // the error UI (mirrors RetainedTimelineScrubber, which hides its strip too).
46
+ isError?: boolean
47
+ }
48
+
49
+ // The local-mode strip. Same control surface as the retained scrubber, but the
50
+ // overview is computed from the loaded ring (no server rollup) and the domain is
51
+ // the ring's actual span. Deliberately renders NO gap hatching: coverage is a
52
+ // retention concept — locally we cannot know what we missed while not watching,
53
+ // so an honest strip omits it rather than inventing gap bands.
54
+ export function LocalTimelineScrubber({
55
+ events,
56
+ loading,
57
+ selection,
58
+ onSelectionChange,
59
+ onSelectionClamp,
60
+ onPresetSelect,
61
+ lens,
62
+ onLensChange,
63
+ lensResizable,
64
+ onDomainChange,
65
+ liveState,
66
+ onLiveChipClick,
67
+ isError,
68
+ }: LocalTimelineScrubberProps) {
69
+ const overview = useMemo(() => localOverviewFromEvents(events), [events])
70
+ const hourBuckets = overview.buckets
71
+ const availableFromMs = overview.availableFromMs
72
+
73
+ // When recording began: the oldest LIVE-fed event (informer / k8s event).
74
+ // Historical events are synthesized from resource metadata and can be years
75
+ // old, so they don't mark where the ring's real coverage starts. The strip
76
+ // dims the query region before this — "Radar wasn't watching yet" is the
77
+ // honest answer to "why can't I scroll further back".
78
+ const recordingStartMs = useMemo(() => {
79
+ let min: number | null = null
80
+ for (const e of events) {
81
+ if (e.source === 'historical') continue
82
+ const t = new Date(e.timestamp).getTime()
83
+ if (Number.isFinite(t) && (min == null || t < min)) min = t
84
+ }
85
+ return min ?? undefined
86
+ }, [events])
87
+
88
+ // Stable "now" between renders so the domain doesn't jitter. A LIVE selection's
89
+ // right edge tracks the host's tick, so take the max — the domain never trails
90
+ // the selection (which would fight the clamp effect every tick).
91
+ const [mountedAt] = useState(() => Date.now())
92
+ const now = Math.max(mountedAt, selection.toMs)
93
+
94
+ // Domain floor = the oldest event held in the ring (the span we actually have),
95
+ // clamped so the strip always has at least an hour of width. No retention
96
+ // horizon: the whole ring is in memory, so the domain is exactly what we hold.
97
+ const domain = useMemo<ScrubberRange>(() => {
98
+ const fromMs = availableFromMs != null ? Math.min(availableFromMs, now - HOUR_MS) : now - HOUR_MS
99
+ return { fromMs, toMs: now }
100
+ }, [availableFromMs, now])
101
+
102
+ const domainWidth = domain.toMs - domain.fromMs
103
+ // No per-request cap locally: the full ring is already loaded, so a brush can
104
+ // span the entire domain.
105
+ const maxSelectionMs = domainWidth
106
+
107
+ // The histogram spans the QUERY RANGE (selection) directly —
108
+ // no framing, no minimap. The query is the view, so a narrow window is never a
109
+ // sub-pixel sliver; the draggable lens band lives inside this span.
110
+ const displayDomain = useMemo<ScrubberRange>(
111
+ () => ({ fromMs: selection.fromMs, toMs: selection.toMs }),
112
+ [selection.fromMs, selection.toMs],
113
+ )
114
+ const displayWidth = displayDomain.toMs - displayDomain.fromMs
115
+
116
+ // Bar granularity is driven by the WINDOW, not the whole query: a narrow window
117
+ // on a 24h query must still span many fine bars, or it sits over one coarse bar
118
+ // and an EMPTY window reads as populated (and midpoint colouring mislabels it).
119
+ // The whole ring is in the browser, so we can rebucket raw events sub-hour.
120
+ // Floor keeps the query itself from exceeding ~MAX_STRIP_BARS.
121
+ const lensWidthMs = lens ? Math.max(lens.toMs - lens.fromMs, MINUTE_MS) : displayWidth
122
+ const bucketSizeMs = Math.max(
123
+ pickDisplayBucketSizeMs(lensWidthMs, MINUTE_MS),
124
+ Math.ceil(displayWidth / MAX_STRIP_BARS / MINUTE_MS) * MINUTE_MS,
125
+ )
126
+
127
+ const displayBuckets = useMemo(() => {
128
+ const source = bucketSizeMs >= HOUR_MS
129
+ ? hourBuckets
130
+ : localOverviewFromEvents(events, bucketSizeMs).buckets
131
+ return groupBuckets(source, bucketSizeMs)
132
+ .filter((b) => b.endMs > displayDomain.fromMs && b.startMs < displayDomain.toMs)
133
+ }, [hourBuckets, bucketSizeMs, events, displayDomain.fromMs, displayDomain.toMs])
134
+
135
+ // Enrich a frozen chip with the count of events after the frozen edge, so the
136
+ // "Go live" CTA can pull the user toward fresh data. Counted over the FULL
137
+ // domain — the displayed span may cut off newer events. Live states pass
138
+ // through.
139
+ const fullBuckets = useMemo(() => groupBuckets(hourBuckets, HOUR_MS), [hourBuckets])
140
+ const chipState = useMemo<TimelineLiveState | undefined>(() => {
141
+ if (!liveState || liveState.kind !== 'frozen') return liveState
142
+ return { ...liveState, newEventCount: countEventsAfter(fullBuckets, selection.toMs) }
143
+ }, [liveState, fullBuckets, selection.toMs])
144
+
145
+ // Presets clamp to the domain: only offer windows the ring can actually fill,
146
+ // so we never advertise 7d of history on a 20-minute-old cluster. Always keep
147
+ // at least the smallest so there is a preset to click.
148
+ const presets = useMemo<ScrubberPreset[]>(() => {
149
+ const all = buildPresets(30)
150
+ const fit = all.filter((p) => p.ms <= domainWidth)
151
+ const base = fit.length > 0 ? fit : [all[0]]
152
+ // One-click whole-ring selection. The strip caps a single brush at the
153
+ // displayed span, so "everything we hold" needs a first-class control.
154
+ return [...base, { label: 'All', ms: domainWidth }]
155
+ }, [domainWidth])
156
+
157
+ // Lift the resolved domain + cap so the host can clamp extend requests.
158
+ useEffect(() => {
159
+ onDomainChange?.({ domain, maxSelectionMs })
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, [domain.fromMs, domain.toMs, maxSelectionMs])
162
+
163
+ // Keep the controlled selection inside the derived domain. Fires when the
164
+ // domain resolves or shrinks under the current selection (e.g. a short ring).
165
+ // Route through onSelectionClamp (falling back to onSelectionChange) so the
166
+ // host can tell this apart from a user brush and keep LIVE mode alive.
167
+ useEffect(() => {
168
+ const { selection: clamped } = clampSelection(selection, domain, maxSelectionMs, 'end')
169
+ if (clamped.fromMs !== selection.fromMs || clamped.toMs !== selection.toMs) {
170
+ ;(onSelectionClamp ?? onSelectionChange)(clamped)
171
+ }
172
+ // Selection endpoints included: an externally-set selection (URL restore,
173
+ // back-nav) outside the domain must clamp even when the domain itself
174
+ // didn't change. Loop-safe — once clamped, the comparison is equal.
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ }, [domain.fromMs, domain.toMs, maxSelectionMs, selection.fromMs, selection.toMs])
177
+
178
+ // A failed ring fetch would render a hollow "0 events" strip next to the host's
179
+ // error pane. Hide instead — checked after all hooks so hook order is stable.
180
+ if (isError) return null
181
+
182
+ return (
183
+ <div className="@container px-4 py-2 border-b border-theme-border bg-theme-surface">
184
+ <TimelineStrip
185
+ buckets={displayBuckets}
186
+ loading={loading}
187
+ domain={domain}
188
+ historyUnavailableBeforeMs={recordingStartMs}
189
+ // Exact count with the SAME predicate the toolbar chips use — edge
190
+ // buckets spill a few events, so a bucket sum tells a second story.
191
+ totalInQueryRange={events.reduce((n, e) => {
192
+ const t = new Date(e.timestamp).getTime()
193
+ return t >= selection.fromMs && t <= selection.toMs ? n + 1 : n
194
+ }, 0)}
195
+ selection={selection}
196
+ onSelectionChange={onSelectionChange}
197
+ maxSelectionMs={maxSelectionMs}
198
+ presets={presets}
199
+ onPresetSelect={(p) => (
200
+ onPresetSelect
201
+ ? onPresetSelect(p.ms)
202
+ : onSelectionChange(presetToSelection(p.ms, now, domain, maxSelectionMs).selection)
203
+ )}
204
+ lens={lens}
205
+ onLensChange={onLensChange}
206
+ lensResizable={lensResizable}
207
+ liveState={chipState}
208
+ onLiveChipClick={onLiveChipClick}
209
+ />
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,311 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { AlertTriangle, RefreshCw } from 'lucide-react'
4
+ import {
5
+ TimelineStrip,
6
+ clampSelection,
7
+ countEventsAfter,
8
+ mergeGapRanges,
9
+ pickDisplayBucketSizeMs,
10
+ presetToSelection,
11
+ type ScrubberBucket,
12
+ type ScrubberPreset,
13
+ type ScrubberRange,
14
+ type TimelineLiveState,
15
+ } from '@skyhook-io/k8s-ui'
16
+ import type {
17
+ TimelineSource,
18
+ TimelineOverviewBucket,
19
+ TimelineOverviewResult,
20
+ } from '../../api/timelineSource'
21
+ import { getApiBase } from '../../api/config'
22
+
23
+ const HOUR_MS = 60 * 60 * 1000
24
+ const DAY_MS = 24 * HOUR_MS
25
+ const EMPTY_BUCKETS: TimelineOverviewBucket[] = []
26
+ const MAX_STRIP_BARS = 512
27
+
28
+ // Per-request guard on the retained events endpoint: never brush wider than 7d.
29
+ const MAX_SELECTION_MS = 7 * DAY_MS
30
+
31
+ // Group the server's hour buckets into fixed display buckets aligned to the
32
+ // display size, summing counts. The host owns this so the pure scrubber only
33
+ // ever paints ready-to-draw bars.
34
+ export function groupBuckets(
35
+ hourBuckets: TimelineOverviewBucket[],
36
+ bucketSizeMs: number,
37
+ ): ScrubberBucket[] {
38
+ const slots = new Map<number, { total: number; warnings: number }>()
39
+ for (const b of hourBuckets) {
40
+ const slot = Math.floor(b.startMs / bucketSizeMs) * bucketSizeMs
41
+ const cur = slots.get(slot) ?? { total: 0, warnings: 0 }
42
+ cur.total += b.summary.total
43
+ cur.warnings += b.summary.warnings
44
+ slots.set(slot, cur)
45
+ }
46
+ return [...slots.entries()]
47
+ .map(([slot, v]) => ({ startMs: slot, endMs: slot + bucketSizeMs, total: v.total, warnings: v.warnings }))
48
+ .sort((a, b) => a.startMs - b.startMs)
49
+ }
50
+
51
+ function coverageNumber(v: unknown): number | undefined {
52
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined
53
+ }
54
+
55
+ /**
56
+ * Collect recording-gap spans from every bucket's backend-owned coverage field
57
+ * into merged, domain-clipped time ranges. The hub emits event-time bounds
58
+ * (`eventTimeStartMs` / `eventTimeEndMs`); a span missing either bound is
59
+ * skipped. Merge/dedupe/clamp is delegated to the pure `mergeGapRanges`.
60
+ */
61
+ export function extractRecordingGaps(
62
+ hourBuckets: TimelineOverviewBucket[],
63
+ domain?: ScrubberRange,
64
+ ): ScrubberRange[] {
65
+ const raw: ScrubberRange[] = []
66
+ for (const b of hourBuckets) {
67
+ const list = b.coverage ?? []
68
+ for (const item of list) {
69
+ if (!item) continue
70
+ const start = coverageNumber(item.eventTimeStartMs)
71
+ const end = coverageNumber(item.eventTimeEndMs)
72
+ if (start == null || end == null) continue
73
+ raw.push({ fromMs: start, toMs: end })
74
+ }
75
+ }
76
+ return mergeGapRanges(raw, domain)
77
+ }
78
+
79
+ export function buildPresets(maxRangeDays: number): ScrubberPreset[] {
80
+ const presets: ScrubberPreset[] = [
81
+ { label: '1h', ms: HOUR_MS },
82
+ { label: '6h', ms: 6 * HOUR_MS },
83
+ { label: '24h', ms: DAY_MS },
84
+ { label: '7d', ms: 7 * DAY_MS },
85
+ ]
86
+ // 30d is a domain-context preset — it clamps to the 7d per-request cap, but
87
+ // signals the deeper retained window is available.
88
+ if (maxRangeDays >= 30) presets.push({ label: '30d', ms: 30 * DAY_MS })
89
+ return presets
90
+ }
91
+
92
+ /**
93
+ * Extend an applied selection by 50% of its width in one direction, clamped to
94
+ * the domain and the per-request cap. Used by the swimlane's "extend" affordance
95
+ * — anchored on the edge that stays put (past extend keeps the recent edge).
96
+ */
97
+ export function extendSelection(
98
+ sel: ScrubberRange,
99
+ dir: 'past' | 'future',
100
+ domain: ScrubberRange,
101
+ maxSelectionMs: number,
102
+ ): ScrubberRange {
103
+ const grow = (sel.toMs - sel.fromMs) * 0.5
104
+ if (dir === 'past') {
105
+ return clampSelection({ fromMs: sel.fromMs - grow, toMs: sel.toMs }, domain, maxSelectionMs, 'end').selection
106
+ }
107
+ return clampSelection({ fromMs: sel.fromMs, toMs: sel.toMs + grow }, domain, maxSelectionMs, 'start').selection
108
+ }
109
+
110
+ export interface ScrubberDomainInfo {
111
+ domain: ScrubberRange
112
+ maxSelectionMs: number
113
+ }
114
+
115
+ interface RetainedTimelineScrubberProps {
116
+ source: TimelineSource
117
+ selection: ScrubberRange
118
+ // Any explicit range action (brush Run-query, pan, zoom, step, handle drag)
119
+ // — the host treats this as a transition to FROZEN mode.
120
+ onSelectionChange: (sel: ScrubberRange) => void
121
+ // A domain clamp (the selection outgrew or fell outside the retained window)
122
+ // is NOT a user range action: routed here so the host can preserve LIVE mode
123
+ // (narrowing the width) instead of freezing.
124
+ onSelectionClamp?: (sel: ScrubberRange) => void
125
+ // Preset click (1h/6h/24h/7d) — distinct route so the host can enter LIVE mode
126
+ // with that width. When omitted, presets fall back to a one-shot frozen
127
+ // selection via onSelectionChange.
128
+ onPresetSelect?: (widthMs: number) => void
129
+ // The lens band (swimlane's visible window). Two-way synced by the host.
130
+ lens?: ScrubberRange
131
+ onLensChange?: (lens: ScrubberRange) => void
132
+ lensResizable?: boolean
133
+ // Lifts the server-derived domain + cap so the host can clamp extend requests.
134
+ onDomainChange?: (info: ScrubberDomainInfo) => void
135
+ // Lifts merged recording gaps so the host can thread them into the swimlane.
136
+ onGapsChange?: (gaps: ScrubberRange[]) => void
137
+ // Live/paused chip state + click handler (host-computed). The scrubber owns the
138
+ // overview buckets, so it enriches a frozen state with the "new events" count
139
+ // before handing the chip to TimelineStrip.
140
+ liveState?: TimelineLiveState
141
+ onLiveChipClick?: () => void
142
+ }
143
+
144
+ export function RetainedTimelineScrubber({ source, selection, onSelectionChange, onSelectionClamp, onPresetSelect, lens, onLensChange, lensResizable, onDomainChange, onGapsChange, liveState, onLiveChipClick }: RetainedTimelineScrubberProps) {
145
+ const maxRangeDays = source.capabilities.maxRangeDays ?? 7
146
+ const fetchOverview = source.fetchOverview
147
+
148
+ const overview = useQuery<TimelineOverviewResult>({
149
+ // apiBase scopes the key so a cluster swap (mutable module global) can't serve
150
+ // the previous cluster's overview buckets.
151
+ queryKey: ['timeline-overview', getApiBase(), maxRangeDays],
152
+ queryFn: () => {
153
+ const to = Date.now()
154
+ const from = to - maxRangeDays * DAY_MS
155
+ return fetchOverview!({ from, to })
156
+ },
157
+ enabled: !!fetchOverview,
158
+ staleTime: 60_000,
159
+ refetchInterval: 60_000,
160
+ })
161
+
162
+ // Stable "now" between refetches so the domain (and selection clamping) don't
163
+ // jitter on every render. Must be render-stable even before the query
164
+ // resolves: a bare Date.now() fallback changes every render, and the domain
165
+ // effects below setState in the host — an update loop until data arrives.
166
+ const [mountedAt] = useState(() => Date.now())
167
+ // A LIVE selection's right edge tracks now (host's 30s tick), which runs ahead
168
+ // of the overview's dataUpdatedAt (≤60s stale). Take the max so the domain
169
+ // never trails the selection and the clamp effect can't rewrite a live edge
170
+ // backward every tick (mode fight).
171
+ const now = Math.max(overview.dataUpdatedAt || mountedAt, selection.toMs)
172
+ // Stable empty fallback: a fresh [] literal here changes the gaps memo's
173
+ // identity every pre-data render, and the onGapsChange lift effect keyed on
174
+ // it would setState the host into an update loop.
175
+ const hourBuckets = overview.data?.buckets ?? EMPTY_BUCKETS
176
+ const availableFromMs = overview.data?.availableFromMs
177
+
178
+ const domain = useMemo<ScrubberRange>(() => {
179
+ // Clamp the domain floor to the queryable window. availableFromMs can point
180
+ // at ancient synthesized-historical event times (resource creation dates on
181
+ // long-lived clusters), which would stretch the strip over years of
182
+ // unreachable nothing — the UI can never brush past maxRangeDays anyway.
183
+ const floor = now - maxRangeDays * DAY_MS
184
+ const fromMs = availableFromMs != null ? Math.max(availableFromMs, floor) : floor
185
+ return { fromMs: Math.min(fromMs, now - HOUR_MS), toMs: now }
186
+ }, [availableFromMs, now, maxRangeDays])
187
+
188
+ const domainWidth = domain.toMs - domain.fromMs
189
+ const maxSelectionMs = Math.min(MAX_SELECTION_MS, domainWidth)
190
+
191
+ // The histogram spans the QUERY RANGE (selection) directly —
192
+ // no ×8 framing, no minimap. The query is the view, so a narrow window is never
193
+ // a sub-pixel sliver; the draggable lens band lives inside this span. Navigating
194
+ // to other times is the query-range picker's job, not a spatial minimap's.
195
+ const displayDomain = useMemo<ScrubberRange>(
196
+ () => ({ fromMs: selection.fromMs, toMs: selection.toMs }),
197
+ [selection.fromMs, selection.toMs],
198
+ )
199
+ const displayWidth = displayDomain.toMs - displayDomain.fromMs
200
+ // Bucket size follows the WINDOW (lens), like the local strip: zooming the
201
+ // window into a slice of a wide query re-buckets the histogram to match the
202
+ // zoom level. Floored at the server rollup's hour granularity, and at a size
203
+ // that keeps the whole query under ~MAX_STRIP_BARS bars.
204
+ const lensWidthMs = lens ? Math.max(lens.toMs - lens.fromMs, HOUR_MS) : displayWidth
205
+ const bucketSizeMs = Math.max(
206
+ pickDisplayBucketSizeMs(lensWidthMs),
207
+ Math.ceil(displayWidth / MAX_STRIP_BARS / HOUR_MS) * HOUR_MS,
208
+ )
209
+
210
+ const displayBuckets = useMemo(
211
+ () => groupBuckets(hourBuckets, bucketSizeMs)
212
+ .filter((b) => b.endMs > displayDomain.fromMs && b.startMs < displayDomain.toMs),
213
+ [hourBuckets, bucketSizeMs, displayDomain.fromMs, displayDomain.toMs],
214
+ )
215
+
216
+ // Enrich a frozen chip with the count of events recorded after the frozen edge,
217
+ // so the "Go live" CTA can pull the user toward the fresh data. Counted over
218
+ // the FULL domain — the displayed span may cut off newer events. Live states
219
+ // pass through unchanged.
220
+ const fullBuckets = useMemo(() => groupBuckets(hourBuckets, HOUR_MS), [hourBuckets])
221
+ const chipState = useMemo<TimelineLiveState | undefined>(() => {
222
+ if (!liveState || liveState.kind !== 'frozen') return liveState
223
+ return { ...liveState, newEventCount: countEventsAfter(fullBuckets, selection.toMs) }
224
+ }, [liveState, fullBuckets, selection.toMs])
225
+ const gaps = useMemo(() => extractRecordingGaps(hourBuckets, domain), [hourBuckets, domain])
226
+ const presets = useMemo(() => buildPresets(maxRangeDays), [maxRangeDays])
227
+
228
+ // Floor for dimming "not recorded yet": the first real event, clamped into the
229
+ // visible domain. Below the domain floor (availableFromMs can predate the
230
+ // retention window by years via synthesized historical event times) it collapses
231
+ // to the domain start — the pre-retention dead zone is as unreachable as
232
+ // pre-recording time. Above it (a fresh cluster whose oldest event is <1h old)
233
+ // the [domain start .. first event] band dims as "not recorded yet" instead of
234
+ // reading as a quiet hour.
235
+ const historyFloorMs = availableFromMs != null
236
+ ? Math.min(Math.max(availableFromMs, domain.fromMs), domain.toMs)
237
+ : undefined
238
+
239
+ // Lift the resolved domain + cap so the host can clamp extend requests to the
240
+ // real retained window rather than an estimate.
241
+ useEffect(() => {
242
+ onDomainChange?.({ domain, maxSelectionMs })
243
+ // eslint-disable-next-line react-hooks/exhaustive-deps
244
+ }, [domain.fromMs, domain.toMs, maxSelectionMs])
245
+
246
+ // Lift merged gaps so the swimlane can render matching offline bands + copy.
247
+ useEffect(() => {
248
+ onGapsChange?.(gaps)
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, [gaps])
251
+
252
+ // Keep the controlled selection inside the (server-derived) domain. Fires once
253
+ // the domain resolves or shrinks under the current selection. Route through
254
+ // onSelectionClamp (falling back to onSelectionChange) so the host can tell
255
+ // this apart from a user brush and keep LIVE mode alive.
256
+ useEffect(() => {
257
+ const { selection: clamped } = clampSelection(selection, domain, maxSelectionMs, 'end')
258
+ if (clamped.fromMs !== selection.fromMs || clamped.toMs !== selection.toMs) {
259
+ ;(onSelectionClamp ?? onSelectionChange)(clamped)
260
+ }
261
+ // Selection endpoints included: an externally-set selection (URL restore,
262
+ // back-nav) outside the domain must clamp even when the domain itself
263
+ // didn't change. Loop-safe — once clamped, the comparison is equal.
264
+ // eslint-disable-next-line react-hooks/exhaustive-deps
265
+ }, [domain.fromMs, domain.toMs, maxSelectionMs, selection.fromMs, selection.toMs])
266
+
267
+ if (overview.isError) {
268
+ return (
269
+ <div className="flex items-center gap-2 px-4 py-1.5 text-xs text-theme-text-tertiary border-b border-theme-border bg-theme-surface">
270
+ <AlertTriangle className="w-3.5 h-3.5 text-amber-400/70" />
271
+ <span>Failed to load timeline overview</span>
272
+ <button
273
+ onClick={() => overview.refetch()}
274
+ className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary transition-colors"
275
+ >
276
+ <RefreshCw className="w-3 h-3" />
277
+ Retry
278
+ </button>
279
+ </div>
280
+ )
281
+ }
282
+
283
+ return (
284
+ <div className="@container px-4 py-2 border-b border-theme-border bg-theme-surface">
285
+ <TimelineStrip
286
+ buckets={displayBuckets}
287
+ loading={overview.isLoading}
288
+ gaps={gaps}
289
+ domain={domain}
290
+ // No totalInQueryRange here — the hour-granular rollup can't produce an
291
+ // exact count for an arbitrary sub-hour-aligned range, so the strip's
292
+ // bucket-sum footer (± edge-bucket spillover) is the best available.
293
+ historyUnavailableBeforeMs={historyFloorMs}
294
+ selection={selection}
295
+ onSelectionChange={onSelectionChange}
296
+ maxSelectionMs={maxSelectionMs}
297
+ presets={presets}
298
+ onPresetSelect={(p) => (
299
+ onPresetSelect
300
+ ? onPresetSelect(p.ms)
301
+ : onSelectionChange(presetToSelection(p.ms, now, domain, maxSelectionMs).selection)
302
+ )}
303
+ lens={lens}
304
+ onLensChange={onLensChange}
305
+ lensResizable={lensResizable}
306
+ liveState={chipState}
307
+ onLiveChipClick={onLiveChipClick}
308
+ />
309
+ </div>
310
+ )
311
+ }