@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
-
<
|
|
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
|
+
}
|