@skyhook-io/k8s-ui 1.15.0 → 1.16.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 (37) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  5. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  8. package/src/components/issues/IssuesView.tsx +2 -2
  9. package/src/components/logs/LogCore.tsx +131 -97
  10. package/src/components/logs/StructuredLogLine.tsx +18 -28
  11. package/src/components/logs/log-palette.ts +4 -0
  12. package/src/components/logs/useLogBuffer.ts +8 -39
  13. package/src/components/logs/useLogSearch.ts +52 -24
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  16. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  17. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  18. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  19. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  20. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  21. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  22. package/src/components/shared/ResourceActionsBar.tsx +14 -9
  23. package/src/components/ui/FilterPill.tsx +1 -1
  24. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  25. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  26. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  27. package/src/components/ui/ResourceBar.tsx +12 -6
  28. package/src/components/ui/SelectMenu.tsx +62 -19
  29. package/src/components/ui/index.ts +8 -0
  30. package/src/components/workload/WorkloadView.tsx +109 -3
  31. package/src/types/core.ts +25 -4
  32. package/src/types/fetch-error.ts +33 -0
  33. package/src/utils/log-format.ts +1 -1
  34. package/src/utils/log-level.test.ts +269 -0
  35. package/src/utils/log-level.ts +301 -0
  36. package/src/utils/resource-hierarchy.test.ts +69 -0
  37. package/src/utils/resource-hierarchy.ts +87 -52
@@ -23,6 +23,8 @@ import type {
23
23
  RBACPolicyRule,
24
24
  } from '../../../types'
25
25
  import { Tooltip } from '../../ui/Tooltip'
26
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
27
+ import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../../types/fetch-error'
26
28
  import { MetricsChart } from '../../ui/MetricsChart'
27
29
  import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
28
30
  import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
@@ -191,10 +193,12 @@ function EnvVarsSection({
191
193
  initContainers,
192
194
  containers,
193
195
  resolvedEnvFrom,
196
+ upgrade,
194
197
  }: {
195
198
  initContainers: any[]
196
199
  containers: any[]
197
200
  resolvedEnvFrom?: ResolvedEnvFrom
201
+ upgrade?: RadarUpgradeRequirement | null
198
202
  }) {
199
203
  const allContainers = [...initContainers, ...containers]
200
204
  const containersWithEnv = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0)
