@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
@@ -1,170 +1,363 @@
1
- import { ArrowRight, Check, Info, AlertTriangle } from 'lucide-react'
2
- import { SEVERITY_TEXT, SEVERITY_BADGE, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
3
- import { usePrometheusRightsizing, usePrometheusStatus, type RightsizingTone, type RightsizingRow } from '../../api/client'
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'
4
12
  import { Tooltip } from '../ui/Tooltip'
5
13
 
6
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.'
7
20
 
8
- /**
9
- * RightsizingStrip — compact "current → recommended" table per container.
10
- *
11
- * Tone policy is deliberately mild:
12
- * - "Well-sized" or "Nx headroom" → neutral, no badge
13
- * - 5×+ over-provisioning → info ("could reduce"), not a problem
14
- * - P95 exceeds request but within limit → warning
15
- * - P95 exceeds CPU limit → alert (throttling)
16
- * - Memory P95 near limit → critical (active OOM risk)
17
- *
18
- * Anything below severe over-provisioning displays without flagging it as
19
- * an issue. 2-3× headroom is the common, sensible default and should not nag.
20
- */
21
- export function RightsizingStrip({ kind, namespace, name }: {
21
+ interface RightsizingProps {
22
22
  kind: string
23
23
  namespace: string
24
24
  name: string
25
- }) {
26
- const { data: status } = usePrometheusStatus()
27
- const isConnected = status?.connected === true
25
+ }
26
+
27
+ function useRightsizingEvidence({ kind, namespace, name }: RightsizingProps) {
28
+ useAutoPromConnect()
29
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
30
+ const connected = status?.connected === true
28
31
  const supported = RIGHTSIZING_KINDS.has(kind)
29
- const { data, error, isLoading } = usePrometheusRightsizing(kind, namespace, name, isConnected && supported)
30
-
31
- if (!supported || !isConnected || isLoading) return null
32
- // Stay consistent with WorkloadHealthBadge — when the rightsizing query
33
- // fails, surface a small inline note so the absence of recommendations
34
- // doesn't read as "everything is fine."
35
- if (error && !data) {
36
- const msg = error instanceof Error ? error.message : String(error)
37
- return (
38
- <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
39
- <header className="flex items-center justify-between mb-1">
40
- <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
41
- </header>
42
- <Tooltip content={msg}>
43
- <p className="text-xs text-theme-text-tertiary">Right-sizing unavailable — Prometheus query failed.</p>
44
- </Tooltip>
45
- </section>
46
- )
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 />
47
112
  }
48
- if (!data) return null
49
- if (!data.sampleAvailable || data.rows.length === 0) {
50
- // Backend distinguishes "workload too new / retention short" from "Prometheus
51
- // query failed" — show the reason inline so operators have an actionable signal
52
- // instead of an empty section.
53
- if (!data.reason) return null
54
- return (
55
- <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
56
- <header className="flex items-center justify-between mb-1">
57
- <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
58
- </header>
59
- <p className="text-xs text-theme-text-tertiary">{data.reason}</p>
60
- </section>
61
- )
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.'} />
62
126
  }
63
127
 
64
- // Group rows by container so each container is a compact two-row block (cpu+mem).
65
128
  const byContainer = new Map<string, RightsizingRow[]>()
66
- for (const row of data.rows) {
67
- if (!byContainer.has(row.container)) byContainer.set(row.container, [])
68
- byContainer.get(row.container)!.push(row)
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)
69
133
  }
70
134
 
71
- return (
72
- <section className="rounded-lg border border-theme-border bg-theme-surface/40 p-3 mb-3">
73
- <header className="flex items-center justify-between mb-2">
74
- <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
75
- <span className="text-[11px] text-theme-text-tertiary">
76
- based on last {data.window} · P95
77
- </span>
78
- </header>
79
- <div className="space-y-1.5">
135
+ if (compact) {
136
+ return (
137
+ <div className="space-y-2">
80
138
  {Array.from(byContainer.entries()).map(([container, rows]) => (
81
- <div key={container} className="text-xs">
82
- <div className="text-theme-text-secondary font-medium mb-0.5">{container}</div>
83
- <div className="space-y-0.5 pl-2">
84
- {rows.map(row => (
85
- <RightsizingLine key={row.resource} row={row} />
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} />
86
144
  ))}
87
145
  </div>
88
146
  </div>
89
147
  ))}
90
148
  </div>
91
- </section>
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>
92
179
  )
93
180
  }
94
181
 
95
- function RightsizingLine({ row }: { row: RightsizingRow }) {
96
- const showRec = row.recommendedRequest && row.recommendedRequest !== row.currentRequest
97
- const toneClass = toneClasses(row.tone)
98
- const Icon = toneIcon(row.tone)
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
+ }
99
243
 
244
+ function CompactFitRow({ row }: { row: RightsizingRow }) {
100
245
  return (
101
- <div className="flex items-center gap-2 text-theme-text-tertiary tabular-nums">
102
- <span className="w-12 text-theme-text-quaternary uppercase tracking-wide text-[10px]">
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">
103
248
  {row.resource}
104
249
  </span>
105
-
106
- <span className="text-theme-text-secondary min-w-[3.5rem]">
107
- {row.currentRequest ?? <span className="text-theme-text-quaternary italic">unset</span>}
108
- </span>
109
-
110
- {showRec && (
250
+ <span className="min-w-14 text-theme-text-secondary">{row.currentRequest ?? 'unset'}</span>
251
+ {row.recommendedRequest && (
111
252
  <>
112
- <ArrowRight className="w-3 h-3 text-theme-text-quaternary shrink-0" />
113
- <span className={`min-w-[3.5rem] ${toneClass.value}`}>
114
- {row.recommendedRequest}
115
- </span>
253
+ <ArrowRight className="h-3 w-3 text-theme-text-quaternary" />
254
+ <span className="min-w-14 text-theme-text-primary">{row.recommendedRequest}</span>
116
255
  </>
117
256
  )}
118
-
119
- {row.p95 && (
120
- <span className="text-theme-text-quaternary text-[10px]">
121
- (P95 {row.p95})
122
- </span>
123
- )}
124
-
125
- {row.tone !== 'ok' && Icon && (
126
- <span className={`ml-auto inline-flex items-center gap-1 ${toneClass.badge} px-1.5 py-0.5 rounded text-[10px]`}>
127
- <Icon className="w-3 h-3" />
128
- <span>{row.message}</span>
257
+ {row.observed && (
258
+ <span className="text-[10px] text-theme-text-quaternary">
259
+ ({row.observed.name} {row.observed.formatted})
129
260
  </span>
130
261
  )}
131
-
132
- {row.tone === 'ok' && row.message && (
133
- <span className="ml-auto text-theme-text-quaternary text-[10px]">{row.message}</span>
134
- )}
262
+ <span className="ml-auto">
263
+ <FitBadge fit={row.fit} queryError={row.queryError} />
264
+ </span>
135
265
  </div>
136
266
  )
137
267
  }
138
268
 
139
- const TONE_TO_SEVERITY: Record<RightsizingTone, Severity> = {
140
- critical: 'error',
141
- alert: 'alert',
142
- warning: 'warning',
143
- info: 'info',
144
- ok: 'neutral',
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
+ )
145
291
  }
146
292
 
147
- function toneClasses(tone: RightsizingTone): { value: string; badge: string } {
148
- if (tone === 'ok') return { value: 'text-theme-text-secondary', badge: '' }
149
- if (tone === 'info') {
150
- // "Could reduce" is a suggestion, not a problem — mute the badge.
151
- return { value: SEVERITY_TEXT.info, badge: 'text-theme-text-tertiary bg-theme-elevated/60' }
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',
152
308
  }
153
- const sev = TONE_TO_SEVERITY[tone]
154
- return { value: SEVERITY_TEXT[sev], badge: SEVERITY_BADGE[sev] }
309
+ return { label: labels[fit], severity: severities[fit] }
155
310
  }
156
311
 
157
- function toneIcon(tone: RightsizingTone) {
158
- switch (tone) {
159
- case 'critical':
160
- case 'alert':
161
- case 'warning':
162
- return AlertTriangle
163
- case 'info':
164
- return Info
165
- case 'ok':
166
- return Check
167
- default:
168
- return null
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}`
169
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
+ )
170
363
  }
@@ -9,6 +9,7 @@ import { formatBytes } from '../../utils/format'
9
9
  import { downloadBlob, filterTree } from './file-browser-utils'
10
10
  import { Tooltip } from '../ui/Tooltip'
11
11
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
12
+ import { Input } from '@skyhook-io/k8s-ui'
12
13
 
13
14
  // Manual fetch function for filesystem (not a hook - gives us full control)
14
15
  async function fetchImageFilesystem(
@@ -166,8 +167,7 @@ export function ImageFilesystemModal({
166
167
  <div className="p-3 border-b border-theme-border shrink-0">
167
168
  <div className="relative">
168
169
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
169
- <input
170
- type="text"
170
+ <Input
171
171
  placeholder="Search files..."
172
172
  value={searchQuery}
173
173
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -1,7 +1,7 @@
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'
@@ -192,8 +192,7 @@ export function PodFilesystemModal({
192
192
  {showFilesystem && (
193
193
  <div className="relative flex-1 min-w-[200px]">
194
194
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
195
- <input
196
- type="text"
195
+ <Input
197
196
  placeholder="Filter files..."
198
197
  value={searchQuery}
199
198
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -23,6 +23,8 @@ interface ResourceDetailDrawerProps {
23
23
  /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
24
24
  * pass 0 in chromeless embeds where there's no Radar header above it. */
25
25
  headerHeight?: number
26
+ /** Right inset in px so the drawer sits beside a docked side panel (AI), not under it. */
27
+ rightInset?: number
26
28
  }
27
29
 
28
30
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
@@ -41,6 +41,7 @@ interface ResourcesViewProps {
41
41
 
42
42
  type SelectedKindInfo = { name: string; kind: string; group: string } | null
43
43
 
44
+ const EMPTY_RESOURCE_COUNTS: Record<string, number> = {}
44
45
  const LARGE_RESOURCE_LIST_LIMIT = 25000
45
46
  const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
46
47
  'Pod',
@@ -60,6 +61,10 @@ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
60
61
  return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
61
62
  }
62
63
 
64
+ function hasResourceCount(counts: Record<string, number> | undefined, key: string): boolean {
65
+ return Object.prototype.hasOwnProperty.call(counts ?? {}, key)
66
+ }
67
+
63
68
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
64
69
  const location = useLocation()
65
70
  const navigate = useNavigate()
@@ -181,14 +186,16 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
181
186
 
182
187
  const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
183
188
  const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
189
+ const selectedCountKnown = selectedCountKey ? hasResourceCount(countsData?.counts, selectedCountKey) : false
184
190
  const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
185
191
  const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
186
192
  const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
187
- const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT)
193
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT))
188
194
  const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
189
195
  const podCount = countsData?.counts.Pod
196
+ const podCountKnown = hasResourceCount(countsData?.counts, 'Pod')
190
197
  const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
191
- const podCountAllowsBulkMetrics = countsData != null && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
198
+ const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
192
199
  const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
193
200
  const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
194
201
  const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
@@ -248,6 +255,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
248
255
  const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
249
256
  if (!selectedKind) return undefined
250
257
  return {
258
+ resourceName: selectedKind.name,
259
+ group: selectedKind.group,
251
260
  data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
252
261
  isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
253
262
  error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
@@ -315,7 +324,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
315
324
  return (
316
325
  <>
317
326
  <BaseResourcesView
318
- key={location.pathname}
327
+ key={connection.context || 'default'}
319
328
  namespaces={namespaces}
320
329
  selectedResource={selectedResource}
321
330
  onResourceClick={onResourceClick}
@@ -325,7 +334,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
325
334
  // Injected data
326
335
  apiResources={apiResources}
327
336
  // Lightweight counts for sidebar (replaces 233 parallel queries)
328
- resourceCounts={countsData?.counts}
337
+ resourceCounts={countsData?.counts ?? EMPTY_RESOURCE_COUNTS}
329
338
  resourceForbidden={countsData?.forbidden}
330
339
  resourceReasons={countsData?.reasons}
331
340
  resourceUnavailable={countsData?.unavailable}
@@ -0,0 +1 @@
1
+ export * from '@skyhook-io/k8s-ui/components/resources/renderers/CronWorkflowRenderer'
@@ -7,6 +7,7 @@ export { ConfigMapRenderer } from './ConfigMapRenderer'
7
7
  export { SecretRenderer } from './SecretRenderer'
8
8
  export { JobRenderer } from './JobRenderer'
9
9
  export { CronJobRenderer } from './CronJobRenderer'
10
+ export { CronWorkflowRenderer } from './CronWorkflowRenderer'
10
11
  export { HPARenderer } from './HPARenderer'
11
12
  export { NodeRenderer } from './NodeRenderer'
12
13
  export { PVCRenderer } from './PVCRenderer'