@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,363 @@
1
+ import { ArrowRight, ExternalLink, Gauge, HelpCircle, Loader2 } from 'lucide-react'
2
+ import { Link, useLocation } from 'react-router-dom'
3
+ import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
4
+ import {
5
+ usePrometheusRightsizing,
6
+ usePrometheusStatus,
7
+ useAutoPromConnect,
8
+ type PrometheusRightsizing,
9
+ type RightsizingFit,
10
+ type RightsizingRow,
11
+ } from '../../api/client'
12
+ import { Tooltip } from '../ui/Tooltip'
13
+
14
+ const RIGHTSIZING_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
15
+ export const RIGHTSIZING_DOCS_URL = 'https://radarhq.io/docs/features/rightsizing'
16
+ export const RIGHTSIZING_SUMMARY =
17
+ 'Evidence-based guidance for this workload, not a savings estimate or automatic change.'
18
+ export const RIGHTSIZING_METHODOLOGY =
19
+ 'Uses seven days of 5-minute samples: CPU P95 and memory maximum, plus 15% headroom. Reductions are staged and rounded to practical values; memory reductions also require verifiable restart history. Radar does not change requests.'
20
+
21
+ interface RightsizingProps {
22
+ kind: string
23
+ namespace: string
24
+ name: string
25
+ }
26
+
27
+ function useRightsizingEvidence({ kind, namespace, name }: RightsizingProps) {
28
+ useAutoPromConnect()
29
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
30
+ const connected = status?.connected === true
31
+ const supported = RIGHTSIZING_KINDS.has(kind)
32
+ const query = usePrometheusRightsizing(kind, namespace, name, connected && supported)
33
+ return { connected, supported, statusLoading, ...query }
34
+ }
35
+
36
+ export function RightsizingPanel(props: RightsizingProps) {
37
+ const state = useRightsizingEvidence(props)
38
+ const { search } = useLocation()
39
+ if (!state.supported) return null
40
+
41
+ return (
42
+ <section className="mx-auto w-full max-w-[1600px] rounded-lg border border-theme-border bg-theme-surface/50">
43
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
44
+ <div className="flex items-start gap-2">
45
+ <Gauge className="mt-0.5 h-4 w-4 text-theme-text-tertiary" />
46
+ <div>
47
+ <div className="flex items-center gap-1.5">
48
+ <h3 className="text-sm font-semibold text-theme-text-primary">Rightsizing</h3>
49
+ <Tooltip content={RIGHTSIZING_METHODOLOGY}>
50
+ <HelpCircle className="h-3.5 w-3.5 text-theme-text-tertiary" />
51
+ </Tooltip>
52
+ </div>
53
+ <p className="text-xs text-theme-text-tertiary">
54
+ {RIGHTSIZING_SUMMARY}{' '}
55
+ <a
56
+ href={RIGHTSIZING_DOCS_URL}
57
+ target="_blank"
58
+ rel="noreferrer"
59
+ className="inline-flex items-center gap-1 text-accent-text hover:underline"
60
+ >
61
+ How it works
62
+ <ExternalLink className="h-3 w-3" />
63
+ </a>
64
+ </p>
65
+ </div>
66
+ </div>
67
+ <div className="flex items-center gap-3">
68
+ {state.data && <RightsizingContext data={state.data} />}
69
+ <Link
70
+ to={{ pathname: '/cost/rightsizing', search }}
71
+ className="text-xs font-medium text-accent-text hover:underline"
72
+ >
73
+ Scan visible workloads
74
+ </Link>
75
+ </div>
76
+ </header>
77
+ <div className="p-4">
78
+ <RightsizingBody state={state} compact={false} />
79
+ </div>
80
+ </section>
81
+ )
82
+ }
83
+
84
+ export function RightsizingStrip(props: RightsizingProps) {
85
+ const state = useRightsizingEvidence(props)
86
+ if (!state.supported || !state.connected || (state.isLoading && !state.data)) return null
87
+ return (
88
+ <section className="mb-3 rounded-lg border border-theme-border bg-theme-surface/40 p-3">
89
+ <header className="mb-2 flex items-center justify-between gap-3">
90
+ <div className="flex items-center gap-1.5">
91
+ <h3 className="text-sm font-medium text-theme-text-primary">Rightsizing</h3>
92
+ <Tooltip content={RIGHTSIZING_METHODOLOGY}>
93
+ <HelpCircle className="h-3.5 w-3.5 text-theme-text-tertiary" />
94
+ </Tooltip>
95
+ </div>
96
+ {state.data && <RightsizingContext data={state.data} compact />}
97
+ </header>
98
+ <RightsizingBody state={state} compact />
99
+ </section>
100
+ )
101
+ }
102
+
103
+ function RightsizingBody({
104
+ state,
105
+ compact,
106
+ }: {
107
+ state: ReturnType<typeof useRightsizingEvidence>
108
+ compact: boolean
109
+ }) {
110
+ if (state.statusLoading) {
111
+ return <EmptyState text="Checking Prometheus connection…" loading />
112
+ }
113
+ if (!state.connected) {
114
+ return <EmptyState text="Connect Prometheus to compare requests with observed demand." />
115
+ }
116
+ if (state.isLoading && !state.data) {
117
+ return <EmptyState text="Analyzing seven days of workload demand…" loading />
118
+ }
119
+ if (state.error && !state.data) {
120
+ const message = state.error instanceof Error ? state.error.message : String(state.error)
121
+ return <EmptyState text={`Rightsizing unavailable — ${message}`} />
122
+ }
123
+ if (!state.data) return null
124
+ if (!state.data.sampleAvailable || state.data.rows.length === 0) {
125
+ return <EmptyState text={state.data.reason ?? 'No rightsizing evidence is available.'} />
126
+ }
127
+
128
+ const byContainer = new Map<string, RightsizingRow[]>()
129
+ for (const row of state.data.rows) {
130
+ const rows = byContainer.get(row.container) ?? []
131
+ rows.push(row)
132
+ byContainer.set(row.container, rows)
133
+ }
134
+
135
+ if (compact) {
136
+ return (
137
+ <div className="space-y-2">
138
+ {Array.from(byContainer.entries()).map(([container, rows]) => (
139
+ <div key={container}>
140
+ <div className="mb-1 text-xs font-medium text-theme-text-secondary">{container}</div>
141
+ <div className="space-y-1 pl-2">
142
+ {rows.map((row) => (
143
+ <CompactFitRow key={row.resource} row={row} />
144
+ ))}
145
+ </div>
146
+ </div>
147
+ ))}
148
+ </div>
149
+ )
150
+ }
151
+
152
+ return (
153
+ <div className="space-y-4">
154
+ {(state.data.scaledToZero || state.data.ownerCoverage === 'current_pods') && (
155
+ <div className="flex flex-wrap gap-2">
156
+ {state.data.scaledToZero && (
157
+ <Badge severity="neutral" size="sm">
158
+ No current replicas · showing retained evidence
159
+ </Badge>
160
+ )}
161
+ {state.data.ownerCoverage === 'current_pods' && (
162
+ <Badge severity="info" size="sm">
163
+ Current pods only · confidence capped
164
+ </Badge>
165
+ )}
166
+ </div>
167
+ )}
168
+ {Array.from(byContainer.entries()).map(([container, rows]) => (
169
+ <div key={container}>
170
+ <h4 className="mb-2 text-xs font-semibold text-theme-text-secondary">{container}</h4>
171
+ <div className="grid gap-3 md:grid-cols-2">
172
+ {rows.map((row) => (
173
+ <FitCard key={row.resource} row={row} window={state.data!.window} />
174
+ ))}
175
+ </div>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ )
180
+ }
181
+
182
+ function FitCard({ row, window }: { row: RightsizingRow; window: string }) {
183
+ return (
184
+ <div className="rounded-md border border-theme-border bg-theme-base/60 p-3">
185
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
186
+ <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
187
+ {row.resource}
188
+ </span>
189
+ <div className="flex flex-wrap items-center justify-end gap-1.5">
190
+ <FitBadge fit={row.fit} queryError={row.queryError} />
191
+ {row.hpaManaged && (
192
+ <Badge severity="info" size="sm">
193
+ HPA-managed
194
+ </Badge>
195
+ )}
196
+ {row.bursty && (
197
+ <Badge severity="warning" size="sm">
198
+ Bursty CPU
199
+ </Badge>
200
+ )}
201
+ {row.throttleRatio != null && row.throttleRatio >= 0.1 && (
202
+ <Badge severity="alert" size="sm">
203
+ Throttling observed
204
+ </Badge>
205
+ )}
206
+ {(row.currentPodOOM || row.windowOomEvidence) && (
207
+ <Badge severity="error" size="sm">
208
+ OOM evidence
209
+ </Badge>
210
+ )}
211
+ </div>
212
+ </div>
213
+ <div className="flex items-baseline gap-2 tabular-nums">
214
+ <div>
215
+ <div className="text-[10px] uppercase tracking-wide text-theme-text-quaternary">
216
+ Current request
217
+ </div>
218
+ <div className="text-lg font-semibold text-theme-text-primary">
219
+ {row.currentRequest ?? 'Unset'}
220
+ </div>
221
+ </div>
222
+ <ArrowRight className="h-4 w-4 text-theme-text-quaternary" />
223
+ <div>
224
+ <div className="text-[10px] uppercase tracking-wide text-theme-text-quaternary">
225
+ Suggested next step
226
+ </div>
227
+ <div className="text-lg font-semibold text-theme-text-primary">
228
+ {row.recommendedRequest ?? '—'}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ <div className="mt-3 grid grid-cols-2 gap-3 border-t border-theme-border pt-3 text-xs">
233
+ <Metric
234
+ label={`${row.observed?.name ?? (row.resource === 'cpu' ? 'P95' : 'Max')} observed`}
235
+ value={row.observed?.formatted ?? 'No samples'}
236
+ />
237
+ <Metric label={`Evidence · ${window}`} value={`${confidenceLabel(row)} · 5m samples`} />
238
+ </div>
239
+ <FitExplanation row={row} />
240
+ </div>
241
+ )
242
+ }
243
+
244
+ function CompactFitRow({ row }: { row: RightsizingRow }) {
245
+ return (
246
+ <div className="flex flex-wrap items-center gap-2 text-xs tabular-nums text-theme-text-tertiary">
247
+ <span className="w-12 text-[10px] uppercase tracking-wide text-theme-text-quaternary">
248
+ {row.resource}
249
+ </span>
250
+ <span className="min-w-14 text-theme-text-secondary">{row.currentRequest ?? 'unset'}</span>
251
+ {row.recommendedRequest && (
252
+ <>
253
+ <ArrowRight className="h-3 w-3 text-theme-text-quaternary" />
254
+ <span className="min-w-14 text-theme-text-primary">{row.recommendedRequest}</span>
255
+ </>
256
+ )}
257
+ {row.observed && (
258
+ <span className="text-[10px] text-theme-text-quaternary">
259
+ ({row.observed.name} {row.observed.formatted})
260
+ </span>
261
+ )}
262
+ <span className="ml-auto">
263
+ <FitBadge fit={row.fit} queryError={row.queryError} />
264
+ </span>
265
+ </div>
266
+ )
267
+ }
268
+
269
+ function RightsizingContext({
270
+ data,
271
+ compact = false,
272
+ }: {
273
+ data: PrometheusRightsizing
274
+ compact?: boolean
275
+ }) {
276
+ const evidence = data.ownerCoverage === 'ksm_history' ? 'retained ownership' : 'current pods only'
277
+ return (
278
+ <span className="text-[11px] text-theme-text-tertiary">
279
+ {compact ? data.window : `Last ${data.window} · ${evidence}`}
280
+ </span>
281
+ )
282
+ }
283
+
284
+ function FitBadge({ fit, queryError }: { fit: RightsizingFit; queryError?: string }) {
285
+ const presentation = getRightsizingPresentation(fit, queryError)
286
+ return (
287
+ <Badge severity={presentation.severity} size="sm">
288
+ {presentation.label}
289
+ </Badge>
290
+ )
291
+ }
292
+
293
+ export function getRightsizingPresentation(fit: RightsizingFit, queryError?: string) {
294
+ if (queryError) return { label: 'Query failed', severity: 'error' as const }
295
+ const labels: Record<RightsizingFit, string> = {
296
+ balanced: 'Balanced',
297
+ oversized: 'Oversized',
298
+ under_requested: 'Under-requested',
299
+ missing_request: 'Missing request',
300
+ insufficient_history: 'Insufficient history',
301
+ }
302
+ const severities: Record<RightsizingFit, 'success' | 'info' | 'warning' | 'neutral'> = {
303
+ balanced: 'success',
304
+ oversized: 'info',
305
+ under_requested: 'warning',
306
+ missing_request: 'warning',
307
+ insufficient_history: 'neutral',
308
+ }
309
+ return { label: labels[fit], severity: severities[fit] }
310
+ }
311
+
312
+ function FitExplanation({ row }: { row: RightsizingRow }) {
313
+ const message = getRightsizingExplanation(row)
314
+ if (!message) return null
315
+ return <p className="mt-3 text-xs text-theme-text-tertiary">{message}</p>
316
+ }
317
+
318
+ export function getRightsizingExplanation(row: RightsizingRow): string | undefined {
319
+ const messages: Record<string, string> = {
320
+ request_within_fit_range: 'The configured request is within 30% of the evidence-based target.',
321
+ insufficient_history: 'At least six hours of samples are required before suggesting a request.',
322
+ hpa_managed: `The HPA manages ${row.resource}; review its target before changing this request.`,
323
+ hpa_evidence_unavailable:
324
+ 'Radar could not verify HPA ownership, so it withheld a suggested request.',
325
+ oom_evidence: 'A lower memory request is withheld because OOM evidence exists.',
326
+ oom_evidence_unavailable:
327
+ 'Radar could not verify recent OOM history, so it withheld a lower memory request.',
328
+ recommended_request_exceeds_limit:
329
+ 'The evidence-based request would exceed the current limit; review the limit first.',
330
+ }
331
+ if (row.reductionLimited && row.calculatedRequest && row.recommendedRequest) {
332
+ const burstNote = row.bursty && row.peak ? ` CPU P99 reached ${row.peak.formatted}.` : ''
333
+ return `Demand-based target: ${row.calculatedRequest}. Radar suggests ${row.recommendedRequest} as a conservative next step; observe another full window before reducing further.${burstNote}`
334
+ }
335
+ const message = row.queryError
336
+ ? `Partial result: ${row.queryError}.`
337
+ : messages[row.recommendationReason ?? '']
338
+ const throttleUnavailable = row.resource === 'cpu' && !row.throttleAvailable
339
+ if (!message && !throttleUnavailable) return undefined
340
+ return message ?? 'CPU throttling metrics are unavailable; no throttling conclusion was drawn.'
341
+ }
342
+
343
+ function confidenceLabel(row: RightsizingRow) {
344
+ return `${row.confidence[0].toUpperCase()}${row.confidence.slice(1)} confidence`
345
+ }
346
+
347
+ function Metric({ label, value }: { label: string; value: string }) {
348
+ return (
349
+ <div>
350
+ <div className="text-theme-text-quaternary">{label}</div>
351
+ <div className="mt-0.5 text-theme-text-secondary">{value}</div>
352
+ </div>
353
+ )
354
+ }
355
+
356
+ function EmptyState({ text, loading = false }: { text: string; loading?: boolean }) {
357
+ return (
358
+ <div className="flex min-h-16 items-center justify-center text-sm text-theme-text-tertiary">
359
+ {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
360
+ {text}
361
+ </div>
362
+ )
363
+ }
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { useState, useCallback } from 'react'
5
5
  import type { TopologyNode, NodeKind, HealthStatus } from '../../types'
6
6
  import { getKindBadgeBordered, healthToSeverity, SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface ResourceDrawerProps {
9
10
  node: TopologyNode
@@ -146,10 +147,10 @@ export const ResourceDrawer = memo(function ResourceDrawer({
146
147
  <h2 className="text-lg font-semibold text-theme-text-primary truncate">
147
148
  {node.name}
148
149
  </h2>
150
+ <Tooltip content="Copy name">
149
151
  <button
150
152
  onClick={copyName}
151
153
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
152
- title="Copy name"
153
154
  >
154
155
  {copied ? (
155
156
  <Check className="w-4 h-4 text-green-400" />
@@ -157,6 +158,7 @@ export const ResourceDrawer = memo(function ResourceDrawer({
157
158
  <Copy className="w-4 h-4" />
158
159
  )}
159
160
  </button>
161
+ </Tooltip>
160
162
  </div>
161
163
  </div>
162
164
  <button
@@ -0,0 +1,101 @@
1
+ import { useMemo } from 'react'
2
+ import { useQueries } from '@tanstack/react-query'
3
+ import {
4
+ CompositeRenderer as BaseCompositeRenderer,
5
+ type ComposedRefStatus,
6
+ } from '@skyhook-io/k8s-ui/components/resources/renderers/CompositeRenderer'
7
+ import {
8
+ getCrossplaneResourceRefs,
9
+ type CrossplaneResourceRef,
10
+ } from '@skyhook-io/k8s-ui/components/resources/resource-utils-crossplane'
11
+ import { getResourceStatus } from '@skyhook-io/k8s-ui'
12
+ import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
13
+ import type { ResourceRef as NavRef } from '../../types'
14
+ import { fetchJSON, ApiError } from '../../api/client'
15
+
16
+ interface CompositeRendererProps {
17
+ data: any
18
+ onNavigate?: (ref: NavRef) => void
19
+ }
20
+
21
+ function makeRefKey(ref: CrossplaneResourceRef): string {
22
+ return `${ref.apiVersion}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
23
+ }
24
+
25
+ // Crossplane refs carry full apiVersion (e.g. "s3.aws.upbound.io/v1beta1")
26
+ // because plural collisions across provider groups are real — two providers
27
+ // can ship CRDs with the same kind. Drop the version, keep the group.
28
+ function groupFromApiVersion(apiVersion: string | undefined): string {
29
+ if (!apiVersion) return ''
30
+ const slash = apiVersion.indexOf('/')
31
+ return slash < 0 ? '' : apiVersion.slice(0, slash)
32
+ }
33
+
34
+ /**
35
+ * Host wrapper for the package's CompositeRenderer: fans out a React Query
36
+ * lookup per composed resource ref so each row in the composed-resources
37
+ * list can show a live status badge.
38
+ *
39
+ * Errors are folded into the per-ref status entry rather than thrown — a
40
+ * missing composed resource (e.g. 404 because reconciliation hasn't created
41
+ * it yet) is a normal state for a freshly-applied Composite, not a failure.
42
+ */
43
+ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps) {
44
+ const refs = useMemo<CrossplaneResourceRef[]>(() => getCrossplaneResourceRefs(data), [data])
45
+
46
+ const queries = useQueries({
47
+ queries: refs.map(ref => {
48
+ const group = groupFromApiVersion(ref.apiVersion)
49
+ return {
50
+ queryKey: ['composed-ref', group, ref.kind, ref.namespace ?? '', ref.name],
51
+ queryFn: async () => {
52
+ const ns = ref.namespace || '_'
53
+ const plural = kindToPlural(ref.kind)
54
+ const query = group ? `?group=${encodeURIComponent(group)}` : ''
55
+ return fetchJSON<{ resource: any }>(`/resources/${plural}/${ns}/${ref.name}${query}`)
56
+ },
57
+ staleTime: 30000,
58
+ retry: false,
59
+ enabled: Boolean(ref.kind && ref.name),
60
+ }
61
+ }),
62
+ })
63
+
64
+ const composedRefStatuses = useMemo<Map<string, ComposedRefStatus>>(() => {
65
+ const map = new Map<string, ComposedRefStatus>()
66
+ refs.forEach((ref, i) => {
67
+ const q = queries[i]
68
+ const key = makeRefKey(ref)
69
+ if (q.isLoading) {
70
+ map.set(key, { ref, loading: true })
71
+ return
72
+ }
73
+ if (q.isError) {
74
+ // 404 = the composed resource genuinely doesn't exist yet (normal
75
+ // during reconciliation). Any other failure (401/403/500/network)
76
+ // means we can't tell the operator the resource's status — render
77
+ // it distinctly so they don't read "missing" and think the resource
78
+ // is absent when they've actually just lost permission to read it.
79
+ if (q.error instanceof ApiError && q.error.status === 404) {
80
+ map.set(key, { ref, missing: true })
81
+ } else {
82
+ const message = q.error instanceof Error ? q.error.message : 'Failed to fetch composed resource'
83
+ map.set(key, { ref, error: true, errorMessage: message })
84
+ }
85
+ return
86
+ }
87
+ if (!q.data) {
88
+ map.set(key, { ref, missing: true })
89
+ return
90
+ }
91
+ const status = getResourceStatus(kindToPlural(ref.kind), q.data.resource) ?? undefined
92
+ map.set(key, {
93
+ ref,
94
+ status: status ? { ...status, level: (status as any).level ?? 'unknown' } : undefined,
95
+ })
96
+ })
97
+ return map
98
+ }, [refs, queries])
99
+
100
+ return <BaseCompositeRenderer data={data} onNavigate={onNavigate} composedRefStatuses={composedRefStatuses} />
101
+ }
@@ -7,7 +7,9 @@ import { useImageMetadata, ApiError } from '../../api/client'
7
7
  import type { FileNode, ImageFilesystem } from '../../types'
8
8
  import { formatBytes } from '../../utils/format'
9
9
  import { downloadBlob, filterTree } from './file-browser-utils'
10
+ import { Tooltip } from '../ui/Tooltip'
10
11
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
12
+ import { Input } from '@skyhook-io/k8s-ui'
11
13
 
12
14
  // Manual fetch function for filesystem (not a hook - gives us full control)
13
15
  async function fetchImageFilesystem(
@@ -141,9 +143,11 @@ export function ImageFilesystemModal({
141
143
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
142
144
  <div className="flex-1 min-w-0">
143
145
  <h3 className="text-lg font-semibold text-theme-text-primary">Image Filesystem</h3>
144
- <p className="text-sm text-theme-text-secondary truncate mt-0.5" title={image}>
146
+ <Tooltip content={image} wrapperClassName="!block w-full">
147
+ <p className="text-sm text-theme-text-secondary truncate mt-0.5">
145
148
  {image}
146
149
  </p>
150
+ </Tooltip>
147
151
  {(displayFilesystem?.platform || metadata?.platform) && (
148
152
  <p className="text-xs text-theme-text-tertiary mt-1">
149
153
  Platform: {displayFilesystem?.platform || metadata?.platform}
@@ -163,8 +167,7 @@ export function ImageFilesystemModal({
163
167
  <div className="p-3 border-b border-theme-border shrink-0">
164
168
  <div className="relative">
165
169
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
166
- <input
167
- type="text"
170
+ <Input
168
171
  placeholder="Search files..."
169
172
  value={searchQuery}
170
173
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -245,9 +248,11 @@ export function ImageFilesystemModal({
245
248
  <span>{formatBytes(displayFilesystem.totalSize)}</span>
246
249
  {displayFilesystem.layers && <span>{displayFilesystem.layers.length} layers</span>}
247
250
  {displayFilesystem.digest && (
248
- <span className="truncate" title={displayFilesystem.digest}>
251
+ <Tooltip content={displayFilesystem.digest} wrapperClassName="min-w-0">
252
+ <span className="truncate">
249
253
  Digest: {displayFilesystem.digest.substring(0, 20)}...
250
254
  </span>
255
+ </Tooltip>
251
256
  )}
252
257
  </>
253
258
  )}
@@ -362,10 +367,10 @@ function DownloadConfirmation({ metadata, onConfirm, onCancel }: DownloadConfirm
362
367
  <pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
363
368
  {authCommand}
364
369
  </pre>
370
+ <Tooltip content="Copy to clipboard" position="left" wrapperClassName="absolute top-2 right-2">
365
371
  <button
366
372
  onClick={handleCopy}
367
- className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
368
- title="Copy to clipboard"
373
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
369
374
  >
370
375
  {copied ? (
371
376
  <Check className="w-4 h-4 text-green-400" />
@@ -373,6 +378,7 @@ function DownloadConfirmation({ metadata, onConfirm, onCancel }: DownloadConfirm
373
378
  <Copy className="w-4 h-4" />
374
379
  )}
375
380
  </button>
381
+ </Tooltip>
376
382
  </div>
377
383
  </div>
378
384
  )}
@@ -444,7 +450,7 @@ function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelp
444
450
  </p>
445
451
 
446
452
  <p className="text-xs text-theme-text-tertiary text-center max-w-md mb-6">
447
- Registry: <span className="font-mono text-theme-text-secondary">{registry}</span>
453
+ Registry: <span className="inline-code">{registry}</span>
448
454
  {registryType && registryType !== 'generic' && (
449
455
  <> ({formatAuthMethod(registryType)})</>
450
456
  )}
@@ -461,10 +467,10 @@ function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelp
461
467
  <pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
462
468
  {authCommand}
463
469
  </pre>
470
+ <Tooltip content="Copy to clipboard" position="left" wrapperClassName="absolute top-2 right-2">
464
471
  <button
465
472
  onClick={handleCopy}
466
- className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
467
- title="Copy to clipboard"
473
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
468
474
  >
469
475
  {copied ? (
470
476
  <Check className="w-4 h-4 text-green-400" />
@@ -472,6 +478,7 @@ function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelp
472
478
  <Copy className="w-4 h-4" />
473
479
  )}
474
480
  </button>
481
+ </Tooltip>
475
482
  </div>
476
483
  </div>
477
484
  )}
@@ -709,11 +716,11 @@ function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, p
709
716
  )}
710
717
 
711
718
  {isFile && (
719
+ <Tooltip content="Download file" wrapperClassName="ml-1">
712
720
  <button
713
721
  onClick={handleDownload}
714
722
  disabled={downloading}
715
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded ml-1 disabled:opacity-50"
716
- title="Download file"
723
+ className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
717
724
  >
718
725
  {downloading ? (
719
726
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -721,6 +728,7 @@ function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, p
721
728
  <Download className="w-3.5 h-3.5" />
722
729
  )}
723
730
  </button>
731
+ </Tooltip>
724
732
  )}
725
733
  </div>
726
734
 
@@ -743,4 +751,3 @@ function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, p
743
751
  </div>
744
752
  )
745
753
  }
746
-
@@ -1,12 +1,13 @@
1
1
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
- import { PaneLoader } from '@skyhook-io/k8s-ui'
4
+ import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
5
5
  import { clsx } from 'clsx'
6
6
  import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
8
8
  import { downloadBlob, filterTree } from './file-browser-utils'
9
9
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
10
+ import { Tooltip } from '../ui/Tooltip'
10
11
 
11
12
  interface PodFilesystem {
12
13
  root: FileNode
@@ -191,8 +192,7 @@ export function PodFilesystemModal({
191
192
  {showFilesystem && (
192
193
  <div className="relative flex-1 min-w-[200px]">
193
194
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
194
- <input
195
- type="text"
195
+ <Input
196
196
  placeholder="Filter files..."
197
197
  value={searchQuery}
198
198
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -384,11 +384,11 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
384
384
  )}
385
385
 
386
386
  {isDownloadable && (
387
+ <Tooltip content="Download file" wrapperClassName="ml-1">
387
388
  <button
388
389
  onClick={handleDownload}
389
390
  disabled={downloading}
390
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded ml-1 disabled:opacity-50"
391
- title="Download file"
391
+ className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
392
392
  >
393
393
  {downloading ? (
394
394
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -396,6 +396,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
396
396
  <Download className="w-3.5 h-3.5" />
397
397
  )}
398
398
  </button>
399
+ </Tooltip>
399
400
  )}
400
401
  </div>
401
402
  )