@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,24 +1,29 @@
1
- import { useState, useMemo, useRef, useCallback } from 'react'
2
- import { clsx } from 'clsx'
3
- import { BarChart3, Wifi, WifiOff, Loader2 } from 'lucide-react'
1
+ import { useState, useMemo } from 'react'
2
+ import {
3
+ PrometheusChartsView,
4
+ MetricsSummary as BaseMetricsSummary,
5
+ type TimeSeries,
6
+ type ReferenceLine,
7
+ } from '@skyhook-io/k8s-ui/components/charts'
4
8
  import {
5
9
  usePrometheusStatus,
6
10
  usePrometheusConnect,
7
11
  usePrometheusResourceMetrics,
12
+ useAutoPromConnect,
8
13
  type PrometheusMetricCategory,
9
14
  type PrometheusTimeRange,
10
- type PrometheusSeries,
11
15
  } from '../../api/client'
12
16
 
13
17
  // ============================================================================
14
- // Types & Constants
18
+ // Radar-specific category config (lives here, not in k8s-ui, so consumers
19
+ // reusing AreaChart aren't forced to inherit Radar's category vocabulary).
15
20
  // ============================================================================
16
21
 
17
22
  const SUPPORTED_KINDS = new Set([
18
23
  'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
19
24
  ])
20
25
 
