@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
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// Timeline data-source abstraction.
|
|
2
|
+
//
|
|
3
|
+
// Radar's timeline can be backed by two stores:
|
|
4
|
+
// - 'local' — the in-process event store the Radar binary keeps (default,
|
|
5
|
+
// OSS standalone). Fetched via GET {apiBase}/changes.
|
|
6
|
+
// - 'retained' — a longer-horizon history store answered upstream of Radar
|
|
7
|
+
// (relative to apiBase) as GET {apiBase}/timeline/events and
|
|
8
|
+
// GET {apiBase}/timeline/overview. This is an extension point:
|
|
9
|
+
// the standalone binary never selects it; a host that embeds
|
|
10
|
+
// RadarApp behind a proxy that serves retained history opts in
|
|
11
|
+
// via the `timelineSource` prop.
|
|
12
|
+
//
|
|
13
|
+
// Both sources expose the same `useEvents(query)` hook shape so the timeline
|
|
14
|
+
// wrappers stay source-agnostic: pick the source from context, call useEvents.
|
|
15
|
+
import { useMemo } from 'react'
|
|
16
|
+
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
|
17
|
+
import { quantizeBaseWindow } from '@skyhook-io/k8s-ui'
|
|
18
|
+
import { useChanges, apiFetch, ApiError, type UseChangesOptions } from './client'
|
|
19
|
+
import { apiUrl, getApiBase } from './config'
|
|
20
|
+
import type { TimelineEvent, TimeRange } from '../types'
|
|
21
|
+
|
|
22
|
+
// The query the wrappers pass. Superset of the local store's params so most
|
|
23
|
+
// call sites don't change shape when switching sources.
|
|
24
|
+
export type TimelineQuery = UseChangesOptions & {
|
|
25
|
+
// Explicit [from,to] window in epoch-ms. When both are set the retained
|
|
26
|
+
// source loads exactly this window (the scrubber's brush selection) instead
|
|
27
|
+
// of deriving one from `timeRange`. The local source can't express a frozen
|
|
28
|
+
// past window server-side, so it loads the whole ring and bounds it to
|
|
29
|
+
// [from,to] client-side.
|
|
30
|
+
fromMs?: number
|
|
31
|
+
toMs?: number
|
|
32
|
+
// LIVE mode: the [from,to] window slides every tick. Quantize the BASE fetch
|
|
33
|
+
// window to fixed steps so the react-query key only changes every few minutes;
|
|
34
|
+
// the precise sliding window is still applied by the client-side filter, and
|
|
35
|
+
// the trailing seam is covered by the live poll. Ignored by the local source.
|
|
36
|
+
sliding?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TimelineSourceCapabilities {
|
|
40
|
+
mode: 'local' | 'retained'
|
|
41
|
+
// Only meaningful for 'retained': the maximum lookback the retained backend
|
|
42
|
+
// serves. Clamps EVERY derived range (rangeSpanMs, not just 'all'), the
|
|
43
|
+
// from-edge of an explicit [from,to] window, and the scrubber's selectable
|
|
44
|
+
// domain — nothing can reach further back than this. Defaults to
|
|
45
|
+
// DEFAULT_RETAINED_MAX_RANGE_DAYS when unset.
|
|
46
|
+
maxRangeDays?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A single window [from,to] in epoch-ms. Cluster identity is implicit in the
|
|
50
|
+
// configured apiBase path, so no cluster id is carried here.
|
|
51
|
+
export interface TimelineRange {
|
|
52
|
+
from: number
|
|
53
|
+
to: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Coverage lines emitted inline in the retained events stream (gaps / retention
|
|
57
|
+
// boundaries). Shape is owned by the retained backend; kept opaque here and
|
|
58
|
+
// surfaced on the result so a future UI can render coverage without another
|
|
59
|
+
// round-trip. Not consumed by the timeline wrappers today.
|
|
60
|
+
export interface TimelineCoverageRecord {
|
|
61
|
+
type: 'coverage'
|
|
62
|
+
[key: string]: unknown
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A recording-coverage span within an overview bucket. Event-time bounds are
|
|
66
|
+
// owned by the retained (hub) backend; on the wire a bucket may carry one span
|
|
67
|
+
// or several — fetchRetainedOverview normalizes both to an array so consumers
|
|
68
|
+
// only ever see TimelineCoverageSpan[].
|
|
69
|
+
export interface TimelineCoverageSpan {
|
|
70
|
+
eventTimeStartMs?: number
|
|
71
|
+
eventTimeEndMs?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TimelineOverviewBucket {
|
|
75
|
+
// Bucket start in epoch-ms — hourly for the retained rollup, sub-hour when the
|
|
76
|
+
// local overview rebuckets finer. Named for what it is, not its granularity.
|
|
77
|
+
startMs: number
|
|
78
|
+
summary: {
|
|
79
|
+
total: number
|
|
80
|
+
adds: number
|
|
81
|
+
updates: number
|
|
82
|
+
deletes: number
|
|
83
|
+
warnings: number
|
|
84
|
+
// Server-owned health rollup. localOverviewFromEvents only emits
|
|
85
|
+
// 'healthy'/'unhealthy', but the retained (hub) rollup can carry the full
|
|
86
|
+
// HealthLevel vocabulary, so this stays an open string.
|
|
87
|
+
worstHealth: string
|
|
88
|
+
namespaces: string[]
|
|
89
|
+
}
|
|
90
|
+
coverage?: TimelineCoverageSpan[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Overview response envelope: the hourly `buckets` plus `availableFromMs` (the
|
|
94
|
+
// oldest event-time the server holds for this cluster) so the scrubber domain
|
|
95
|
+
// reflects real retention.
|
|
96
|
+
export interface TimelineOverviewResult {
|
|
97
|
+
buckets: TimelineOverviewBucket[]
|
|
98
|
+
availableFromMs?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface TimelineEventsResult {
|
|
102
|
+
data: TimelineEvent[] | undefined
|
|
103
|
+
isLoading: boolean
|
|
104
|
+
isFetching: boolean
|
|
105
|
+
isError: boolean
|
|
106
|
+
refetch: () => void
|
|
107
|
+
// Present only for sources that report coverage (retained).
|
|
108
|
+
coverage?: TimelineCoverageRecord[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TimelineSource {
|
|
112
|
+
capabilities: TimelineSourceCapabilities
|
|
113
|
+
useEvents: (query: TimelineQuery) => TimelineEventsResult
|
|
114
|
+
// Optional hourly rollup. Only the retained source implements it.
|
|
115
|
+
fetchOverview?: (range: TimelineRange) => Promise<TimelineOverviewResult>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Config carried by the RadarApp `timelineSource` prop. Absent = local.
|
|
119
|
+
export interface TimelineSourceConfig {
|
|
120
|
+
mode: 'retained'
|
|
121
|
+
maxRangeDays?: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Local source — thin wrapper over the existing useChanges behavior. Zero
|
|
126
|
+
// behavioral change for OSS standalone.
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
// Full ring size to pull when the host bounds the local list by the shared
|
|
130
|
+
// scrubber selection — matches the swimlane's ring fetch so both views cover the
|
|
131
|
+
// same window.
|
|
132
|
+
const LOCAL_RING_LIMIT = 10000
|
|
133
|
+
|
|
134
|
+
function useLocalEvents(query: TimelineQuery): TimelineEventsResult {
|
|
135
|
+
// Without a window the dropdown-driven `since` fetch is untouched. When the
|
|
136
|
+
// host drives an explicit [from,to] window (the shared scrubber selection),
|
|
137
|
+
// the private range dropdown is bypassed: the local /changes endpoint is
|
|
138
|
+
// `since`-based and can't express a frozen past window, so load the whole ring
|
|
139
|
+
// and bound it to the selection client-side (applyClientFilters), exactly as
|
|
140
|
+
// the swimlane derives its view from the loaded ring.
|
|
141
|
+
const windowed = query.fromMs != null || query.toMs != null
|
|
142
|
+
// deltaSync on every full-ring pull (timeRange 'all' — the swimlane's direct
|
|
143
|
+
// query and the list's windowed one): SSE-driven refetches then transfer only
|
|
144
|
+
// what arrived since the last full load. Dropdown-ranged (`since`) queries
|
|
145
|
+
// stay plain — they're small and their range moves with the clock.
|
|
146
|
+
const { data, isLoading, isFetching, isError, refetch } = useChanges(
|
|
147
|
+
windowed
|
|
148
|
+
? { ...query, timeRange: 'all', limit: LOCAL_RING_LIMIT, deltaSync: true }
|
|
149
|
+
: { ...query, deltaSync: query.timeRange === 'all' },
|
|
150
|
+
)
|
|
151
|
+
// applyClientFilters runs on BOTH paths: a multi-kind selection is a CLIENT-side
|
|
152
|
+
// filter (only a single kind rides the /changes server query key), so a 2+ kind
|
|
153
|
+
// pick is narrowed here whether or not a window is set. A non-windowed query has
|
|
154
|
+
// null from/to, so the [from,to] bounding inside is a no-op there; the windowed
|
|
155
|
+
// path additionally bounds the loaded ring. The memo watches kindsKey itself —
|
|
156
|
+
// `data` identity won't change when only the kind set does.
|
|
157
|
+
const kindsKey = query.kinds?.join(',')
|
|
158
|
+
const events = useMemo(
|
|
159
|
+
() => (data ? applyClientFilters(data, query) : data),
|
|
160
|
+
// `data` identity captures every server-side filter change (namespaces,
|
|
161
|
+
// k8s-events, deleted — all in the useChanges query key); the client-only
|
|
162
|
+
// window + cap + kind set are added here, plus includeManaged, which is
|
|
163
|
+
// client-enforced and must not depend on staying in the server key. The
|
|
164
|
+
// live tick advances query.toMs, re-filtering to the sliding edge with no
|
|
165
|
+
// refetch.
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
[data, query.fromMs, query.toMs, query.limit, kindsKey, query.includeManaged],
|
|
168
|
+
)
|
|
169
|
+
return { data: events, isLoading, isFetching, isError, refetch }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const localSource: TimelineSource = {
|
|
173
|
+
capabilities: { mode: 'local' },
|
|
174
|
+
useEvents: useLocalEvents,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const LOCAL_HOUR_MS = 60 * 60 * 1000
|
|
178
|
+
|
|
179
|
+
interface LocalHourSlot {
|
|
180
|
+
total: number
|
|
181
|
+
adds: number
|
|
182
|
+
updates: number
|
|
183
|
+
deletes: number
|
|
184
|
+
warnings: number
|
|
185
|
+
namespaces: Set<string>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Client-side overview for the local source. The Radar binary loads the whole
|
|
189
|
+
// event ring into the browser (the swimlane's 10k fetch), so the scrubber
|
|
190
|
+
// histogram is derived here instead of from a server rollup — the local store
|
|
191
|
+
// has no /timeline/overview endpoint. Buckets by hour to match the retained
|
|
192
|
+
// overview shape the scrubber host already groups.
|
|
193
|
+
//
|
|
194
|
+
// `availableFromMs` is the oldest event time held in the ring: the scrubber
|
|
195
|
+
// domain floor comes from whoever holds the data, exactly as the retained
|
|
196
|
+
// source reports its own retention floor.
|
|
197
|
+
//
|
|
198
|
+
// `bucketSizeMs` defaults to the retained rollup's hourly granularity; a
|
|
199
|
+
// tightly framed strip passes a sub-hour size to rebucket the same events
|
|
200
|
+
// finer. The bucket's `startMs` is its start regardless of size — hourly or not.
|
|
201
|
+
//
|
|
202
|
+
// No coverage/gap field is emitted. Coverage is a retention concept — the hub
|
|
203
|
+
// records what it missed while not watching. Locally we cannot know what
|
|
204
|
+
// happened during downtime, so claiming a gap would be dishonest; we omit it.
|
|
205
|
+
export function localOverviewFromEvents(events: TimelineEvent[], bucketSizeMs = LOCAL_HOUR_MS): TimelineOverviewResult {
|
|
206
|
+
const slots = new Map<number, LocalHourSlot>()
|
|
207
|
+
let oldest = Number.POSITIVE_INFINITY
|
|
208
|
+
|
|
209
|
+
for (const e of events) {
|
|
210
|
+
const t = new Date(e.timestamp).getTime()
|
|
211
|
+
if (!Number.isFinite(t)) continue
|
|
212
|
+
if (t < oldest) oldest = t
|
|
213
|
+
const hour = Math.floor(t / bucketSizeMs) * bucketSizeMs
|
|
214
|
+
let slot = slots.get(hour)
|
|
215
|
+
if (!slot) {
|
|
216
|
+
slot = { total: 0, adds: 0, updates: 0, deletes: 0, warnings: 0, namespaces: new Set() }
|
|
217
|
+
slots.set(hour, slot)
|
|
218
|
+
}
|
|
219
|
+
slot.total++
|
|
220
|
+
if (e.eventType === 'add') slot.adds++
|
|
221
|
+
else if (e.eventType === 'update') slot.updates++
|
|
222
|
+
else if (e.eventType === 'delete') slot.deletes++
|
|
223
|
+
if (e.eventType === 'Warning') slot.warnings++
|
|
224
|
+
if (e.namespace) slot.namespaces.add(e.namespace)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const buckets: TimelineOverviewBucket[] = [...slots.entries()]
|
|
228
|
+
.map(([startMs, s]) => ({
|
|
229
|
+
startMs,
|
|
230
|
+
summary: {
|
|
231
|
+
total: s.total,
|
|
232
|
+
adds: s.adds,
|
|
233
|
+
updates: s.updates,
|
|
234
|
+
deletes: s.deletes,
|
|
235
|
+
warnings: s.warnings,
|
|
236
|
+
worstHealth: s.warnings > 0 ? 'unhealthy' : 'healthy',
|
|
237
|
+
namespaces: [...s.namespaces],
|
|
238
|
+
},
|
|
239
|
+
}))
|
|
240
|
+
.sort((a, b) => a.startMs - b.startMs)
|
|
241
|
+
|
|
242
|
+
return { buckets, availableFromMs: Number.isFinite(oldest) ? oldest : undefined }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Retained source — streams NDJSON from {apiBase}/timeline/events.
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
250
|
+
const DAY_MS = 24 * HOUR_MS
|
|
251
|
+
|
|
252
|
+
// Bound for the 'all' range when the host doesn't specify maxRangeDays.
|
|
253
|
+
const DEFAULT_RETAINED_MAX_RANGE_DAYS = 7
|
|
254
|
+
|
|
255
|
+
// Recent window the live poll re-fetches and merges over the loaded range. Wide
|
|
256
|
+
// enough that a missed 10s tick can't open a gap. Exported so a unit test can pin
|
|
257
|
+
// it against the base-window quantization step (quantization lag < poll window).
|
|
258
|
+
export const LIVE_WINDOW_MS = 10 * 60 * 1000
|
|
259
|
+
const LIVE_REFETCH_MS = 10_000
|
|
260
|
+
|
|
261
|
+
function rangeSpanMs(range: TimeRange | undefined, maxRangeDays?: number): number {
|
|
262
|
+
const cap = (maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
|
|
263
|
+
let span: number
|
|
264
|
+
switch (range) {
|
|
265
|
+
case '5m':
|
|
266
|
+
span = 5 * 60 * 1000
|
|
267
|
+
break
|
|
268
|
+
case '30m':
|
|
269
|
+
span = 30 * 60 * 1000
|
|
270
|
+
break
|
|
271
|
+
case '1h':
|
|
272
|
+
span = HOUR_MS
|
|
273
|
+
break
|
|
274
|
+
case '6h':
|
|
275
|
+
span = 6 * HOUR_MS
|
|
276
|
+
break
|
|
277
|
+
case '24h':
|
|
278
|
+
span = 24 * HOUR_MS
|
|
279
|
+
break
|
|
280
|
+
case '7d':
|
|
281
|
+
span = 7 * DAY_MS
|
|
282
|
+
break
|
|
283
|
+
case '30d':
|
|
284
|
+
span = 30 * DAY_MS
|
|
285
|
+
break
|
|
286
|
+
case 'all':
|
|
287
|
+
case undefined:
|
|
288
|
+
span = cap
|
|
289
|
+
break
|
|
290
|
+
default:
|
|
291
|
+
span = HOUR_MS
|
|
292
|
+
}
|
|
293
|
+
return Math.min(span, cap)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface RetainedWindowResult {
|
|
297
|
+
events: TimelineEvent[]
|
|
298
|
+
coverage: TimelineCoverageRecord[]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
type TerminalRecord = { type: 'end' } | { type: 'error'; message?: string }
|
|
302
|
+
|
|
303
|
+
// De-dupe by id keeping the LAST occurrence — a later revision of an event
|
|
304
|
+
// replaces the earlier one.
|
|
305
|
+
function dedupeById(events: TimelineEvent[]): TimelineEvent[] {
|
|
306
|
+
const byId = new Map<string, TimelineEvent>()
|
|
307
|
+
for (const e of events) byId.set(e.id, e)
|
|
308
|
+
return Array.from(byId.values())
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Exported for unit tests (the NDJSON stream parser); not re-exported publicly.
|
|
312
|
+
export async function fetchRetainedWindow(
|
|
313
|
+
from: number,
|
|
314
|
+
to: number,
|
|
315
|
+
signal?: AbortSignal,
|
|
316
|
+
): Promise<RetainedWindowResult> {
|
|
317
|
+
const res = await apiFetch(
|
|
318
|
+
apiUrl(`/timeline/events?from=${Math.round(from)}&to=${Math.round(to)}`),
|
|
319
|
+
signal ? { signal } : undefined,
|
|
320
|
+
)
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
323
|
+
throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
|
|
324
|
+
}
|
|
325
|
+
if (!res.body) {
|
|
326
|
+
throw new Error('timeline stream has no body')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const reader = res.body.getReader()
|
|
330
|
+
const decoder = new TextDecoder()
|
|
331
|
+
const events: TimelineEvent[] = []
|
|
332
|
+
const coverage: TimelineCoverageRecord[] = []
|
|
333
|
+
let terminal: TerminalRecord | null = null
|
|
334
|
+
let buf = ''
|
|
335
|
+
|
|
336
|
+
const handleLine = (line: string): void => {
|
|
337
|
+
const trimmed = line.trim()
|
|
338
|
+
if (!trimmed) return
|
|
339
|
+
const rec = JSON.parse(trimmed) as { type?: string; message?: string }
|
|
340
|
+
if (rec.type === 'end') {
|
|
341
|
+
terminal = { type: 'end' }
|
|
342
|
+
} else if (rec.type === 'error') {
|
|
343
|
+
terminal = { type: 'error', message: rec.message }
|
|
344
|
+
} else if (rec.type === 'coverage') {
|
|
345
|
+
coverage.push(rec as TimelineCoverageRecord)
|
|
346
|
+
} else {
|
|
347
|
+
events.push(rec as unknown as TimelineEvent)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (;;) {
|
|
352
|
+
const { done, value } = await reader.read()
|
|
353
|
+
if (done) break
|
|
354
|
+
buf += decoder.decode(value, { stream: true })
|
|
355
|
+
let idx: number
|
|
356
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
357
|
+
handleLine(buf.slice(0, idx))
|
|
358
|
+
buf = buf.slice(idx + 1)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
handleLine(buf)
|
|
362
|
+
|
|
363
|
+
// Absence of a terminal record means the response was truncated — treat as a
|
|
364
|
+
// failure so the UI doesn't render a partial window as complete.
|
|
365
|
+
if (!terminal) {
|
|
366
|
+
throw new Error('timeline stream truncated (missing terminal record)')
|
|
367
|
+
}
|
|
368
|
+
if ((terminal as TerminalRecord).type === 'error') {
|
|
369
|
+
throw new Error((terminal as { message?: string }).message || 'timeline stream error')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { events: dedupeById(events), coverage }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Mirrors the Go store's TimelineEvent.IsManaged (pkg/timeline/types.go):
|
|
376
|
+
// a resource managed by another — owned, or one of the churn kinds. Keep the
|
|
377
|
+
// two predicates in lockstep.
|
|
378
|
+
function isManagedTimelineEvent(e: TimelineEvent): boolean {
|
|
379
|
+
return e.owner != null || e.kind === 'ReplicaSet' || e.kind === 'Pod' || e.kind === 'Event'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The retained endpoint scopes only by [from,to] (cluster is implicit in the
|
|
383
|
+
// apiBase path), so the store-side query params the local endpoint honors are
|
|
384
|
+
// applied client-side over the loaded window. Exported for unit tests; not part
|
|
385
|
+
// of the package's public surface.
|
|
386
|
+
export function applyClientFilters(events: TimelineEvent[], query: TimelineQuery): TimelineEvent[] {
|
|
387
|
+
let out = events
|
|
388
|
+
if (query.namespaces && query.namespaces.length > 0) {
|
|
389
|
+
const set = new Set(query.namespaces)
|
|
390
|
+
out = out.filter((e) => set.has(e.namespace))
|
|
391
|
+
}
|
|
392
|
+
if (query.kinds && query.kinds.length > 0) {
|
|
393
|
+
const set = new Set(query.kinds)
|
|
394
|
+
out = out.filter((e) => set.has(e.kind))
|
|
395
|
+
}
|
|
396
|
+
if (query.includeK8sEvents === false) {
|
|
397
|
+
out = out.filter((e) => e.source !== 'k8s_event')
|
|
398
|
+
}
|
|
399
|
+
if (query.includeDeleted === false) {
|
|
400
|
+
out = out.filter((e) => e.eventType !== 'delete')
|
|
401
|
+
}
|
|
402
|
+
// Enforced here — not only server-side — so both sources honor it
|
|
403
|
+
// identically: the retained endpoint has no include_managed param, and the
|
|
404
|
+
// local path's filter presets can override the server-side flag (the 'all'
|
|
405
|
+
// preset re-includes managed). Only an explicit false filters; the default
|
|
406
|
+
// keeps machinery rows, which the swimlane's pod/RS child lanes require.
|
|
407
|
+
if (query.includeManaged === false) {
|
|
408
|
+
out = out.filter((e) => !isManagedTimelineEvent(e))
|
|
409
|
+
}
|
|
410
|
+
// An explicit brush window bounds the result by event time so the live poll's
|
|
411
|
+
// recent-window merge can't leak events past the selected [from,to].
|
|
412
|
+
if (query.fromMs != null || query.toMs != null) {
|
|
413
|
+
out = out.filter((e) => {
|
|
414
|
+
const t = new Date(e.timestamp).getTime()
|
|
415
|
+
if (query.fromMs != null && t < query.fromMs) return false
|
|
416
|
+
if (query.toMs != null && t > query.toMs) return false
|
|
417
|
+
return true
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
out = [...out].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
|
421
|
+
if (query.limit && out.length > query.limit) {
|
|
422
|
+
out = out.slice(0, query.limit)
|
|
423
|
+
}
|
|
424
|
+
return out
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function mergeWindows(
|
|
428
|
+
base: RetainedWindowResult | undefined,
|
|
429
|
+
live: RetainedWindowResult | undefined,
|
|
430
|
+
): RetainedWindowResult {
|
|
431
|
+
// live is newer, so it overwrites base for shared ids.
|
|
432
|
+
const events = dedupeById([...(base?.events ?? []), ...(live?.events ?? [])])
|
|
433
|
+
const coverage = [...(base?.coverage ?? []), ...(live?.coverage ?? [])]
|
|
434
|
+
return { events, coverage }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function createRetainedEventsHook(
|
|
438
|
+
capabilities: TimelineSourceCapabilities,
|
|
439
|
+
): (query: TimelineQuery) => TimelineEventsResult {
|
|
440
|
+
return function useRetainedEvents(query: TimelineQuery): TimelineEventsResult {
|
|
441
|
+
const enabled = query.enabled ?? true
|
|
442
|
+
|
|
443
|
+
// Pin the base window when the range (or cap) changes — not on every render —
|
|
444
|
+
// so the query key is stable and react-query can cache it. Recency is the
|
|
445
|
+
// live poll's job.
|
|
446
|
+
const window = useMemo<TimelineRange>(() => {
|
|
447
|
+
if (query.fromMs != null && query.toMs != null) {
|
|
448
|
+
// An explicit window (frozen brush, or a hand-entered ?from&to) must not
|
|
449
|
+
// reach further back than maxRangeDays — the same cap rangeSpanMs applies
|
|
450
|
+
// to the range-derived branch below. Anchor at the recent edge.
|
|
451
|
+
const capMs = (capabilities.maxRangeDays ?? DEFAULT_RETAINED_MAX_RANGE_DAYS) * DAY_MS
|
|
452
|
+
const from = Math.max(query.fromMs, query.toMs - capMs)
|
|
453
|
+
// Sliding: quantize so two ticks inside one step share a query key (no
|
|
454
|
+
// refetch). The precise [from,to] is still enforced by applyClientFilters.
|
|
455
|
+
if (query.sliding) {
|
|
456
|
+
const q = quantizeBaseWindow(from, query.toMs)
|
|
457
|
+
return { from: q.fromMs, to: q.toMs }
|
|
458
|
+
}
|
|
459
|
+
return { from, to: query.toMs }
|
|
460
|
+
}
|
|
461
|
+
const to = Date.now()
|
|
462
|
+
return { from: to - rangeSpanMs(query.timeRange, capabilities.maxRangeDays), to }
|
|
463
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
464
|
+
}, [query.fromMs, query.toMs, query.timeRange, query.sliding, capabilities.maxRangeDays])
|
|
465
|
+
|
|
466
|
+
const base = useQuery<RetainedWindowResult>({
|
|
467
|
+
// apiBase scopes the key: a host that swaps clusters (mutable module global)
|
|
468
|
+
// must not serve the previous cluster's cached window.
|
|
469
|
+
queryKey: ['timeline-retained', getApiBase(), 'base', window.from, window.to],
|
|
470
|
+
queryFn: ({ signal }) => fetchRetainedWindow(window.from, window.to, signal),
|
|
471
|
+
enabled,
|
|
472
|
+
staleTime: LIVE_REFETCH_MS,
|
|
473
|
+
// The base window quantizes to fixed steps, so its key rotates every few
|
|
474
|
+
// minutes even while live. Hold the previous window's events through the
|
|
475
|
+
// refetch instead of blanking the range on each rotation.
|
|
476
|
+
placeholderData: keepPreviousData,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// Fixed recent-window re-poll. Key is stable; queryFn reads the clock fresh
|
|
480
|
+
// each tick so the window slides without churning the cache key. Skipped for
|
|
481
|
+
// a purely historical brush (its right edge is older than the live window),
|
|
482
|
+
// where recency merging would only add events the time filter drops.
|
|
483
|
+
const liveEnabled = enabled && (query.toMs == null || query.toMs >= Date.now() - LIVE_WINDOW_MS)
|
|
484
|
+
const live = useQuery<RetainedWindowResult>({
|
|
485
|
+
queryKey: ['timeline-retained', getApiBase(), 'live'],
|
|
486
|
+
queryFn: ({ signal }) => {
|
|
487
|
+
const to = Date.now()
|
|
488
|
+
return fetchRetainedWindow(to - LIVE_WINDOW_MS, to, signal)
|
|
489
|
+
},
|
|
490
|
+
enabled: liveEnabled,
|
|
491
|
+
refetchInterval: LIVE_REFETCH_MS,
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
const merged = useMemo(
|
|
495
|
+
() => mergeWindows(base.data, live.data),
|
|
496
|
+
[base.data, live.data],
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
const kindsKey = query.kinds?.join(',')
|
|
500
|
+
const data = useMemo(() => {
|
|
501
|
+
if (base.data === undefined && live.data === undefined) return undefined
|
|
502
|
+
return applyClientFilters(merged.events, query)
|
|
503
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
|
+
}, [
|
|
505
|
+
merged,
|
|
506
|
+
base.data,
|
|
507
|
+
live.data,
|
|
508
|
+
query.namespaces,
|
|
509
|
+
kindsKey,
|
|
510
|
+
query.includeK8sEvents,
|
|
511
|
+
query.includeDeleted,
|
|
512
|
+
query.includeManaged,
|
|
513
|
+
query.limit,
|
|
514
|
+
query.fromMs,
|
|
515
|
+
query.toMs,
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
data,
|
|
520
|
+
isLoading: base.isLoading,
|
|
521
|
+
isFetching: base.isFetching || live.isFetching,
|
|
522
|
+
// Base failure is a real error; a failing live poll must not blank an
|
|
523
|
+
// already-loaded range.
|
|
524
|
+
isError: base.isError,
|
|
525
|
+
refetch: () => {
|
|
526
|
+
base.refetch()
|
|
527
|
+
live.refetch()
|
|
528
|
+
},
|
|
529
|
+
coverage: merged.coverage,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The retained (hub) wire shape: the bucket start ships as `hourStartMs` and
|
|
535
|
+
// coverage may be a single span or an array. fetchRetainedOverview remaps this
|
|
536
|
+
// to the client `TimelineOverviewBucket` (startMs + a normalized coverage array)
|
|
537
|
+
// at this one parse boundary so nothing downstream sees the wire quirks.
|
|
538
|
+
interface RawOverviewBucket {
|
|
539
|
+
hourStartMs: number
|
|
540
|
+
summary: TimelineOverviewBucket['summary']
|
|
541
|
+
coverage?: TimelineCoverageSpan | TimelineCoverageSpan[]
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function normalizeCoverage(
|
|
545
|
+
cov: TimelineCoverageSpan | TimelineCoverageSpan[] | undefined,
|
|
546
|
+
): TimelineCoverageSpan[] | undefined {
|
|
547
|
+
if (cov == null) return undefined
|
|
548
|
+
return Array.isArray(cov) ? cov : [cov]
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function fetchRetainedOverview(range: TimelineRange): Promise<TimelineOverviewResult> {
|
|
552
|
+
const res = await apiFetch(apiUrl(`/timeline/overview?from=${Math.round(range.from)}&to=${Math.round(range.to)}`))
|
|
553
|
+
if (!res.ok) {
|
|
554
|
+
const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
555
|
+
throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
|
|
556
|
+
}
|
|
557
|
+
const body: unknown = await res.json()
|
|
558
|
+
const env = body as { buckets?: RawOverviewBucket[]; availableFromMs?: number }
|
|
559
|
+
const buckets: TimelineOverviewBucket[] = (env.buckets ?? []).map((b) => ({
|
|
560
|
+
startMs: b.hourStartMs,
|
|
561
|
+
summary: b.summary,
|
|
562
|
+
coverage: normalizeCoverage(b.coverage),
|
|
563
|
+
}))
|
|
564
|
+
return { buckets, availableFromMs: env.availableFromMs }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function createRetainedSource(config: TimelineSourceConfig): TimelineSource {
|
|
568
|
+
const capabilities: TimelineSourceCapabilities = {
|
|
569
|
+
mode: 'retained',
|
|
570
|
+
maxRangeDays: config.maxRangeDays,
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
capabilities,
|
|
574
|
+
useEvents: createRetainedEventsHook(capabilities),
|
|
575
|
+
fetchOverview: fetchRetainedOverview,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function resolveTimelineSource(config?: TimelineSourceConfig): TimelineSource {
|
|
580
|
+
if (config?.mode === 'retained') return createRetainedSource(config)
|
|
581
|
+
return localSource
|
|
582
|
+
}
|