@@ -205,6 +209,7 @@ function EnvVarsSection({
205
209
  return (
206
210
  <Section title={`Environment Variables · ${subtitle}`} icon={List} defaultExpanded={false}>
207
211
  <div className="space-y-4">
212
+ {upgrade && <RadarUpgradeNote requirement={upgrade} />}
208
213
  {allContainers.map((container: any) => {
209
214
  const envVars: any[] = container.env || []
210
215
  const envFrom: any[] = container.envFrom || []
@@ -312,6 +317,8 @@ export function PodRenderer({
312
317
  const hasResolvedEnvironmentRows = environment?.containers.some(
313
318
  container => container.rows.length > 0 || container.truncated,
314
319
  ) ?? false
320
+ // An older Radar can't resolve variable sources; fall back to the spec view.
321
+ const environmentUpgrade = getRadarUpgradeRequirement(environmentError)
315
322
 
316
323
  const namespace = data.metadata?.namespace
317
324
  const podName = data.metadata?.name
@@ -792,7 +799,7 @@ export function PodRenderer({
792
799
  <div className="text-xs text-theme-text-tertiary">Loading variable sources…</div>
793
800
  </Section>
794
801
  )}
795
- {hasEnvironmentDeclarations && environmentError && !environment && (
802
+ {hasEnvironmentDeclarations && environmentError && !environment && !environmentUpgrade && (
796
803
  <Section title="Environment Variables" icon={List} defaultExpanded={false}>
797
804
  <div className="space-y-2 text-xs">
798
805
  <p className="text-theme-text-secondary">Variable sources could not be loaded.</p>
@@ -800,11 +807,12 @@ export function PodRenderer({
800
807
  </div>
801
808
  </Section>
802
809
  )}
803
- {(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && !environmentError && hasEnvironmentDeclarations && (
810
+ {(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && (!environmentError || environmentUpgrade) && hasEnvironmentDeclarations && (
804
811
  <EnvVarsSection
805
812
  initContainers={initContainers}
806
813
  containers={containers}
807
814
  resolvedEnvFrom={resolvedEnvFrom}
815
+ upgrade={environmentUpgrade}
808
816
  />
809
817
  )}
810
818
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import { PolicySection } from './PolicySection'
4
+ import { RadarUpgradeContext } from '../../ui/RadarUpgradeNote'
4
5
  import type { PolicyResourceResponse } from '../../../types/policy'
5
6
 
6
7
  const base: PolicyResourceResponse = {
@@ -157,3 +158,44 @@ describe('when results were withheld from this caller', () => {
157
158
  expect(html).not.toContain('not shown here')
158
159
  })
159
160
  })
161
+
162
+ // An older in-cluster Radar behind a newer frontend (Radar Hub) has no policy
163
+ // endpoint. That is an expected state, so it gets an upgrade prompt, not red.
164
+ describe('PolicySection — connected Radar predates policy results', () => {
165
+ const unsupported = Object.assign(new Error('Policy results need Radar v1.10.0 or newer'), {
166
+ radarUpgrade: { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2' },
167
+ })
168
+
169
+ it('prompts an upgrade instead of reporting a failure', () => {
170
+ const html = renderToString(<PolicySection data={null} error={unsupported} />)
171
+ expect(html).toContain('Policy results')
172
+ expect(html).toContain('need Radar v1.10 or newer on this cluster. It runs v1.7.2.')
173
+ expect(html).not.toContain('Could not load policy results')
174
+ expect(html).not.toContain('text-red-400')
175
+ })
176
+
177
+ it('hands the upgrade to the host when it offers a flow', () => {
178
+ const html = renderToString(
179
+ <RadarUpgradeContext.Provider value={{ onRequestUpgrade: () => undefined }}>
180
+ <PolicySection data={null} error={unsupported} />
181
+ </RadarUpgradeContext.Provider>,
182
+ )
183
+ expect(html).toContain('<button')
184
+ expect(html).toContain('Upgrade Radar')
185
+ })
186
+
187
+ it('links to instructions when the host has no flow', () => {
188
+ const html = renderToString(
189
+ <RadarUpgradeContext.Provider value={{ upgradeHref: 'https://radarhq.io/docs/configuration/in-cluster' }}>
190
+ <PolicySection data={null} error={unsupported} />
191
+ </RadarUpgradeContext.Provider>,
192
+ )
193
+ expect(html).toContain('href="https://radarhq.io/docs/configuration/in-cluster"')
194
+ expect(html).toContain('How to upgrade')
195
+ })
196
+
197
+ it('keeps a genuine failure red', () => {
198
+ const html = renderToString(<PolicySection data={null} error={new Error('HTTP 502 (Bad Gateway)')} />)
199
+ expect(html).toContain('Could not load policy results: HTTP 502 (Bad Gateway)')
200
+ })
201
+ })
@@ -2,7 +2,8 @@ import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section } from '../../ui/drawer-components'
4
4
  import { SEVERITY_BADGE } from '../../../utils/badge-colors'
5
- import { isForbiddenError } from '../../../types/fetch-error'
5
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
6
+ import { getRadarUpgradeRequirement, isForbiddenError } from '../../../types/fetch-error'
6
7
  import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
7
8
 
8
9
  interface PolicySectionProps {
@@ -61,6 +62,14 @@ export function PolicySection({ data, loading, error }: PolicySectionProps) {
61
62
  </Section>
62
63
  )
63
64
  }
65
+ const upgrade = getRadarUpgradeRequirement(error)
66
+ if (upgrade) {
67
+ return (
68
+ <Section title={TITLE} icon={ShieldQuestion}>
69
+ <RadarUpgradeNote requirement={upgrade} />
70
+ </Section>
71
+ )
72
+ }
64
73
  return (
65
74
  <Section title={TITLE} icon={ShieldAlert}>
66
75
  <div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
@@ -88,6 +88,16 @@ describe('DrainPlanContent', () => {
88
88
  )
89
89
  }
90
90
 
91
+ it('says a drain plan needs a newer Radar when the cluster predates it', () => {
92
+ const html = render({
93
+ planSupported: false,
94
+ planUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0', currentVersion: 'v1.13.1' },
95
+ })
96
+ expect(html).toContain('Drain plans')
97
+ expect(html).toContain('need Radar v1.14 or newer on this cluster.')
98
+ expect(html).not.toContain('Computing the plan')
99
+ })
100
+
91
101
  it('shows per-pod outcomes with reasons and calls the result an estimate', () => {
92
102
  const html = render({ plan: plan([pod('web', 'may-block', { pdb: 'shop/web', reason: 'PodDisruptionBudget shop/web currently allows no disruptions' }), pod('agent', 'skip')]) })
93
103
  expect(html).toContain('may block')
@@ -3,6 +3,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
3
3
  import { Badge, type BadgeSeverity } from '../ui/Badge'
4
4
  import { AlertBanner } from '../ui/drawer-components'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { RadarUpgradeNote } from '../ui/RadarUpgradeNote'
7
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
6
8
  import { pluralize } from '../../utils/pluralize'
7
9
 
8
10
  // Shapes returned by POST /api/nodes/{name}/drain-plan.
@@ -102,6 +104,8 @@ interface DrainPlanContentProps {
102
104
  options: DrainDialogOptions
103
105
  onOptionsChange: (options: DrainDialogOptions) => void
104
106
  planSupported: boolean
107
+ /** Why there is no plan, when the connected Radar predates drain plans. */
108
+ planUpgrade?: RadarUpgradeRequirement | null
105
109
  /** Recompute the plan with the current options; also the retry after a failed plan. */
106
110
  onRefreshPlan?: () => void
107
111
  }
@@ -112,7 +116,7 @@ interface DrainPlanContentProps {
112
116
  * react-dom/server); the enclosing dialog owns the confirm gating.
113
117
  */
114
118
  export function DrainPlanContent({
115
- nodeName, plan, loading, error, options, onOptionsChange, planSupported, onRefreshPlan,
119
+ nodeName, plan, loading, error, options, onOptionsChange, planSupported, planUpgrade, onRefreshPlan,
116
120
  }: DrainPlanContentProps) {
117
121
  const current = planMatches(plan, nodeName, options) ? plan : null
118
122
  const atRisk = current ? emptyDirPodsAtRisk(current) : []
@@ -140,6 +144,8 @@ export function DrainPlanContent({
140
144
  </label>
141
145
  </div>
142
146
 
147
+ {!planSupported && planUpgrade && <RadarUpgradeNote requirement={planUpgrade} />}
148
+
143
149
  {planSupported && loading && (
144
150
  <div className="flex items-center gap-2 text-theme-text-secondary">
145
151
  <Loader2 className="w-4 h-4 animate-spin" /> Computing the plan…
@@ -259,12 +265,14 @@ interface DrainPlanDialogProps {
259
265
  isDraining: boolean
260
266
  /** Whether the host can compute plans. Without it the dialog confirms without one. */
261
267
  planSupported: boolean
268
+ /** Why there is no plan, when the connected Radar predates drain plans. */
269
+ planUpgrade?: RadarUpgradeRequirement | null
262
270
  /** Recompute the plan with the current options; also the retry after a failed plan. */
263
271
  onRefreshPlan?: () => void
264
272
  }
265
273
 
266
274
  export function DrainPlanDialog({
267
- open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, onRefreshPlan,
275
+ open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, planUpgrade, onRefreshPlan,
268
276
  }: DrainPlanDialogProps) {
269
277
  const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, error, planSupported })
270
278
 
@@ -274,7 +282,9 @@ export function DrainPlanDialog({
274
282
  onClose={onClose}
275
283
  onConfirm={() => onConfirm(options)}
276
284
  title="Drain Node"
277
- message={`Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`}
285
+ message={planSupported
286
+ ? `Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`
287
+ : `Cordon "${nodeName}" and evict its pods.`}
278
288
  confirmLabel={isDraining ? 'Draining...' : 'Drain'}
279
289
  variant="danger"
280
290
  isLoading={isDraining}
@@ -290,6 +300,7 @@ export function DrainPlanDialog({
290
300
  options={options}
291
301
  onOptionsChange={onOptionsChange}
292
302
  planSupported={planSupported}
303
+ planUpgrade={planUpgrade}
293
304
  onRefreshPlan={onRefreshPlan}
294
305
  />
295
306
  </ConfirmDialog>
@@ -25,6 +25,7 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
25
25
  import { AlertBanner } from '../ui/drawer-components'
26
26
  import { DialogPortal } from '../ui/DialogPortal'
27
27
  import type { SelectedResource, WorkloadRevision } from '../../types'
28
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
28
29
  import { displayKindName } from '../ui/drawer-components'
29
30
  import { getDefaultContainerName } from '../resources/resource-utils'
30
31
  import { SetImageDialog, type ManagedImageSource } from './SetImageDialog'
@@ -143,10 +144,12 @@ interface ResourceActionsBarProps {
143
144
  drainPlan?: DrainPlan | null
144
145
  isPlanningDrain?: boolean
145
146
  drainPlanError?: string | null
146
- // The connected backend has no drain-plan endpoint (version skew, e.g. a newer
147
- // frontend against an older radar). The dialog falls back to the plan-less
148
- // acknowledgement-only mode instead of keeping Drain disabled on a dead request.
149
- drainPlanUnsupported?: boolean
147
+ // The connected Radar predates the drain-plan endpoint (a newer frontend
148
+ // against an older Radar). The dialog falls back to the plan-less
149
+ // acknowledgement-only mode instead of keeping Drain disabled on a dead
150
+ // request, and names the Radar version that serves plans so the user knows a
151
+ // preview exists.
152
+ drainPlanUpgrade?: RadarUpgradeRequirement | null
150
153
  }
151
154
 
152
155
  export function ResourceActionsBar({
@@ -177,7 +180,7 @@ export function ResourceActionsBar({
177
180
  onCordonNode, isCordoningNode,
178
181
  onUncordonNode, isUncordoningNode,
179
182
  onDrainNode, isDrainingNode,
180
- onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUnsupported,
183
+ onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
181
184
  }: ResourceActionsBarProps) {
182
185
  const kind = resource.kind.toLowerCase()
183
186
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
@@ -202,13 +205,14 @@ export function ResourceActionsBar({
202
205
  // open: the host reports it through a mutation error, which the next request clears,
203
206
  // so without latching every option change would refire a request known to 404 and
204
207
  // bounce the dialog out of its fallback mode.
205
- const [planUnsupported, setPlanUnsupported] = useState(false)
208
+ const [planUpgrade, setPlanUpgrade] = useState<RadarUpgradeRequirement | null>(null)
206
209
  useEffect(() => {
207
- if (drainPlanUnsupported) setPlanUnsupported(true)
208
- }, [drainPlanUnsupported])
210
+ if (drainPlanUpgrade) setPlanUpgrade(drainPlanUpgrade)
211
+ }, [drainPlanUpgrade])
209
212
  useEffect(() => {
210
- if (!showDrainConfirm) setPlanUnsupported(false)
213
+ if (!showDrainConfirm) setPlanUpgrade(null)
211
214
  }, [showDrainConfirm])
215
+ const planUnsupported = planUpgrade !== null
212
216
  const planSupported = Boolean(onPlanDrain) && !planUnsupported
213
217
 
214
218
  // Fetch (and refetch on option changes) the read-only plan while the drain dialog is open.
@@ -719,6 +723,7 @@ export function ResourceActionsBar({
719
723
  options={drainOptions}
720
724
  onOptionsChange={setDrainOptions}
721
725
  planSupported={planSupported}
726
+ planUpgrade={planUpgrade}
722
727
  onRefreshPlan={planSupported ? () => onPlanDrain?.({ name: resource.name, options: drainOptions }) : undefined}
723
728
  isDraining={Boolean(isDrainingNode)}
724
729
  onClose={() => {
@@ -52,7 +52,7 @@ const TONE_ACTIVE: Record<FilterPillTone, string> = {
52
52
  high: 'bg-orange-500/15 border-orange-500/40 text-orange-800 dark:text-orange-300',
53
53
  medium: 'bg-yellow-500/15 border-yellow-500/40 text-yellow-800 dark:text-yellow-300',
54
54
  ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
55
- brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
55
+ brand: 'bg-[var(--color-brand-50,var(--accent-muted))] border-[var(--color-radar-accent,var(--accent))] text-theme-text-primary dark:bg-[var(--color-brand-950,var(--accent-muted))]',
56
56
  }
57
57
 
58
58
  const INACTIVE = 'border-theme-border-light text-theme-text-secondary hover:border-theme-border hover:text-theme-text-primary hover:bg-theme-hover/50'
@@ -0,0 +1,24 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { RadarUpgradeContext, RadarUpgradeNote } from './RadarUpgradeNote'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ it('hands the whole requirement to the host when the user asks to upgrade', async () => {
10
+ const onRequestUpgrade = vi.fn()
11
+ const requirement = { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2', latestVersion: 'v1.16.0' }
12
+ const element = document.createElement('div')
13
+ const root = createRoot(element)
14
+ await act(async () => {
15
+ root.render(
16
+ <RadarUpgradeContext.Provider value={{ onRequestUpgrade }}>
17
+ <RadarUpgradeNote requirement={requirement} />
18
+ </RadarUpgradeContext.Provider>,
19
+ )
20
+ })
21
+ await act(async () => { element.querySelector('button')!.click() })
22
+ expect(onRequestUpgrade).toHaveBeenCalledWith(requirement)
23
+ await act(async () => root.unmount())
24
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { formatRadarVersion, radarUpgradeDetail, radarUpgradeHeadline, RadarUpgradeNote } from './RadarUpgradeNote'
4
+ import { getRadarUpgradeRequirement } from '../../types/fetch-error'
5
+
6
+ describe('radarUpgradeHeadline', () => {
7
+ it('says the cluster is behind, not the UI', () => {
8
+ expect(radarUpgradeHeadline('Capacity')).toBe('Capacity needs a newer Radar on this cluster')
9
+ })
10
+ })
11
+
12
+ describe('radarUpgradeDetail', () => {
13
+ it('quotes the minimum and a real current version', () => {
14
+ expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: '1.7.2' }))
15
+ .toBe('Available from Radar v1.10. This cluster runs v1.7.2.')
16
+ })
17
+
18
+ it('leaves out a current version that is not a version', () => {
19
+ expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'dev' }))
20
+ .toBe('Available from Radar v1.10.')
21
+ expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0' }))
22
+ .toBe('Available from Radar v1.10.')
23
+ })
24
+ })
25
+
26
+ describe('radarUpgradeDetail without a known first release', () => {
27
+ const unknownSince = { feature: 'Drift alerts', currentVersion: 'v1.7.2' }
28
+
29
+ it('asks for the latest release when it is newer than the running one', () => {
30
+ expect(radarUpgradeDetail({ ...unknownSince, latestVersion: '1.16.0' }))
31
+ .toBe('Available in the latest Radar, v1.16. This cluster runs v1.7.2.')
32
+ })
33
+
34
+ it('only names the running version when no target release is known', () => {
35
+ expect(radarUpgradeDetail(unknownSince)).toBe('This cluster runs v1.7.2.')
36
+ expect(radarUpgradeDetail({ ...unknownSince, currentVersion: 'v1.16.0', latestVersion: 'v1.16.0' }))
37
+ .toBe('This cluster runs v1.16.')
38
+ })
39
+
40
+ it('reads the same way inline', () => {
41
+ const html = renderToString(<RadarUpgradeNote requirement={{ ...unknownSince, latestVersion: 'v1.16.0' }} />)
42
+ expect(html).toContain('need the latest Radar (v1.16) on this cluster. It runs v1.7.2.')
43
+ })
44
+ })
45
+
46
+ describe('formatRadarVersion', () => {
47
+ it('drops a zero patch and adds the v', () => {
48
+ expect(formatRadarVersion('v1.14.0')).toBe('v1.14')
49
+ expect(formatRadarVersion('1.7.2')).toBe('v1.7.2')
50
+ expect(formatRadarVersion('v1.7.2-dirty')).toBe('v1.7.2-dirty')
51
+ })
52
+ })
53
+
54
+ describe('getRadarUpgradeRequirement', () => {
55
+ it('reads the requirement off any error that carries one', () => {
56
+ const error = Object.assign(new Error('x'), { radarUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0' } })
57
+ expect(getRadarUpgradeRequirement(error)).toEqual({ feature: 'Drain plans', minimumVersion: 'v1.14.0' })
58
+ })
59
+
60
+ it('ignores plain errors and malformed requirements', () => {
61
+ expect(getRadarUpgradeRequirement(new Error('HTTP 404 (Not Found)'))).toBeNull()
62
+ expect(getRadarUpgradeRequirement({ radarUpgrade: { minimumVersion: 'v1.9.0' } })).toBeNull()
63
+ expect(getRadarUpgradeRequirement({ radarUpgrade: { feature: 'x', minimumVersion: 9 } })).toBeNull()
64
+ expect(getRadarUpgradeRequirement(null)).toBeNull()
65
+ })
66
+ })
@@ -0,0 +1,129 @@
1
+ import { createContext, useContext } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { ArrowUpCircle, ExternalLink } from 'lucide-react'
4
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
5
+
6
+ export interface RadarUpgradeActions {
7
+ /** Hands the upgrade to the host, e.g. Radar Cloud's role-aware upgrade flow. */
8
+ onRequestUpgrade?: (requirement: RadarUpgradeRequirement) => void
9
+ /** Used when there is no handler: a page with upgrade instructions. */
10
+ upgradeHref?: string
11
+ }
12
+
13
+ export const RadarUpgradeContext = createContext<RadarUpgradeActions>({})
14
+
15
+ /** "v1.10.0" → "v1.10", "1.7.2" → "v1.7.2". Anything else is shown as given. */
16
+ export function formatRadarVersion(version: string): string {
17
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim())
18
+ if (!match) return version
19
+ const [, major, minor, patch] = match
20
+ return patch === '0' ? `v${major}.${minor}` : `v${major}.${minor}.${patch}`
21
+ }
22
+
23
+ function parseVersion(version: string | undefined): number[] | null {
24
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? '')
25
+ return match ? match.slice(1).map(Number) : null
26
+ }
27
+
28
+ function isNewer(a: number[], b: number[]): boolean {
29
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]
30
+ return false
31
+ }
32
+
33
+ /**
34
+ * Which Radar to ask for. The release that introduced the feature when it is
35
+ * known; otherwise the latest release, which serves everything this UI calls;
36
+ * otherwise just "a newer Radar".
37
+ */
38
+ function target(requirement: RadarUpgradeRequirement): { since?: string; latest?: string } {
39
+ if (requirement.minimumVersion) return { since: formatRadarVersion(requirement.minimumVersion) }
40
+ const { latestVersion } = requirement
41
+ const latest = parseVersion(latestVersion)
42
+ const current = parseVersion(requirement.currentVersion)
43
+ if (latestVersion && latest && (!current || isNewer(latest, current))) {
44
+ return { latest: formatRadarVersion(latestVersion) }
45
+ }
46
+ return {}
47
+ }
48
+
49
+ // The Radar that is behind is the cluster's, not the UI the user is looking
50
+ // at, so the copy names the cluster. "It runs dev." says nothing useful, so
51
+ // only a real version is quoted.
52
+ function clusterVersion(requirement: RadarUpgradeRequirement): string | undefined {
53
+ const current = requirement.currentVersion?.trim()
54
+ return current && /^v?\d+\.\d+/.test(current) ? formatRadarVersion(current) : undefined
55
+ }
56
+
57
+ /** Headline for a full-page state: "Capacity needs a newer Radar on this cluster". */
58
+ export function radarUpgradeHeadline(feature: string): string {
59
+ return `${feature} needs a newer Radar on this cluster`
60
+ }
61
+
62
+ /** Detail under that headline: "Available from Radar v1.9. This cluster runs v1.7.2." */
63
+ export function radarUpgradeDetail(requirement: RadarUpgradeRequirement): string {
64
+ const { since, latest } = target(requirement)
65
+ const current = clusterVersion(requirement)
66
+ return [
67
+ since ? `Available from Radar ${since}.` : latest ? `Available in the latest Radar, ${latest}.` : '',
68
+ current ? `This cluster runs ${current}.` : '',
69
+ ].filter(Boolean).join(' ')
70
+ }
71
+
72
+ /** Follows the feature name in an inline note: "need Radar v1.10 or newer on this cluster. It runs v1.7.2." */
73
+ function radarUpgradeNeed(requirement: RadarUpgradeRequirement): string {
74
+ const { since, latest } = target(requirement)
75
+ const current = clusterVersion(requirement)
76
+ const need = since
77
+ ? `need Radar ${since} or newer on this cluster.`
78
+ : latest
79
+ ? `need the latest Radar (${latest}) on this cluster.`
80
+ : 'need a newer Radar on this cluster.'
81
+ return current ? `${need} It runs ${current}.` : need
82
+ }
83
+
84
+ /** The upgrade call to action, or nothing when the host offers no way to upgrade. */
85
+ export function RadarUpgradeAction({ requirement }: { requirement: RadarUpgradeRequirement }) {
86
+ const { onRequestUpgrade, upgradeHref } = useContext(RadarUpgradeContext)
87
+ const className = 'inline-flex items-center gap-1 font-medium text-accent-text hover:underline'
88
+ if (onRequestUpgrade) {
89
+ return (
90
+ <button type="button" className={className} onClick={() => onRequestUpgrade(requirement)}>
91
+ Upgrade Radar
92
+ </button>
93
+ )
94
+ }
95
+ if (upgradeHref) {
96
+ return (
97
+ <a href={upgradeHref} target="_blank" rel="noopener noreferrer" className={className}>
98
+ How to upgrade
99
+ <ExternalLink className="h-3 w-3" aria-hidden />
100
+ </a>
101
+ )
102
+ }
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * Inline note for a feature the connected Radar is too old to serve. Calm on
108
+ * purpose: nothing failed, the cluster just runs an older Radar than this UI.
109
+ */
110
+ export function RadarUpgradeNote({
111
+ requirement,
112
+ className,
113
+ }: {
114
+ requirement: RadarUpgradeRequirement
115
+ className?: string
116
+ }) {
117
+ return (
118
+ <div className={clsx('flex items-start gap-2 text-xs text-theme-text-secondary', className)}>
119
+ <ArrowUpCircle className="mt-px h-3.5 w-3.5 shrink-0 text-accent-text" aria-hidden />
120
+ <p className="min-w-0">
121
+ <span className="font-medium text-theme-text-primary">{requirement.feature}</span>
122
+ {' '}
123
+ {radarUpgradeNeed(requirement)}
124
+ {' '}
125
+ <RadarUpgradeAction requirement={requirement} />
126
+ </p>
127
+ </div>
128
+ )
129
+ }
@@ -60,7 +60,7 @@ export function ResourceBar({
60
60
  label,
61
61
  }: ResourceBarProps) {
62
62
  const track = (
63
- <div className={clsx('relative', layout === 'inline' && 'min-w-0 flex-1')}>
63
+ <div className={clsx('relative', layout === 'inline' && 'min-w-16 flex-1')}>
64
64
  <div className={clsx('rounded-full border border-theme-border bg-theme-elevated overflow-hidden', layout === 'inline' ? 'h-1' : 'h-1.5')}>
65
65
  <div
66
66
  className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
@@ -78,7 +78,7 @@ export function ResourceBar({
78
78
 
79
79
  const bar =
80
80
  layout === 'inline' ? (
81
- <div className="flex items-center gap-1.5 min-w-0">
81
+ <div className={clsx('flex items-center gap-1.5 min-w-0', tooltip && 'w-full')}>
82
82
  <span className="w-7 shrink-0 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</span>
83
83
  {track}
84
84
  <span className="w-8 shrink-0 text-right text-[10.5px] font-mono tabular-nums text-theme-text-secondary">
@@ -86,7 +86,7 @@ export function ResourceBar({
86
86
  </span>
87
87
  </div>
88
88
  ) : (
89
- <div className="flex flex-col gap-0.5 min-w-0">
89
+ <div className={clsx('flex flex-col gap-0.5 min-w-0', tooltip && 'w-full')}>
90
90
  <div className="flex items-baseline justify-between gap-1">
91
91
  <span className="text-xs font-mono text-theme-text-secondary truncate">
92
92
  {used} / {total}
@@ -99,11 +99,17 @@ export function ResourceBar({
99
99
  </div>
100
100
  )
101
101
 
102
+ // Tooltip's wrapper is inline-flex: it sizes the row to its content (hence
103
+ // w-full on the row, or the track collapses to the labels' width) and it is
104
+ // inline-level, so the block div keeps sibling bars stacked instead of
105
+ // sharing one line.
102
106
  if (tooltip) {
103
107
  return (
104
- <Tooltip content={tooltip} delay={200} position="top" wrapperClassName="w-full min-w-0">
105
- {bar}
106
- </Tooltip>
108
+ <div className="min-w-0">
109
+ <Tooltip content={tooltip} delay={200} position="top" wrapperClassName="w-full min-w-0">
110
+ {bar}
111
+ </Tooltip>
112
+ </div>
107
113
  )
108
114
  }
109
115