@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
@@ -0,0 +1,270 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Loader2, Wifi, WifiOff } from 'lucide-react'
3
+ import {
4
+ AreaChart,
5
+ SeriesLegend,
6
+ computeSaturation,
7
+ type ReferenceLine,
8
+ } from '@skyhook-io/k8s-ui/components/charts'
9
+ import { SEVERITY_BADGE, SEVERITY_TEXT, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
10
+ import {
11
+ usePrometheusStatus,
12
+ usePrometheusConnect,
13
+ usePrometheusResourceMetrics,
14
+ useAutoPromConnect,
15
+ type PrometheusMetricCategory,
16
+ type PrometheusTimeRange,
17
+ } from '../../api/client'
18
+ import {
19
+ MetricsSummary,
20
+ TIME_RANGES,
21
+ WORKLOAD_CATEGORIES,
22
+ NODE_CATEGORIES,
23
+ computeRequestLimitLines,
24
+ type CategoryDef,
25
+ } from './PrometheusCharts'
26
+ import { RestartEventLane } from './RestartChart'
27
+ import { Tooltip } from '../ui/Tooltip'
28
+
29
+ // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
30
+ // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
31
+ // can't fit the grid cleanly.
32
+ export interface PrometheusChartsGridProps {
33
+ kind: string
34
+ namespace: string
35
+ name: string
36
+ /** Optional full K8s resource for request/limit overlay derivation. */
37
+ resource?: any
38
+ }
39
+
40
+ const SUPPORTED_KINDS = new Set([
41
+ 'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
42
+ ])
43
+
44
+ export function PrometheusChartsGrid({
45
+ kind,
46
+ namespace,
47
+ name,
48
+ resource,
49
+ }: PrometheusChartsGridProps) {
50
+ useAutoPromConnect()
51
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
52
+ const connectMutation = usePrometheusConnect()
53
+ const isConnected = status?.connected === true
54
+ const isSupported = SUPPORTED_KINDS.has(kind)
55
+ const showRestartLane = isSupported && kind !== 'Node'
56
+
57
+ const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
58
+
59
+ const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
60
+
61
+ // CPU + memory get reference-line overlays when a resource is provided.
62
+ // Computed once at the parent so each panel can stay otherwise generic.
63
+ const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
64
+ () => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
65
+ [resource, kind],
66
+ )
67
+ const memRefLines = useMemo<ReferenceLine[] | undefined>(
68
+ () => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
69
+ [resource, kind],
70
+ )
71
+
72
+ if (!isSupported) return null
73
+
74
+ if (statusLoading) {
75
+ return (
76
+ <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
77
+ <Loader2 className="w-5 h-5 animate-spin mr-2" />
78
+ Checking Prometheus availability...
79
+ </div>
80
+ )
81
+ }
82
+
83
+ if (!isConnected) {
84
+ return (
85
+ <div className="flex flex-col items-center justify-center py-12 gap-4">
86
+ <WifiOff className="w-10 h-10 text-theme-text-quaternary" />
87
+ <div className="text-center">
88
+ <p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
89
+ <p className="text-xs text-theme-text-tertiary mb-4">
90
+ {status?.error || 'Connect to view historical CPU, memory, and network metrics'}
91
+ </p>
92
+ <button
93
+ onClick={() => connectMutation.mutate()}
94
+ disabled={connectMutation.isPending}
95
+ className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
96
+ >
97
+ {connectMutation.isPending ? (
98
+ <Loader2 className="w-4 h-4 animate-spin" />
99
+ ) : (
100
+ <Wifi className="w-4 h-4" />
101
+ )}
102
+ Discover Prometheus
103
+ </button>
104
+ </div>
105
+ </div>
106
+ )
107
+ }
108
+
109
+ const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
110
+ categories.find(c => c.key === key)
111
+
112
+ const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
113
+ const cpu = findCategory('cpu')
114
+ if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
115
+ const mem = findCategory('memory')
116
+ if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
117
+ if (kind !== 'Node') {
118
+ const rx = findCategory('network_rx')
119
+ if (rx) primaryCats.push({ def: rx })
120
+ const tx = findCategory('network_tx')
121
+ if (tx) primaryCats.push({ def: tx })
122
+ }
123
+ const disk = findCategory('filesystem')
124
+ const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
125
+
126
+ return (
127
+ <div className="flex flex-col h-full overflow-auto">
128
+ <div className="flex shrink-0 justify-end px-4 pt-3">
129
+ <select
130
+ aria-label="Metrics time range"
131
+ value={timeRange}
132
+ onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
133
+ className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
134
+ >
135
+ {TIME_RANGES.map(tr => (
136
+ <option key={tr.value} value={tr.value}>{tr.label}</option>
137
+ ))}
138
+ </select>
139
+ </div>
140
+
141
+ {/* Restart lane sits above the grid so its markers visually align with
142
+ the time axis of the charts below. */}
143
+ {showRestartLane && (
144
+ <div className="px-4 pt-3">
145
+ <RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
146
+ </div>
147
+ )}
148
+
149
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
150
+ {chartPanels.map(({ def, refLines }) => (
151
+ <MetricsPanel
152
+ key={def.key}
153
+ category={def}
154
+ kind={kind}
155
+ namespace={namespace}
156
+ name={name}
157
+ timeRange={timeRange}
158
+ referenceLines={refLines}
159
+ />
160
+ ))}
161
+ </div>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ interface MetricsPanelProps {
167
+ category: CategoryDef
168
+ kind: string
169
+ namespace: string
170
+ name: string
171
+ timeRange: PrometheusTimeRange
172
+ referenceLines?: ReferenceLine[]
173
+ }
174
+
175
+ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
176
+ const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
177
+ kind, namespace, name, category.key, timeRange, true,
178
+ )
179
+
180
+ const series = metrics?.result?.series
181
+ const hasData = (series?.length ?? 0) > 0
182
+
183
+ // "% of limit / request" derived from current peak vs reference lines.
184
+ // Without this, a low-utilization workload with a high limit looks like
185
+ // an empty chart — the user can't tell healthy from starved at a glance.
186
+ const saturation = hasData && series && referenceLines
187
+ ? computeSaturation(series, referenceLines)
188
+ : undefined
189
+
190
+ return (
191
+ <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
192
+ <header className="flex items-center justify-between mb-2 gap-3">
193
+ <div className="flex items-center gap-2">
194
+ <h3 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
195
+ {category.label}
196
+ </h3>
197
+ {saturation && <SaturationChip {...saturation} />}
198
+ </div>
199
+ {hasData && series && (
200
+ <MetricsSummary series={series} category={category} unit={metrics!.unit} />
201
+ )}
202
+ </header>
203
+
204
+ <div className="flex-1 min-h-[200px]">
205
+ {isLoading ? (
206
+ <PanelLoading />
207
+ ) : error ? (
208
+ <PanelError message={(error as Error).message} />
209
+ ) : hasData && series ? (
210
+ <>
211
+ <AreaChart
212
+ series={series}
213
+ color={category.chartColor}
214
+ fillColor={category.fillColor}
215
+ unit={metrics!.unit}
216
+ referenceLines={referenceLines}
217
+ />
218
+ {series.length > 1 && (
219
+ <div className="mt-1.5">
220
+ <SeriesLegend series={series} color={category.chartColor} />
221
+ </div>
222
+ )}
223
+ </>
224
+ ) : (
225
+ <PanelNoData hint={metrics?.hint} />
226
+ )}
227
+ </div>
228
+ </section>
229
+ )
230
+ }
231
+
232
+ function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
233
+ // Thresholds match the rightsizing tone vocabulary: amber from 75% (start
234
+ // watching), red at 90% (the same OOM-risk boundary the backend uses for
235
+ // memory in classifyRightsizing).
236
+ const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
237
+ const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
238
+ return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
239
+ }
240
+
241
+ function PanelLoading() {
242
+ return (
243
+ <div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
244
+ <Loader2 className="w-4 h-4 animate-spin mr-2" />
245
+ Loading…
246
+ </div>
247
+ )
248
+ }
249
+
250
+ function PanelError({ message }: { message: string }) {
251
+ return (
252
+ <div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
253
+ Query failed
254
+ <Tooltip content={message} wrapperClassName="!block w-full">
255
+ <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">{message}</span>
256
+ </Tooltip>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ function PanelNoData({ hint }: { hint?: string }) {
262
+ return (
263
+ <div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
264
+ No data
265
+ {hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
266
+ </div>
267
+ )
268
+ }
269
+
270
+ export { isPrometheusSupported } from './PrometheusCharts'
@@ -0,0 +1,133 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import { AlertCircle } from 'lucide-react'
3
+ import { usePrometheusResourceMetrics, usePrometheusStatus, type PrometheusSeries, type PrometheusTimeRange } from '../../api/client'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ /**
7
+ * RestartEventLane — vertical markers at each restart event, on a dedicated
8
+ * row below the chart. Markers stay readable when they cluster because they
9
+ * don't overlay the chart waveform. KSM-gated (uses kube_pod_container_status_restarts_total)
10
+ * — silently hidden when Prom isn't connected or the series doesn't exist.
11
+ */
12
+ export function RestartEventLane({ kind, namespace, name, range = '1h' }: {
13
+ kind: string
14
+ namespace: string
15
+ name: string
16
+ range?: PrometheusTimeRange
17
+ }) {
18
+ const { data: status } = usePrometheusStatus()
19
+ const isConnected = status?.connected === true
20
+ const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(kind, namespace, name, 'restarts', range, isConnected)
21
+
22
+ const restarts = useMemo(() => collectRestartEvents(metrics?.result?.series), [metrics])
23
+
24
+ // A real Prom-side failure shouldn't look identical to "no restarts" — log
25
+ // it so an operator investigating a missing lane has a breadcrumb. The lane
26
+ // still hides because we don't want a permanent red banner on every pod.
27
+ // Effect-gated so we log once per error change, not on every re-render.
28
+ useEffect(() => {
29
+ if (error) {
30
+ console.warn('[RestartEventLane] restart query failed', error)
31
+ }
32
+ }, [error])
33
+
34
+ if (!isConnected || isLoading) return null
35
+ if (restarts.length === 0) return null
36
+
37
+ // Position markers within the chart's full time window — not the
38
+ // min/max of detected events — so a cluster of restarts at the start
39
+ // of the window doesn't visually spread across the whole lane.
40
+ const nowSec = Date.now() / 1000
41
+ const windowStart = nowSec - rangeToSeconds(range)
42
+ const span = nowSec - windowStart
43
+
44
+ return (
45
+ <div className="rounded-md border border-amber-500/20 bg-amber-500/[0.04] px-3 py-2">
46
+ <div className="flex items-center gap-2 mb-1.5">
47
+ <AlertCircle className="w-3.5 h-3.5 text-amber-500/70" />
48
+ <span className="text-xs font-medium text-theme-text-secondary">
49
+ Restarts in last {range}
50
+ </span>
51
+ <span className="text-xs text-theme-text-quaternary tabular-nums">
52
+ {restarts.reduce((n, r) => n + r.value, 0)} total
53
+ </span>
54
+ </div>
55
+ <div className="relative h-5">
56
+ {/* Baseline */}
57
+ <div className="absolute inset-x-0 top-1/2 h-px bg-theme-border/40" />
58
+ {/* Markers */}
59
+ {restarts.map((r, i) => {
60
+ const left = `${Math.max(0, Math.min(100, ((r.timestamp - windowStart) / span) * 100))}%`
61
+ return (
62
+ <Tooltip
63
+ key={i}
64
+ content={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
65
+ wrapperClassName="absolute top-0 h-full w-px bg-amber-500/80"
66
+ wrapperStyle={{ left }}
67
+ >
68
+ <div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-500" />
69
+ </Tooltip>
70
+ )
71
+ })}
72
+ </div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ // ============================================================================
78
+ // Internals
79
+ // ============================================================================
80
+
81
+ interface RestartEvent {
82
+ timestamp: number
83
+ value: number
84
+ label: string
85
+ }
86
+
87
+ function collectRestartEvents(series: PrometheusSeries[] | undefined): RestartEvent[] {
88
+ if (!series) return []
89
+ const events: RestartEvent[] = []
90
+ for (const s of series) {
91
+ const pod = s.labels.pod ?? 'pod'
92
+ // `changes(...[1h])` produces a rolling count, so a single restart shows
93
+ // value=1 for ~60 consecutive samples (the whole 1h window). Emit a marker
94
+ // only when the count increases — that's when a *new* restart entered the
95
+ // window — and use the increase as the marker's restart count.
96
+ let prev: number | null = null
97
+ for (const dp of s.dataPoints) {
98
+ // Skip gap samples and drop the previous reference, so the next finite
99
+ // sample isn't diffed across the gap — a delta across a gap is
100
+ // meaningless and could fabricate a restart count. We compare only
101
+ // consecutive finite samples.
102
+ if (dp.value == null) {
103
+ prev = null
104
+ continue
105
+ }
106
+ if (prev !== null) {
107
+ // Only count positive deltas — restarts that entered the rolling 1h
108
+ // window during the chart range. The first sample's value covers
109
+ // [start-1h, start] which is outside the user's chosen window; counting
110
+ // it would inflate the total with pre-window restarts.
111
+ const delta = dp.value - prev
112
+ if (delta > 0) {
113
+ events.push({ timestamp: dp.timestamp, value: delta, label: pod })
114
+ }
115
+ }
116
+ prev = dp.value
117
+ }
118
+ }
119
+ events.sort((a, b) => a.timestamp - b.timestamp)
120
+ return events
121
+ }
122
+
123
+ function rangeToSeconds(range: PrometheusTimeRange): number {
124
+ const match = range.match(/^(\d+)([mhd])$/)
125
+ if (!match) return 3600 // default to 1h if unrecognized
126
+ const n = parseInt(match[1], 10)
127
+ switch (match[2]) {
128
+ case 'm': return n * 60
129
+ case 'h': return n * 3600
130
+ case 'd': return n * 86400
131
+ default: return 3600
132
+ }
133
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RightsizingRow } from '../../api/client'
3
+ import {
4
+ getRightsizingExplanation,
5
+ getRightsizingPresentation,
6
+ RIGHTSIZING_METHODOLOGY,
7
+ RIGHTSIZING_DOCS_URL,
8
+ RIGHTSIZING_SUMMARY,
9
+ } from './RightsizingStrip'
10
+
11
+ const row = (overrides: Partial<RightsizingRow> = {}): RightsizingRow => ({
12
+ container: 'server',
13
+ resource: 'cpu',
14
+ fit: 'balanced',
15
+ confidence: 'high',
16
+ sampleCount: 2016,
17
+ expectedSamples: 2016,
18
+ coverage: 1,
19
+ hpaManaged: false,
20
+ hpaEvidenceAvailable: true,
21
+ oomEvidenceAvailable: true,
22
+ throttleAvailable: true,
23
+ ...overrides,
24
+ })
25
+
26
+ describe('rightsizing presentation', () => {
27
+ it('keeps fit, confidence, and runtime risk as separate concepts', () => {
28
+ expect(getRightsizingPresentation('oversized')).toEqual({
29
+ label: 'Oversized',
30
+ severity: 'info',
31
+ })
32
+ expect(getRightsizingPresentation('under_requested')).toEqual({
33
+ label: 'Under-requested',
34
+ severity: 'warning',
35
+ })
36
+ expect(getRightsizingPresentation('insufficient_history')).toEqual({
37
+ label: 'Insufficient history',
38
+ severity: 'neutral',
39
+ })
40
+ })
41
+
42
+ it('labels query failures independently from insufficient history', () => {
43
+ expect(getRightsizingPresentation('insufficient_history', 'usage query failed')).toEqual({
44
+ label: 'Query failed',
45
+ severity: 'error',
46
+ })
47
+ })
48
+
49
+ it('explains why recommendations are withheld without inventing a zero-risk result', () => {
50
+ expect(getRightsizingExplanation(row({ recommendationReason: 'hpa_managed' }))).toContain(
51
+ 'HPA manages cpu',
52
+ )
53
+ expect(
54
+ getRightsizingExplanation(row({ resource: 'memory', recommendationReason: 'oom_evidence' })),
55
+ ).toContain('OOM evidence')
56
+ expect(
57
+ getRightsizingExplanation(row({ recommendationReason: 'hpa_evidence_unavailable' })),
58
+ ).toContain('could not verify HPA')
59
+ expect(
60
+ getRightsizingExplanation(
61
+ row({
62
+ resource: 'memory',
63
+ recommendationReason: 'oom_evidence_unavailable',
64
+ }),
65
+ ),
66
+ ).toContain('could not verify recent OOM')
67
+ expect(getRightsizingExplanation(row({ throttleAvailable: false }))).toContain(
68
+ 'throttling metrics are unavailable',
69
+ )
70
+ })
71
+
72
+ it('separates the demand target from a conservative reduction step', () => {
73
+ const explanation = getRightsizingExplanation(
74
+ row({
75
+ fit: 'oversized',
76
+ currentRequest: '1',
77
+ recommendedRequest: '500m',
78
+ calculatedRequest: '10m',
79
+ reductionLimited: true,
80
+ bursty: true,
81
+ peak: { name: 'P99', value: 0.2, formatted: '200m' },
82
+ }),
83
+ )
84
+ expect(explanation).toContain('Demand-based target: 10m')
85
+ expect(explanation).toContain('conservative next step')
86
+ expect(explanation).toContain('CPU P99 reached 200m')
87
+ })
88
+
89
+ it('does not revive the misleading efficiency vocabulary', () => {
90
+ const copy = [
91
+ getRightsizingPresentation('balanced').label,
92
+ getRightsizingPresentation('oversized').label,
93
+ getRightsizingExplanation(row({ recommendationReason: 'request_within_fit_range' })),
94
+ ]
95
+ .join(' ')
96
+ .toLowerCase()
97
+ expect(copy).not.toContain('efficien')
98
+ expect(copy).not.toContain('waste')
99
+ })
100
+
101
+ it('sets the workload-level scope and methodology without promising savings or changes', () => {
102
+ expect(RIGHTSIZING_SUMMARY).toContain('this workload')
103
+ expect(RIGHTSIZING_SUMMARY).toContain('not a savings estimate or automatic change')
104
+ expect(RIGHTSIZING_METHODOLOGY).toContain('CPU P95 and memory maximum')
105
+ expect(RIGHTSIZING_METHODOLOGY).toContain('Reductions are staged')
106
+ expect(RIGHTSIZING_METHODOLOGY).toContain('Radar does not change requests')
107
+ expect(RIGHTSIZING_DOCS_URL).toContain('/features/rightsizing')
108
+ })
109
+ })