21
- interface CategoryDef {
26
+ export interface CategoryDef {
22
27
  key: PrometheusMetricCategory
23
28
  label: string
24
29
  color: string // tailwind text class
@@ -26,7 +31,7 @@ interface CategoryDef {
26
31
  fillColor: string // hex with alpha for SVG fill
27
32
  }
28
33
 
29
- const WORKLOAD_CATEGORIES: CategoryDef[] = [
34
+ export const WORKLOAD_CATEGORIES: CategoryDef[] = [
30
35
  { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
31
36
  { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
32
37
  { key: 'network_rx', label: 'Net RX', color: 'text-emerald-400', chartColor: '#34d399', fillColor: '#34d39922' },
@@ -34,28 +39,13 @@ const WORKLOAD_CATEGORIES: CategoryDef[] = [
34
39
  { key: 'filesystem', label: 'Disk I/O', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
35
40
  ]
36
41
 
37
- const NODE_CATEGORIES: CategoryDef[] = [
42
+ export const NODE_CATEGORIES: CategoryDef[] = [
38
43
  { key: 'cpu', label: 'CPU', color: 'text-blue-400', chartColor: '#60a5fa', fillColor: '#60a5fa22' },
39
44
  { key: 'memory', label: 'Memory', color: 'text-purple-400', chartColor: '#c084fc', fillColor: '#c084fc22' },
40
45
  { key: 'filesystem', label: 'Disk', color: 'text-amber-400', chartColor: '#fbbf24', fillColor: '#fbbf2422' },
41
46
  ]
42
47
 
43
- // Distinct colors for multi-series charts (up to 10 series).
44
- // Uses 500-level shades for adequate contrast on both dark (#1e293b) and light (#ffffff) surfaces.
45
- const SERIES_COLORS = [
46
- '#3b82f6', // blue-500
47
- '#10b981', // emerald-500
48
- '#f97316', // orange-500
49
- '#a855f7', // purple-500
50
- '#ec4899', // pink-500
51
- '#eab308', // yellow-500
52
- '#06b6d4', // cyan-500
53
- '#84cc16', // lime-500
54
- '#ef4444', // red-500
55
- '#6366f1', // indigo-500
56
- ]
57
-
58
- const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
48
+ export const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
59
49
  { value: '10m', label: '10m' },
60
50
  { value: '30m', label: '30m' },
61
51
  { value: '1h', label: '1h' },
@@ -66,6 +56,16 @@ const TIME_RANGES: { value: PrometheusTimeRange; label: string }[] = [
66
56
  { value: '7d', label: '7d' },
67
57
  ]
68
58
 
59
+ // Radar's MetricsSummary thin wrapper — adapts CategoryDef to the slim
60
+ // interface of the shared k8s-ui primitive so callers downstream don't change.
61
+ export function MetricsSummary({ series, category, unit }: {
62
+ series: TimeSeries[]
63
+ category: CategoryDef
64
+ unit: string
65
+ }) {
66
+ return <BaseMetricsSummary series={series} unit={unit} currentColorClass={category.color} />
67
+ }
68
+
69
69
  // ============================================================================
70
70
  // Main Component
71
71
  // ============================================================================
@@ -76,13 +76,22 @@ interface PrometheusChartsProps {
76
76
  name: string
77
77
  /** When true, show "no data" empty state instead of hiding. Defaults to false (hide when no data). */
78
78
  showEmptyState?: boolean
79
+ /**
80
+ * Full K8s resource. When provided, CPU and memory charts overlay the
81
+ * aggregate request/limit (summed across runtime containers including
82
+ * native sidecars, multiplied by readyReplicas for replicated workloads).
83
+ */
84
+ resource?: any
85
+ /** Notifies the parent when the user changes the time range, so sibling
86
+ * panels (e.g. restart lane) can mirror the selection. */
87
+ onTimeRangeChange?: (range: PrometheusTimeRange) => void
79
88
  }
80
89
 
81
- export function PrometheusCharts({ kind, namespace, name, showEmptyState = false }: PrometheusChartsProps) {
90
+ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false, resource, onTimeRangeChange }: PrometheusChartsProps) {
91
+ useAutoPromConnect()
82
92
  const { data: status, isLoading: statusLoading } = usePrometheusStatus()
83
93
  const connectMutation = usePrometheusConnect()
84
94
 
85
- const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
86
95
  const [activeCategory, setActiveCategory] = useState<PrometheusMetricCategory>('cpu')
87
96
  const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
88
97
 
@@ -95,587 +104,154 @@ export function PrometheusCharts({ kind, namespace, name, showEmptyState = false
95
104
  isConnected && isSupported,
96
105
  )
97
106
 
98
- if (!isSupported) {
99
- return null
100
- }
101
-
102
- // Loading state checking Prometheus availability (only show when explicitly requested)
103
- if (statusLoading) {
104
- if (!showEmptyState) return null
105
- return (
106
- <div className="flex items-center justify-center py-12 text-theme-text-tertiary">
107
- <Loader2 className="w-5 h-5 animate-spin mr-2" />
108
- Checking Prometheus availability...
109
- </div>
110
- )
111
- }
112
-
113
- // When embedded in Overview (showEmptyState=false), hide when not connected or no data
114
- if (!showEmptyState) {
115
- if (!isConnected) return null
116
- if (!metricsLoading && !metricsError && !metrics?.result?.series?.length) return null
117
- }
118
-
119
- if (!isConnected) {
120
- return (
121
- <div className="flex flex-col items-center justify-center py-12 gap-4">
122
- <WifiOff className="w-10 h-10 text-theme-text-quaternary" />
123
- <div className="text-center">
124
- <p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
125
- <p className="text-xs text-theme-text-tertiary mb-4">
126
- {status?.error || 'Connect to view historical CPU, memory, and network metrics'}
127
- </p>
128
- <button
129
- onClick={() => connectMutation.mutate()}
130
- disabled={connectMutation.isPending}
131
- className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg btn-brand"
132
- >
133
- {connectMutation.isPending ? (
134
- <Loader2 className="w-4 h-4 animate-spin" />
135
- ) : (
136
- <Wifi className="w-4 h-4" />
137
- )}
138
- Discover Prometheus
139
- </button>
140
- </div>
141
- </div>
142
- )
143
- }
144
-
145
- const activeCategoryDef = categories.find(c => c.key === activeCategory) || categories[0]
107
+ // Reference lines: aggregate request/limit overlaid on CPU and memory charts.
108
+ // Computed unconditionally and placed above early returns to keep hook order
109
+ // stable when connection state transitions (Rules of Hooks).
110
+ const referenceLines = useMemo<ReferenceLine[] | undefined>(() => {
111
+ if (!resource || (activeCategory !== 'cpu' && activeCategory !== 'memory')) return undefined
112
+ return computeRequestLimitLines(resource, kind, activeCategory)
113
+ }, [resource, kind, activeCategory])
146
114
 
147
115
  return (
148
- <div className="flex flex-col h-full">
149
- {/* Toolbar */}
150
- <div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-theme-border bg-theme-surface/50">
151
- {/* Category tabs */}
152
- <div className="flex items-center gap-1">
153
- <BarChart3 className="w-4 h-4 text-theme-text-tertiary mr-2" />
154
- {categories.map(cat => (
155
- <button
156
- key={cat.key}
157
- onClick={() => setActiveCategory(cat.key)}
158
- className={clsx(
159
- 'px-2.5 py-1 text-xs font-medium rounded-md transition-colors',
160
- activeCategory === cat.key
161
- ? 'bg-theme-elevated text-theme-text-primary shadow-sm'
162
- : 'text-theme-text-tertiary hover:text-theme-text-secondary hover:bg-theme-elevated/50'
163
- )}
164
- >
165
- {cat.label}
166
- </button>
167
- ))}
168
- </div>
169
-
170
- {/* Time range selector */}
171
- <select
172
- value={timeRange}
173
- onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
174
- className="px-2 py-1 text-xs rounded-md bg-theme-elevated border border-theme-border text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
175
- >
176
- {TIME_RANGES.map(tr => (
177
- <option key={tr.value} value={tr.value}>{tr.label}</option>
178
- ))}
179
- </select>
180
- </div>
181
-
182
- {/* Chart area — fixed min-height prevents layout shift while loading */}
183
- <div className="min-h-[280px] p-4">
184
- {metricsLoading ? (
185
- <div className="flex items-center justify-center min-h-[240px] text-theme-text-tertiary">
186
- <Loader2 className="w-5 h-5 animate-spin mr-2" />
187
- Loading metrics...
188
- </div>
189
- ) : metricsError ? (
190
- <div className="flex items-center justify-center h-full text-red-400 text-sm">
191
- Failed to load metrics: {(metricsError as Error).message}
192
- </div>
193
- ) : metrics?.result?.series?.length ? (
194
- <div className="h-full flex flex-col gap-4">
195
- {/* Summary stats */}
196
- <MetricsSummary
197
- series={metrics.result.series}
198
- category={activeCategoryDef}
199
- unit={metrics.unit}
200
- />
201
-
202
- {/* Main chart */}
203
- <div className="flex-1 min-h-0">
204
- <AreaChart
205
- series={metrics.result.series}
206
- color={activeCategoryDef.chartColor}
207
- fillColor={activeCategoryDef.fillColor}
208
- unit={metrics.unit}
209
- />
210
- </div>
211
-
212
- {/* Per-pod legend for workload-level queries */}
213
- {metrics.result.series.length > 1 && (
214
- <SeriesLegend series={metrics.result.series} color={activeCategoryDef.chartColor} />
215
- )}
216
- </div>
217
- ) : (
218
- <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
219
- <BarChart3 className="w-8 h-8 mb-2 opacity-40" />
220
- <p className="text-sm">No data for this time range</p>
221
- <p className="text-xs text-theme-text-quaternary mt-1">
222
- Try a different time range or check that metrics are being collected
223
- </p>
224
- {metrics?.hint && (
225
- <p className="mt-3 px-3 py-2 w-full max-w-lg text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded">
226
- {metrics.hint}
227
- </p>
228
- )}
229
- {metrics?.query && (
230
- <details className="mt-3 w-full max-w-lg text-left">
231
- <summary className="text-xs text-theme-text-quaternary cursor-pointer hover:text-theme-text-tertiary">
232
- Diagnostics: show PromQL query
233
- </summary>
234
- <div className="mt-2 p-2 bg-theme-base border border-theme-border rounded text-xs font-mono text-theme-text-secondary break-all">
235
- {metrics.query}
236
- </div>
237
- <p className="mt-1.5 text-xs text-theme-text-quaternary">
238
- This query returned no results. Verify in your Prometheus UI that the metric names and labels
239
- ({activeCategoryDef.key === 'cpu' ? 'pod, namespace, container' : 'pod, namespace'}) exist.
240
- Custom label relabeling in your Prometheus configuration may require adjustments.
241
- </p>
242
- </details>
243
- )}
244
- </div>
245
- )}
246
- </div>
247
- </div>
116
+ <PrometheusChartsView
117
+ kind={kind}
118
+ showEmptyState={showEmptyState}
119
+ statusLoading={statusLoading}
120
+ isConnected={isConnected}
121
+ statusError={status?.error}
122
+ onConnect={() => connectMutation.mutate()}
123
+ connecting={connectMutation.isPending}
124
+ category={activeCategory}
125
+ onCategoryChange={setActiveCategory}
126
+ range={timeRange}
127
+ onRangeChange={(r) => { setTimeRange(r); onTimeRangeChange?.(r) }}
128
+ metrics={metrics}
129
+ metricsLoading={metricsLoading}
130
+ metricsError={(metricsError as Error) ?? null}
131
+ referenceLines={referenceLines}
132
+ />
248
133
  )
249
134
  }
250
135
 
136
+
251
137
  // ============================================================================
252
- // Sub-Components
138
+ // Request/limit overlay derivation
253
139
  // ============================================================================
254
140
 
255
- function MetricsSummary({ series, category, unit }: {
256
- series: PrometheusSeries[]
257
- category: CategoryDef
258
- unit: string
259
- }) {
260
- const stats = useMemo(() => {
261
- // Aggregate all data points across series
262
- const allValues: number[] = []
263
- for (const s of series) {
264
- for (const dp of s.dataPoints) {
265
- allValues.push(dp.value)
266
- }
267
- }
268
- if (allValues.length === 0) return null
269
-
270
- // Latest = sum of each series' most recent data point
271
- const lastValues = series.map(s => s.dataPoints[s.dataPoints.length - 1]?.value ?? 0)
272
- const current = lastValues.reduce((a, b) => a + b, 0)
273
- const max = Math.max(...allValues)
274
- const avg = allValues.reduce((a, b) => a + b, 0) / allValues.length
275
-
276
- return { current, max, avg }
277
- }, [series])
278
-
279
- if (!stats) return null
141
+ /**
142
+ * Compute aggregate request + limit reference lines from a K8s resource spec.
143
+ * Sums across runtime containers (regular + native sidecars), excluding pure
144
+ * init containers. The values are per-pod — workload charts use
145
+ * `sum(...) by (pod, namespace)` (one series per pod, at per-pod scale), so
146
+ * the reference line lives on the same axis without any replica multiplier.
147
+ *
148
+ * Returns undefined when the spec doesn't have enough information to render
149
+ * a meaningful line (no runtime containers, or no values set on any container).
150
+ */
151
+ export function computeRequestLimitLines(
152
+ resource: any,
153
+ kind: string,
154
+ category: 'cpu' | 'memory',
155
+ ): ReferenceLine[] | undefined {
156
+ if (!resource) return undefined
157
+ const podSpec = extractPodSpec(resource, kind)
158
+ if (!podSpec) return undefined
159
+
160
+ const runtimeContainers = collectRuntimeContainers(podSpec)
161
+ if (runtimeContainers.length === 0) return undefined
162
+
163
+ let reqSum = 0, reqAny = false
164
+ let limSum = 0, limAny = false
165
+ for (const c of runtimeContainers) {
166
+ const req = readQuantity(c.resources?.requests?.[category], category)
167
+ const lim = readQuantity(c.resources?.limits?.[category], category)
168
+ if (req != null) { reqSum += req; reqAny = true }
169
+ if (lim != null) { limSum += lim; limAny = true }
170
+ }
280
171
 
281
- return (
282
- <div className="flex items-center gap-6">
283
- <StatPill label="Current" value={formatMetricValue(stats.current, unit)} className={category.color} />
284
- <StatPill label="Average" value={formatMetricValue(stats.avg, unit)} className="text-theme-text-secondary" />
285
- <StatPill label="Peak" value={formatMetricValue(stats.max, unit)} className="text-theme-text-secondary" />
286
- </div>
287
- )
172
+ const lines: ReferenceLine[] = []
173
+ if (reqAny) {
174
+ lines.push({
175
+ value: reqSum,
176
+ label: `request ${formatRequestLimitLabel(reqSum, category)}`,
177
+ kind: 'request',
178
+ })
179
+ }
180
+ if (limAny) {
181
+ lines.push({
182
+ value: limSum,
183
+ label: `limit ${formatRequestLimitLabel(limSum, category)}`,
184
+ kind: 'limit',
185
+ })
186
+ }
187
+ return lines.length > 0 ? lines : undefined
288
188
  }
289
189
 
290
- function StatPill({ label, value, className }: { label: string; value: string; className?: string }) {
291
- return (
292
- <div className="flex items-baseline gap-1.5">
293
- <span className="text-xs text-theme-text-quaternary uppercase tracking-wide">{label}</span>
294
- <span className={clsx('text-sm font-semibold tabular-nums', className)}>{value}</span>
295
- </div>
296
- )
190
+ function extractPodSpec(resource: any, kind: string): any | undefined {
191
+ if (kind === 'Pod') return resource?.spec
192
+ if (kind === 'CronJob') return resource?.spec?.jobTemplate?.spec?.template?.spec
193
+ return resource?.spec?.template?.spec
297
194
  }
298
195
 
299
- // ============================================================================
300
- // Area Chart (pure SVG, no dependencies)
301
- // ============================================================================
302
-
303
- function seriesColor(index: number, fallback: string): string {
304
- return SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback
196
+ function collectRuntimeContainers(podSpec: any): any[] {
197
+ const out: any[] = []
198
+ for (const c of (podSpec?.containers || [])) out.push(c)
199
+ // Native sidecars (initContainers with restartPolicy: Always, GA in 1.33)
200
+ // run for the pod's lifetime and contribute to steady-state usage. Pure
201
+ // init containers run to completion and don't.
202
+ for (const c of (podSpec?.initContainers || [])) {
203
+ if (c?.restartPolicy === 'Always') out.push(c)
204
+ }
205
+ return out
305
206
  }
306
207
 
307
- function seriesFill(index: number, fallback: string): string {
308
- return (SERIES_COLORS[index % SERIES_COLORS.length] ?? fallback) + '22'
208
+ const CPU_SUFFIXES: Record<string, number> = { n: 1e-9, u: 1e-6, m: 1e-3 }
209
+ const MEMORY_SUFFIXES: Record<string, number> = {
210
+ Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4, Pi: 1024 ** 5, Ei: 1024 ** 6,
211
+ K: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15, E: 1e18,
309
212
  }
310
213
 
311
- // Compute short labels that strip the shared prefix so pods are distinguishable.
312
- // e.g. ["backend-podinfo-849bd668f9-4tzkg", "backend-podinfo-849bd668f9-5z79f"] ["4tzkg", "5z79f"]
313
- function computeShortLabels(labels: string[]): string[] {
314
- if (labels.length <= 1) return labels
315
- // Find longest common prefix
316
- let prefix = labels[0]
317
- for (let i = 1; i < labels.length; i++) {
318
- while (!labels[i].startsWith(prefix)) {
319
- prefix = prefix.slice(0, -1)
320
- }
214
+ // NOT a duplicate of @skyhook-io/k8s-ui/utils/format's parseCPUToNanocores /
215
+ // parseMemoryToBytes — those return 0 on invalid input. We need null so that
216
+ // "abcMi" doesn't silently poison the caller's running sum and produce a
217
+ // missing/zeroed reference line on the chart (real garbage data must be
218
+ // distinguishable from a legit 0).
219
+ function readQuantity(raw: unknown, category: 'cpu' | 'memory'): number | null {
220
+ if (raw == null) return null
221
+ const s = String(raw).trim()
222
+ if (s === '') return null
223
+ if (category === 'cpu') {
224
+ if (s.endsWith('m')) return scaleOrNull(s, CPU_SUFFIXES.m)
225
+ if (s.endsWith('n')) return scaleOrNull(s, CPU_SUFFIXES.n)
226
+ if (s.endsWith('u')) return scaleOrNull(s, CPU_SUFFIXES.u)
227
+ const v = parseFloat(s)
228
+ return isNaN(v) ? null : v
321
229
  }
322
- // Trim to last separator (- or /) for cleaner cuts
323
- const lastSep = Math.max(prefix.lastIndexOf('-'), prefix.lastIndexOf('/'))
324
- if (lastSep > 0) prefix = prefix.slice(0, lastSep + 1)
325
-
326
- const suffixes = labels.map(l => l.slice(prefix.length))
327
- // If stripping made them empty or all the same, fall back to originals
328
- if (suffixes.some(s => s === '') || new Set(suffixes).size !== suffixes.length) return labels
329
- return suffixes
330
- }
331
-
332
- function AreaChart({ series, color, fillColor, unit }: {
333
- series: PrometheusSeries[]
334
- color: string
335
- fillColor: string
336
- unit: string
337
- }) {
338
- const svgRef = useRef<SVGSVGElement>(null)
339
- const [hoverX, setHoverX] = useState<number | null>(null)
340
- const multiSeries = series.length > 1
341
-
342
- const chartData = useMemo(() => {
343
- if (!series.length) return null
344
-
345
- // Merge all series into a single timeline for the X axis
346
- let minTs = Infinity
347
- let maxTs = -Infinity
348
- let maxVal = 0
349
-
350
- for (const s of series) {
351
- for (const dp of s.dataPoints) {
352
- if (dp.timestamp < minTs) minTs = dp.timestamp
353
- if (dp.timestamp > maxTs) maxTs = dp.timestamp
354
- if (dp.value > maxVal) maxVal = dp.value
355
- }
356
- }
357
-
358
- if (minTs === maxTs) maxTs = minTs + 60
359
- if (maxVal === 0) {
360
- // Use a small unit-appropriate default so the Y-axis isn't misleadingly large
361
- maxVal = unit === 'cores' ? 0.01 : unit === 'bytes' ? 1024 * 1024 : unit === 'bytes/s' ? 1024 : 1
362
- }
363
-
364
- const padding = maxVal * 0.1
365
- const yMax = maxVal + padding
366
-
367
- return { minTs, maxTs, yMax, series }
368
- }, [series, unit])
369
-
370
- if (!chartData) return null
371
-
372
- const { minTs, maxTs, yMax } = chartData
373
- const width = 1000
374
- const height = 300
375
- const marginLeft = 60
376
- const marginRight = 20
377
- const marginTop = 10
378
- const marginBottom = 30
379
- const plotWidth = width - marginLeft - marginRight
380
- const plotHeight = height - marginTop - marginBottom
381
-
382
- const toX = (ts: number) => marginLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth
383
- const toY = (val: number) => marginTop + plotHeight - (val / yMax) * plotHeight
384
-
385
- // Y axis ticks
386
- const yTicks = useMemo(() => {
387
- const count = 4
388
- return Array.from({ length: count + 1 }, (_, i) => {
389
- const val = (yMax / count) * i
390
- return { val, y: toY(val), label: formatMetricValue(val, unit) }
391
- })
392
- }, [yMax, unit])
393
-
394
- // X axis ticks
395
- const xTicks = useMemo(() => {
396
- const count = 6
397
- return Array.from({ length: count + 1 }, (_, i) => {
398
- const ts = minTs + ((maxTs - minTs) / count) * i
399
- return { ts, x: toX(ts), label: formatTimestamp(ts) }
400
- })
401
- }, [minTs, maxTs])
402
-
403
- // Build paths for each series
404
- const paths = useMemo(() => {
405
- return chartData.series.map((s, seriesIdx) => {
406
- if (s.dataPoints.length < 2) return null
407
- const points = s.dataPoints.map(dp => ({ x: toX(dp.timestamp), y: toY(dp.value) }))
408
-
409
- const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
410
-
411
- // Area path: line + close to bottom
412
- const areaPath = linePath +
413
- ` L${points[points.length - 1].x},${marginTop + plotHeight}` +
414
- ` L${points[0].x},${marginTop + plotHeight} Z`
415
-
416
- return {
417
- linePath,
418
- areaPath,
419
- strokeColor: multiSeries ? seriesColor(seriesIdx, color) : color,
420
- areaFillColor: multiSeries ? seriesFill(seriesIdx, fillColor) : fillColor,
421
- key: seriesIdx,
422
- }
423
- }).filter(Boolean)
424
- }, [chartData])
425
-
426
- // Hover data: find nearest data point per series at the hovered X position
427
- const hoverData = useMemo(() => {
428
- if (hoverX === null) return null
429
- const clampedX = Math.max(marginLeft, Math.min(marginLeft + plotWidth, hoverX))
430
- const frac = (clampedX - marginLeft) / plotWidth
431
- const ts = minTs + frac * (maxTs - minTs)
432
-
433
- const validSeries = chartData.series
434
- .map((s, i) => ({ s, i }))
435
- .filter(({ s }) => s.dataPoints.length >= 2)
436
-
437
- const fullLabels = validSeries.map(({ s, i }) =>
438
- s.labels.pod || s.labels.instance || s.labels.node || `series-${i}`
439
- )
440
- const shortLabels = computeShortLabels(fullLabels)
441
-
442
- const points = validSeries.map(({ s, i }, vi) => {
443
- let closest = s.dataPoints[0]
444
- let closestDist = Infinity
445
- for (const dp of s.dataPoints) {
446
- const dist = Math.abs(dp.timestamp - ts)
447
- if (dist < closestDist) {
448
- closestDist = dist
449
- closest = dp
450
- }
451
- }
452
- return {
453
- label: shortLabels[vi],
454
- fullLabel: fullLabels[vi],
455
- value: closest.value,
456
- y: toY(closest.value),
457
- color: multiSeries ? seriesColor(i, color) : color,
458
- }
459
- })
460
-
461
- return { ts, x: clampedX, points }
462
- }, [hoverX, chartData])
463
-
464
- // Convert client mouse coordinates to SVG viewBox coordinates
465
- const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
466
- const svg = svgRef.current
467
- if (!svg) return
468
- const ctm = svg.getScreenCTM()
469
- if (!ctm) return
470
- setHoverX((e.clientX - ctm.e) / ctm.a)
471
- }, [])
472
-
473
- return (
474
- <div className="relative">
475
- <svg
476
- ref={svgRef}
477
- viewBox={`0 0 ${width} ${height}`}
478
- className="w-full h-full"
479
- preserveAspectRatio="xMidYMid meet"
480
- >
481
- {/* Grid lines */}
482
- {yTicks.map((tick, i) => (
483
- <line
484
- key={`grid-${i}`}
485
- x1={marginLeft}
486
- y1={tick.y}
487
- x2={width - marginRight}
488
- y2={tick.y}
489
- stroke="currentColor"
490
- className="text-theme-border/30"
491
- strokeWidth="1"
492
- strokeDasharray={i === 0 ? undefined : '4 4'}
493
- />
494
- ))}
495
-
496
- {/* Y axis labels */}
497
- {yTicks.map((tick, i) => (
498
- <text
499
- key={`ylabel-${i}`}
500
- x={marginLeft - 8}
501
- y={tick.y + 4}
502
- textAnchor="end"
503
- className="fill-theme-text-secondary"
504
- fontSize="11"
505
- fontFamily="ui-monospace, monospace"
506
- >
507
- {tick.label}
508
- </text>
509
- ))}
510
-
511
- {/* X axis labels */}
512
- {xTicks.map((tick, i) => (
513
- <text
514
- key={`xlabel-${i}`}
515
- x={tick.x}
516
- y={height - 4}
517
- textAnchor="middle"
518
- className="fill-theme-text-secondary"
519
- fontSize="11"
520
- fontFamily="ui-monospace, monospace"
521
- >
522
- {tick.label}
523
- </text>
524
- ))}
525
-
526
- {/* Area fills */}
527
- {paths.map(p => p && (
528
- <path
529
- key={`area-${p.key}`}
530
- d={p.areaPath}
531
- fill={p.areaFillColor}
532
- />
533
- ))}
534
-
535
- {/* Lines */}
536
- {paths.map(p => p && (
537
- <path
538
- key={`line-${p.key}`}
539
- d={p.linePath}
540
- fill="none"
541
- stroke={p.strokeColor}
542
- strokeWidth="2"
543
- strokeLinejoin="round"
544
- />
545
- ))}
546
-
547
- {/* Hover crosshair + dots */}
548
- {hoverData && (
549
- <>
550
- <line
551
- x1={hoverData.x} y1={marginTop}
552
- x2={hoverData.x} y2={marginTop + plotHeight}
553
- stroke="currentColor"
554
- className="text-theme-text-tertiary"
555
- strokeWidth="1"
556
- strokeDasharray="4 4"
557
- />
558
- {hoverData.points.map((p, i) => (
559
- <circle
560
- key={i}
561
- cx={hoverData.x} cy={p.y}
562
- r="4"
563
- fill={p.color}
564
- stroke="var(--color-theme-surface, #1a1a2e)"
565
- strokeWidth="2"
566
- />
567
- ))}
568
- </>
569
- )}
570
-
571
- {/* Invisible overlay for mouse events — must be last for event capture */}
572
- <rect
573
- x={marginLeft} y={marginTop}
574
- width={plotWidth} height={plotHeight}
575
- fill="transparent"
576
- style={{ cursor: 'crosshair' }}
577
- onMouseMove={handleMouseMove}
578
- onMouseLeave={() => setHoverX(null)}
579
- />
580
- </svg>
581
-
582
- {/* Tooltip positioned outside SVG for proper HTML rendering */}
583
- {hoverData && (
584
- <div
585
- className="absolute top-0 pointer-events-none z-10"
586
- style={{
587
- left: `${(hoverData.x / width) * 100}%`,
588
- transform: hoverData.x > width * 0.65 ? 'translateX(calc(-100% - 12px))' : 'translateX(12px)',
589
- }}
590
- >
591
- <div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
592
- <div className="text-theme-text-tertiary mb-1.5 font-mono">
593
- {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
594
- </div>
595
- {hoverData.points.map((p, i) => (
596
- <div key={i} className="flex items-center gap-2 py-0.5">
597
- <div
598
- className="w-2 h-2 rounded-full shrink-0"
599
- style={{ backgroundColor: p.color }}
600
- />
601
- <span className="text-theme-text-secondary font-mono" title={p.fullLabel}>
602
- {p.label}
603
- </span>
604
- <span className="text-theme-text-primary font-semibold ml-auto pl-3 tabular-nums">
605
- {formatMetricValue(p.value, unit)}
606
- </span>
607
- </div>
608
- ))}
609
- </div>
610
- </div>
611
- )}
612
- </div>
613
- )
230
+ // Memory: try two-character then one-character suffixes (Mi before M).
231
+ for (const suffix of ['Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei']) {
232
+ if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
233
+ }
234
+ for (const suffix of ['K', 'M', 'G', 'T', 'P', 'E']) {
235
+ if (s.endsWith(suffix)) return scaleOrNull(s, MEMORY_SUFFIXES[suffix])
236
+ }
237
+ const v = parseFloat(s)
238
+ return isNaN(v) ? null : v
614
239
  }
615
240
 
616
- function SeriesLegend({ series, color }: { series: PrometheusSeries[]; color: string }) {
617
- const labels = series.map((s, i) => s.labels.pod || s.labels.instance || `series-${i}`)
618
- return (
619
- <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
620
- {series.slice(0, 10).map((_, i) => {
621
- const shortName = labels[i].length > 40 ? '...' + labels[i].slice(-37) : labels[i]
622
- return (
623
- <div key={i} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
624
- <div
625
- className="w-2.5 h-2.5 rounded-full shrink-0"
626
- style={{ backgroundColor: seriesColor(i, color) }}
627
- />
628
- <span className="truncate" title={labels[i]}>{shortName}</span>
629
- </div>
630
- )
631
- })}
632
- {series.length > 10 && (
633
- <span className="text-xs text-theme-text-quaternary">+{series.length - 10} more</span>
634
- )}
635
- </div>
636
- )
241
+ function scaleOrNull(s: string, scale: number): number | null {
242
+ const v = parseFloat(s)
243
+ return isNaN(v) ? null : v * scale
637
244
  }
638
245
 
639
- // ============================================================================
640
- // Formatters
641
- // ============================================================================
642
-
643
- function formatMetricValue(value: number, unit: string): string {
644
- if (value === 0) return '0'
645
-
646
- switch (unit) {
647
- case 'cores': {
648
- if (value < 0.0001) return '< 0.1m'
649
- if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
650
- if (value < 1) return `${(value * 1000).toFixed(0)}m`
651
- return `${value.toFixed(2)}`
652
- }
653
- case 'bytes': {
654
- if (value < 1) return '< 1 B'
655
- if (value < 1024) return `${value.toFixed(0)} B`
656
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`
657
- if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB`
658
- return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB`
659
- }
660
- case 'bytes/s': {
661
- if (value < 1) return '< 1 B/s'
662
- if (value < 1024) return `${value.toFixed(0)} B/s`
663
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB/s`
664
- if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
665
- return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
666
- }
667
- default:
668
- if (value < 0.01) return value.toExponential(1)
669
- if (value < 1) return value.toFixed(3)
670
- if (value < 100) return value.toFixed(2)
671
- if (value < 10000) return value.toFixed(0)
672
- return `${(value / 1000).toFixed(1)}k`
246
+ function formatRequestLimitLabel(value: number, category: 'cpu' | 'memory'): string {
247
+ if (category === 'cpu') {
248
+ if (value < 1) return `${Math.round(value * 1000)}m`
249
+ return value.toFixed(2).replace(/\.?0+$/, '')
673
250
  }
674
- }
675
-
676
- function formatTimestamp(unix: number): string {
677
- const d = new Date(unix * 1000)
678
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
251
+ // Memory — match formatMetricValue's tier breakpoints.
252
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)}KiB`
253
+ if (value < 1024 ** 3) return `${(value / (1024 ** 2)).toFixed(0)}MiB`
254
+ return `${(value / (1024 ** 3)).toFixed(1)}GiB`
679
255
  }
680
256
 
681
257
  // ============================================================================