@skyhook-io/radar-app 1.12.2 → 1.13.0

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 (60) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +35 -15
  3. package/src/api/client.images.test.ts +63 -0
  4. package/src/api/client.ts +246 -39
  5. package/src/api/client.yaml.test.ts +3 -3
  6. package/src/api/version-check.test.ts +78 -0
  7. package/src/components/CloudConnectFlow.tsx +46 -26
  8. package/src/components/CloudFunnelButton.tsx +166 -129
  9. package/src/components/ConnectionErrorView.test.tsx +21 -1
  10. package/src/components/ConnectionErrorView.tsx +7 -8
  11. package/src/components/applications/ApplicationsView.tsx +10 -9
  12. package/src/components/audit/AuditView.tsx +6 -3
  13. package/src/components/audit/UpgradeReadinessView.test.ts +26 -2
  14. package/src/components/audit/UpgradeReadinessView.tsx +19 -9
  15. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  16. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  17. package/src/components/cost/CostTrendChart.test.ts +65 -0
  18. package/src/components/cost/CostTrendChart.tsx +107 -17
  19. package/src/components/cost/CostView.test.ts +36 -1
  20. package/src/components/cost/CostView.tsx +133 -51
  21. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  22. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  23. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  24. package/src/components/cost/source.test.ts +33 -0
  25. package/src/components/cost/source.ts +100 -0
  26. package/src/components/diagnose/DiagnoseSurface.tsx +14 -10
  27. package/src/components/diagnose/parts.test.tsx +12 -4
  28. package/src/components/diagnose/parts.tsx +19 -15
  29. package/src/components/gitops/GitOpsView.tsx +8 -3
  30. package/src/components/helm/HelmReleaseDrawer.tsx +4 -3
  31. package/src/components/helm/OwnedResources.tsx +10 -2
  32. package/src/components/home/ClusterHealthCard.test.ts +31 -0
  33. package/src/components/home/ClusterHealthCard.tsx +60 -1
  34. package/src/components/home/CostCard.tsx +3 -2
  35. package/src/components/home/HomeView.tsx +36 -11
  36. package/src/components/home/MCPSetupDialog.tsx +5 -4
  37. package/src/components/home/RadarVersionLine.test.tsx +145 -0
  38. package/src/components/home/RadarVersionLine.tsx +137 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +54 -2
  40. package/src/components/resources/ResourcesView.tsx +31 -8
  41. package/src/components/resources/renderers/WorkloadRenderer.tsx +13 -5
  42. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  43. package/src/components/rightsizing/copy.test.ts +19 -0
  44. package/src/components/settings/SettingsDialog.tsx +687 -107
  45. package/src/components/settings/settings-state.test.ts +42 -0
  46. package/src/components/settings/settings-state.ts +39 -0
  47. package/src/components/ui/ErrorBoundary.test.tsx +55 -0
  48. package/src/components/ui/ErrorBoundary.tsx +17 -2
  49. package/src/components/ui/UpdateNotification.test.tsx +49 -0
  50. package/src/components/ui/UpdateNotification.tsx +6 -2
  51. package/src/components/workload/WorkloadView.test.ts +60 -0
  52. package/src/components/workload/WorkloadView.tsx +270 -29
  53. package/src/contexts/CapabilitiesContext.test.tsx +29 -0
  54. package/src/contexts/CapabilitiesContext.tsx +7 -3
  55. package/src/index.css +11 -1
  56. package/src/utils/navigation.test.ts +45 -0
  57. package/src/utils/navigation.ts +5 -5
  58. package/src/utils/topology-selection.ts +3 -2
  59. package/src/utils/version.test.ts +37 -0
  60. package/src/utils/version.ts +56 -0
@@ -4,7 +4,7 @@ import { getResourceIcon } from '../../utils/resource-icons'
4
4
  import { clsx } from 'clsx'
5
5
  import type { HelmOwnedResource } from '../../types'
6
6
  import type { NavigateToResource } from '../../utils/navigation'
7
- import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
7
+ import { kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
8
8
  import { getResourceStatusColor, SEVERITY_BADGE } from '../../utils/badge-colors'
9
9
  import { useQueryClient } from '@tanstack/react-query'
10
10
  import { useOpenTerminal, useOpenLogs } from '../dock'
@@ -207,7 +207,15 @@ function ResourceItem({ resource, onNavigate }: ResourceItemProps) {
207
207
 
208
208
  const handleClick = () => {
209
209
  if (onNavigate) {
210
- onNavigate({ kind: kindToPlural(resource.kind), namespace: resource.namespace, name: resource.name, group: apiVersionToGroup(resource.apiVersion) })
210
+ onNavigate({
211
+ kind: kindToPluralWithGroup(
212
+ resource.kind,
213
+ apiVersionToGroup(resource.apiVersion),
214
+ ),
215
+ namespace: resource.namespace,
216
+ name: resource.name,
217
+ group: apiVersionToGroup(resource.apiVersion),
218
+ })
211
219
  }
212
220
  }
213
221
 
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { minorsBehind } from "./ClusterHealthCard";
3
+
4
+ describe("minorsBehind", () => {
5
+ it("counts minors between plain versions", () => {
6
+ expect(minorsBehind("1.33", "1.37")).toBe(4);
7
+ expect(minorsBehind("1.36", "1.37")).toBe(1);
8
+ });
9
+
10
+ it("returns 0 when current matches or exceeds the catalog", () => {
11
+ expect(minorsBehind("1.37", "1.37")).toBe(0);
12
+ expect(minorsBehind("1.38", "1.37")).toBe(0);
13
+ });
14
+
15
+ it("handles distribution version suffixes", () => {
16
+ expect(minorsBehind("v1.33.6+k3s1", "1.37")).toBe(4);
17
+ expect(minorsBehind("1.31.5-gke.1023000", "1.37")).toBe(6);
18
+ expect(minorsBehind("v1.32.4-eks-abc123", "1.37")).toBe(5);
19
+ });
20
+
21
+ it("stays quiet on cross-major or unparseable input", () => {
22
+ expect(minorsBehind("2.0.0", "1.37")).toBe(0);
23
+ expect(minorsBehind("unknown", "1.37")).toBe(0);
24
+ expect(minorsBehind("", "1.37")).toBe(0);
25
+ });
26
+
27
+ it("stays quiet when the catalog version is missing", () => {
28
+ expect(minorsBehind("1.33", undefined)).toBe(0);
29
+ expect(minorsBehind("1.33", "")).toBe(0);
30
+ });
31
+ });
@@ -13,6 +13,7 @@ import { MCPSetupDialog } from './MCPSetupDialog'
13
13
  import { assetUrl, pluralize, parseContextName } from '@skyhook-io/k8s-ui'
14
14
  import { Tooltip } from '../ui/Tooltip'
15
15
  import { routePath } from '../../api/config'
16
+ import { parseMajorMinor } from '../../utils/version'
16
17
  import gkeIcon from '../../assets/platform-icons/google_kubernetes_engine.png'
17
18
  import eksIcon from '../../assets/platform-icons/aws_eks.png'
18
19
  import aksIcon from '../../assets/platform-icons/azure-aks.svg'
@@ -33,11 +34,14 @@ interface ClusterHealthCardProps {
33
34
  nodeVersionSkew: DashboardResponse['nodeVersionSkew']
34
35
  onNavigateToKind: (kind: string, group?: string) => void
35
36
  onNavigateToView: () => void
37
+ // Left unset in Cloud takeover mode, where /checks is owned by the host.
38
+ onNavigateToUpgradeImpact?: () => void
36
39
  onWarningEventsClick?: () => void
37
40
  onIssuesClick?: () => void
38
41
  // Freshness/refresh control for the dashboard poll — rendered under the
39
42
  // cluster metadata so the overview carries a freshness signal without a band.
40
43
  freshness?: ReactNode
44
+ radarVersion?: ReactNode
41
45
  }
42
46
 
43
47
  function getMetricsInstallHint(platform: string): string {
@@ -130,9 +134,11 @@ export function ClusterHealthCard({
130
134
  nodeVersionSkew,
131
135
  onNavigateToKind,
132
136
  onNavigateToView,
137
+ onNavigateToUpgradeImpact,
133
138
  onWarningEventsClick,
134
139
  onIssuesClick,
135
140
  freshness,
141
+ radarVersion,
136
142
  }: ClusterHealthCardProps) {
137
143
  void _topCRDs // Reserved for future CRD display
138
144
 
@@ -251,8 +257,13 @@ export function ClusterHealthCard({
251
257
  </Tooltip>
252
258
  )}
253
259
  {cluster.version && (
254
- <span>Kubernetes {cluster.version}</span>
260
+ <KubernetesVersionLine
261
+ version={cluster.version}
262
+ reviewedThrough={cluster.upgradeReviewedThrough}
263
+ onNavigate={onNavigateToUpgradeImpact}
264
+ />
255
265
  )}
266
+ {radarVersion}
256
267
  <span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
257
268
  {/* Show raw kubeconfig context as muted metadata only when
258
269
  it differs from the headline AND we're in local mode
@@ -519,6 +530,54 @@ export function ClusterHealthCard({
519
530
  )
520
531
  }
521
532
 
533
+ // How many minors the running version trails the newest one the upgrade-impact
534
+ // catalog covers. Cross-major comparisons return 0 — better a missing hint than
535
+ // a wrong count on an exotic version string.
536
+ export function minorsBehind(current: string, reviewedThrough?: string): number {
537
+ const cur = parseMajorMinor(current)
538
+ const latest = reviewedThrough ? parseMajorMinor(reviewedThrough) : null
539
+ if (!cur || !latest || latest.major !== cur.major) return 0
540
+ return Math.max(0, latest.minor - cur.minor)
541
+ }
542
+
543
+ function KubernetesVersionLine({
544
+ version,
545
+ reviewedThrough,
546
+ onNavigate,
547
+ }: {
548
+ version: string
549
+ reviewedThrough?: string
550
+ onNavigate?: () => void
551
+ }) {
552
+ if (!onNavigate) {
553
+ return <span>Kubernetes {version}</span>
554
+ }
555
+
556
+ const behind = minorsBehind(version, reviewedThrough)
557
+
558
+ return (
559
+ <Tooltip
560
+ content={
561
+ behind > 0
562
+ ? `Radar's upgrade checks cover Kubernetes through ${reviewedThrough}. Click to assess the next minor upgrade.`
563
+ : 'Assess the next minor Kubernetes upgrade.'
564
+ }
565
+ wrapperClassName="w-fit"
566
+ >
567
+ <button
568
+ onClick={onNavigate}
569
+ className="group flex items-center gap-1 hover:text-theme-text-secondary transition-colors"
570
+ >
571
+ <span>Kubernetes {version}</span>
572
+ {behind > 0 && (
573
+ <span>· Upgrade impact</span>
574
+ )}
575
+ <ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
576
+ </button>
577
+ </Tooltip>
578
+ )
579
+ }
580
+
522
581
  function RestrictedRing({ label }: { label: string }) {
523
582
  const radius = 36
524
583
  const circumference = 2 * Math.PI * radius
@@ -8,6 +8,7 @@ import {
8
8
  formatProjectedMonthlyCost,
9
9
  formatProjectedMonthlyRate,
10
10
  } from '../cost/format'
11
+ import { costFreshnessLabel } from '../cost/source'
11
12
 
12
13
  export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
13
14
  const { data } = useOpenCostSummary()
@@ -87,13 +88,13 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
87
88
 
88
89
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
89
90
  <span className="text-[10px] text-theme-text-tertiary">
90
- {currency} &middot; projected monthly from {data.window ?? '1h'} window
91
+ {currency} &middot; projected from {costFreshnessLabel(data.source, data.window, data.dataThrough)}
91
92
  {currency !== DEFAULT_COST_CURRENCY && (
92
93
  <> &middot; no conversion</>
93
94
  )}
94
95
  </span>
95
96
  <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
96
- OpenCost
97
+ {data.source === 'kubecost' ? 'Kubecost' : 'OpenCost'}
97
98
  </span>
98
99
  </div>
99
100
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
- import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
2
+ import { useCloudConnectSelf, useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, useVersionCheck, type IssuesResponse } from '../../api/client'
3
3
  import { useConnection } from '../../context/ConnectionContext'
4
4
  import type { ClusterLoadState } from '../../types/clusterLoadState'
5
5
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
@@ -22,7 +22,8 @@ import {
22
22
  StatusDot,
23
23
  categoryLabel,
24
24
  groupLabel,
25
- issueTiming,
25
+ issueFirstSeenTitle,
26
+ issueTimingForDisplay,
26
27
  subjectRef,
27
28
  type Issue,
28
29
  } from '@skyhook-io/k8s-ui'
@@ -30,6 +31,8 @@ import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
30
31
  import { ClusterHealthCard } from './ClusterHealthCard'
31
32
  import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
32
33
  import { clsx } from 'clsx'
34
+ import { getVersionUpdateStatus } from '../../utils/version'
35
+ import { RadarVersionLine } from './RadarVersionLine'
33
36
 
34
37
  interface HomeViewProps {
35
38
  namespaces: string[]
@@ -45,14 +48,19 @@ interface HomeViewProps {
45
48
  * standalone OSS → the card drills into secrets as before.
46
49
  */
47
50
  onNavigateToCerts?: () => void
51
+ // Omitted when an embedded host owns /checks, leaving the version as plain text.
52
+ onNavigateToUpgradeImpact?: () => void
53
+ onNavigateToHelmRelease?: (namespace: string, release: string) => void
54
+ onNavigateToManagerPath?: (path: string) => void
48
55
  }
49
56
 
50
- export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
57
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath }: HomeViewProps) {
51
58
  // The card itself decides whether the cluster has a capacity story
52
59
  // (available, softened-denied, or karpenterless-with-managers/groups) and
53
60
  // returns null otherwise — the outer gate only excludes states with nothing
54
61
  // to fetch against.
55
- const karpenterState = useCapabilitiesContext().karpenter?.state
62
+ const capabilities = useCapabilitiesContext()
63
+ const karpenterState = capabilities.karpenter?.state
56
64
  const capacityCardPossible =
57
65
  karpenterState === 'available' || karpenterState === 'denied' || karpenterState === 'not_detected'
58
66
  const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
@@ -61,6 +69,12 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
61
69
  const issues = issuesData?.issues ?? []
62
70
  const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
63
71
  const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
72
+ const deploymentMode = capabilities.deployment?.mode ?? 'local'
73
+ const { data: versionInfo } = useVersionCheck()
74
+ const showHomeUpgrade = deploymentMode === 'in-cluster'
75
+ && !!versionInfo?.updateAvailable
76
+ && getVersionUpdateStatus(versionInfo.currentVersion, versionInfo.latestVersion).tier !== 'none'
77
+ const { data: installationManager, isLoading: installationManagerLoading } = useCloudConnectSelf(showHomeUpgrade)
64
78
 
65
79
  // SSE is cluster-wide on small/medium clusters; the picker only narrows the
66
80
  // dashboard summary, so re-apply the filter here or the legend disagrees.
@@ -124,6 +138,15 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
124
138
  )}
125
139
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
126
140
  <ClusterHealthCard
141
+ radarVersion={deploymentMode === 'in-cluster' && versionInfo ? (
142
+ <RadarVersionLine
143
+ version={versionInfo}
144
+ manager={installationManager}
145
+ managerLoading={installationManagerLoading}
146
+ onNavigateToHelmRelease={onNavigateToHelmRelease}
147
+ onNavigateToGitOps={onNavigateToManagerPath}
148
+ />
149
+ ) : undefined}
127
150
  freshness={
128
151
  <FreshnessControl
129
152
  mode="auto"
@@ -143,6 +166,7 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
143
166
  nodeVersionSkew={data.nodeVersionSkew}
144
167
  onNavigateToKind={onNavigateToResourceKind}
145
168
  onNavigateToView={() => onNavigateToView('resources')}
169
+ onNavigateToUpgradeImpact={onNavigateToUpgradeImpact}
146
170
  onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
147
171
  onIssuesClick={() => onNavigateToView('issues')}
148
172
  />
@@ -383,8 +407,10 @@ function ProblemsPanel({
383
407
  <div className="divide-y divide-theme-border">
384
408
  {issues.map((issue) => {
385
409
  const ref = subjectRef(issue)
386
- const age = issue.first_seen ? formatCompactAge(issue.first_seen) : ''
387
- const timing = issueTiming(issue)
410
+ const partialUnknown = issue.onset_coverage?.unknown ?? 0
411
+ const partialOnset = Boolean(issue.first_seen && partialUnknown > 0)
412
+ const age = issue.first_seen ? `${partialOnset ? '≥' : ''}${formatCompactAge(issue.first_seen)}` : ''
413
+ const timing = issueTimingForDisplay(issue)
388
414
 
389
415
  return (
390
416
  <button
@@ -402,12 +428,11 @@ function ProblemsPanel({
402
428
  <div className="flex items-center gap-1.5">
403
429
  <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
404
430
  <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
405
- {(age || timing || issue.onset_unknown) && (
431
+ {(age || timing) && (
406
432
  <span className="ml-auto flex shrink-0 items-center gap-1">
407
- {age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
408
- {issue.onset_unknown && (
409
- <Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={100}>
410
- <span className="text-[10px] text-theme-text-tertiary">Onset unknown</span>
433
+ {age && (
434
+ <Tooltip content={issueFirstSeenTitle(issue)} delay={100}>
435
+ <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>
411
436
  </Tooltip>
412
437
  )}
413
438
  {timing && (
@@ -204,16 +204,17 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
204
204
  <a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline underline-offset-2">
205
205
  Model Context Protocol
206
206
  </a>{' '}
207
- (MCP) server that lets AI agents query your cluster through Radar.
207
+ (MCP) server that lets AI agents inspect, diagnose, and operate your cluster through Radar.
208
208
  Unlike raw kubectl access, Radar gives your AI pre-processed, enriched data —
209
209
  topology graphs, health assessments, deduplicated events, filtered logs — so it
210
210
  can understand your cluster state quickly without burning through context on
211
211
  verbose YAML output.
212
212
  </p>
213
213
  <p className="text-sm text-theme-text-secondary leading-relaxed">
214
- Read tools are strictly read-only. Write tools (restart, scale, sync, apply,
215
- node drain) are annotated as destructive so your AI client can flag them and
216
- prompt before running.
214
+ Most read tools do not change cluster state. Live route diagnosis can create up
215
+ to five self-deleting probe pods when explicitly requested. Write tools (restart,
216
+ scale, sync, apply, node drain) are annotated as destructive so your AI client
217
+ can flag them and prompt before running.
217
218
  </p>
218
219
  </div>
219
220
 
@@ -0,0 +1,145 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { VersionInfo } from '../../api/client'
4
+ import { RadarVersionLine } from './RadarVersionLine'
5
+
6
+ const version: VersionInfo = {
7
+ currentVersion: '1.2.3',
8
+ latestVersion: '1.3.0',
9
+ updateAvailable: true,
10
+ installMethod: 'direct',
11
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
12
+ }
13
+
14
+ describe('RadarVersionLine', () => {
15
+ it('shows the running version without an upgrade affordance when up to date', () => {
16
+ const html = renderToString(
17
+ <RadarVersionLine version={{ ...version, latestVersion: '1.2.3', updateAvailable: false }} />,
18
+ )
19
+ expect(html).toContain('Radar')
20
+ expect(html).toContain('v1.2.3')
21
+ expect(html).not.toContain('available')
22
+ })
23
+
24
+ it('shows patch upgrades with the quiet treatment', () => {
25
+ const html = renderToString(
26
+ <RadarVersionLine version={{ ...version, latestVersion: '1.2.4' }} />,
27
+ )
28
+ expect(html).toContain('v1.2.3')
29
+ expect(html).toContain('v1.2.4')
30
+ expect(html).toContain('available')
31
+ expect(html).toContain('text-accent-text hover:text-accent')
32
+ })
33
+
34
+ it('makes minor upgrades more prominent', () => {
35
+ const html = renderToString(<RadarVersionLine version={version} />)
36
+ expect(html).toContain('font-medium text-accent hover:text-accent-light')
37
+ expect(html).not.toContain('minor releases behind')
38
+ })
39
+
40
+ it('uses warning emphasis and explains when an installation is three minor releases behind', () => {
41
+ const html = renderToString(
42
+ <RadarVersionLine version={{ ...version, currentVersion: '1.0.9', latestVersion: '1.3.0' }} />,
43
+ )
44
+ expect(html).toContain('font-medium text-warning-text hover:opacity-80')
45
+ expect(html).toContain('This installation is 3 minor releases behind')
46
+ })
47
+
48
+ it('uses warning emphasis for a major upgrade', () => {
49
+ const html = renderToString(
50
+ <RadarVersionLine version={{ ...version, currentVersion: '0.12.0', latestVersion: '1.3.0' }} />,
51
+ )
52
+ expect(html).toContain('font-medium text-warning-text hover:opacity-80')
53
+ expect(html).toContain('A major Radar upgrade is available')
54
+ })
55
+
56
+ it('does not claim manager discovery has failed while it is loading', () => {
57
+ const html = renderToString(<RadarVersionLine version={version} managerLoading />)
58
+ expect(html).toContain('v1.3.0')
59
+ expect(html).toContain('available')
60
+ expect(html).toContain('lucide-circle-arrow-up')
61
+ expect(html).toContain('Checking how this installation is managed')
62
+ expect(html).not.toContain('could not be confirmed')
63
+ expect(html).not.toContain('<a')
64
+ expect(html).not.toContain('<button')
65
+ expect(html).toContain('class="sr-only"')
66
+ expect(html).not.toContain('aria-label=')
67
+ })
68
+
69
+ it('opens actionable upgrade instructions when the installation manager is unknown', () => {
70
+ const html = renderToString(<RadarVersionLine version={version} />)
71
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
72
+ expect(html).not.toContain('#upgrading')
73
+ expect(html).toContain('Open the in-cluster upgrade instructions')
74
+ expect(html).not.toContain(version.releaseUrl)
75
+ expect(html).toContain('font-medium text-accent hover:text-accent-light')
76
+ })
77
+
78
+ it('keeps the visible upgrade label in the accessible name', () => {
79
+ const html = renderToString(<RadarVersionLine version={version} />)
80
+ expect(html).toContain('aria-label="v1.3.0 available —')
81
+ })
82
+
83
+ it('deep-links exact Helm ownership when the host supports it', () => {
84
+ const html = renderToString(
85
+ <RadarVersionLine
86
+ version={version}
87
+ manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
88
+ onNavigateToHelmRelease={() => {}}
89
+ />,
90
+ )
91
+ expect(html).toContain('Managed by Helm release radar-system/radar')
92
+ expect(html).toContain('Open the release to upgrade')
93
+ })
94
+
95
+ it('describes the docs fallback when a Helm navigation callback is unavailable', () => {
96
+ const html = renderToString(
97
+ <RadarVersionLine
98
+ version={version}
99
+ manager={{ ownership: 'helm', namespace: 'radar-system', release: 'radar' }}
100
+ />,
101
+ )
102
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
103
+ expect(html).toContain('Managed by Helm release radar-system/radar')
104
+ expect(html).toContain('Open the in-cluster upgrade instructions')
105
+ expect(html).not.toContain('Open the release to upgrade')
106
+ })
107
+
108
+ it('deep-links verified GitOps ownership', () => {
109
+ const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
110
+ const verified = renderToString(
111
+ <RadarVersionLine
112
+ version={version}
113
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
114
+ onNavigateToGitOps={() => {}}
115
+ />,
116
+ )
117
+ expect(verified).toContain('Managed by Kustomization flux-system/radar')
118
+ expect(verified).toContain('Open it to upgrade through GitOps')
119
+
120
+ const suspected = renderToString(
121
+ <RadarVersionLine
122
+ version={version}
123
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar' }}
124
+ onNavigateToGitOps={() => {}}
125
+ />,
126
+ )
127
+ expect(suspected).toContain('appears to be managed through GitOps (Kustomization flux-system/radar)')
128
+ expect(suspected).toContain('Open the upgrade instructions')
129
+ expect(suspected).not.toContain('Managed by Kustomization')
130
+ })
131
+
132
+ it('describes the docs fallback when a GitOps navigation callback is unavailable', () => {
133
+ const controllerRef = { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', namespace: 'flux-system', name: 'radar' }
134
+ const html = renderToString(
135
+ <RadarVersionLine
136
+ version={version}
137
+ manager={{ ownership: 'gitops', controller: 'Kustomization flux-system/radar', controllerRef }}
138
+ />,
139
+ )
140
+ expect(html).toContain('https://radarhq.io/docs/configuration/in-cluster')
141
+ expect(html).toContain('Managed by Kustomization flux-system/radar')
142
+ expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
143
+ expect(html).not.toContain('Open it to upgrade through GitOps')
144
+ })
145
+ })
@@ -0,0 +1,137 @@
1
+ import { ArrowUpCircle } from 'lucide-react'
2
+ import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
3
+ import type { CloudConnectSelf, VersionInfo } from '../../api/client'
4
+ import {
5
+ getVersionUpdateStatus,
6
+ IN_CLUSTER_UPGRADE_URL,
7
+ type VersionUpdateTier,
8
+ } from '../../utils/version'
9
+ import { Tooltip } from '../ui/Tooltip'
10
+
11
+ interface RadarVersionLineProps {
12
+ version: VersionInfo
13
+ manager?: CloudConnectSelf
14
+ managerLoading?: boolean
15
+ onNavigateToHelmRelease?: (namespace: string, release: string) => void
16
+ onNavigateToGitOps?: (path: string) => void
17
+ }
18
+
19
+ function displayVersion(version: string): string {
20
+ return version === 'dev' || version.startsWith('v') ? version : `v${version}`
21
+ }
22
+
23
+ export function RadarVersionLine({
24
+ version,
25
+ manager,
26
+ managerLoading = false,
27
+ onNavigateToHelmRelease,
28
+ onNavigateToGitOps,
29
+ }: RadarVersionLineProps) {
30
+ const latestVersion = version.latestVersion
31
+ const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
32
+ const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
33
+
34
+ if (!showUpgrade) {
35
+ return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
36
+ }
37
+
38
+ const controller = manager?.controllerRef
39
+ const gitOpsPath = controller
40
+ ? gitOpsRouteForResource({
41
+ apiVersion: controller.group ? `${controller.group}/v1` : undefined,
42
+ kind: controller.kind,
43
+ metadata: { namespace: controller.namespace, name: controller.name },
44
+ })
45
+ : null
46
+
47
+ let detail = managerLoading
48
+ ? 'Checking how this installation is managed.'
49
+ : 'The installation manager could not be confirmed. Open the in-cluster upgrade instructions.'
50
+ let onClick: (() => void) | undefined
51
+ const actionClassName = `inline-flex items-center gap-1 transition-colors ${upgradeActionClassName(updateStatus.tier)}`
52
+
53
+ if (manager?.ownership === 'helm' && manager.namespace && manager.release) {
54
+ if (onNavigateToHelmRelease) {
55
+ detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the release to upgrade.`
56
+ onClick = () => onNavigateToHelmRelease(manager.namespace!, manager.release!)
57
+ } else {
58
+ detail = `Managed by Helm release ${manager.namespace}/${manager.release}. Open the in-cluster upgrade instructions.`
59
+ }
60
+ } else if (controller && gitOpsPath) {
61
+ const objectName = `${controller.namespace ? `${controller.namespace}/` : ''}${controller.name}`
62
+ if (onNavigateToGitOps) {
63
+ detail = `Managed by ${controller.kind} ${objectName}. Open it to upgrade through GitOps.`
64
+ onClick = () => onNavigateToGitOps(gitOpsPath)
65
+ } else {
66
+ detail = `Managed by ${controller.kind} ${objectName}. Open the in-cluster upgrade instructions and apply the change through GitOps.`
67
+ }
68
+ } else if (manager?.ownership === 'gitops') {
69
+ detail = manager.controller
70
+ ? `This installation appears to be managed through GitOps (${manager.controller}). Open the upgrade instructions and apply the change through its source of truth.`
71
+ : 'This installation appears to be managed through GitOps. Open the upgrade instructions and apply the change through its source of truth.'
72
+ }
73
+
74
+ const ageDetail = updateAgeDetail(updateStatus)
75
+ const accessibleLabel = `${displayVersion(latestVersion)} available${ageDetail ? `. ${ageDetail}` : ''} — ${detail}`
76
+ const action = managerLoading ? (
77
+ <span className={actionClassName}>
78
+ <UpgradeLabel version={latestVersion} />
79
+ <span className="sr-only">{detail}</span>
80
+ </span>
81
+ ) : onClick ? (
82
+ <button
83
+ type="button"
84
+ className={actionClassName}
85
+ onClick={onClick}
86
+ aria-label={accessibleLabel}
87
+ >
88
+ <UpgradeLabel version={latestVersion} />
89
+ </button>
90
+ ) : (
91
+ <a
92
+ href={IN_CLUSTER_UPGRADE_URL}
93
+ target="_blank"
94
+ rel="noreferrer"
95
+ className={actionClassName}
96
+ aria-label={accessibleLabel}
97
+ >
98
+ <UpgradeLabel version={latestVersion} />
99
+ </a>
100
+ )
101
+
102
+ return (
103
+ <span className="inline-flex flex-wrap items-center gap-x-1">
104
+ <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
105
+ <span className="inline-flex items-center gap-1">
106
+ <span aria-hidden>·</span>
107
+ <Tooltip
108
+ content={accessibleLabel}
109
+ className="!whitespace-normal !max-w-sm"
110
+ >
111
+ {action}
112
+ </Tooltip>
113
+ </span>
114
+ </span>
115
+ )
116
+ }
117
+
118
+ function upgradeActionClassName(tier: VersionUpdateTier): string {
119
+ if (tier === 'patch') return 'text-accent-text hover:text-accent'
120
+ if (tier === 'minor') return 'font-medium text-accent hover:text-accent-light'
121
+ return 'font-medium text-warning-text hover:opacity-80'
122
+ }
123
+
124
+ function updateAgeDetail(status: ReturnType<typeof getVersionUpdateStatus>): string | undefined {
125
+ if (status.majorVersionBehind) return 'A major Radar upgrade is available.'
126
+ if (status.tier !== 'stale' || !status.minorVersionsBehind) return undefined
127
+ return `This installation is ${status.minorVersionsBehind} minor releases behind.`
128
+ }
129
+
130
+ function UpgradeLabel({ version }: { version: string }) {
131
+ return (
132
+ <>
133
+ <ArrowUpCircle className="h-3.5 w-3.5 shrink-0" aria-hidden />
134
+ <span><span className="font-mono">{displayVersion(version)}</span> available</span>
135
+ </>
136
+ )
137
+ }