@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
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ costSourceApplyLabel,
4
+ shouldOfferCostReview,
5
+ shouldShowSettingsFooter,
6
+ } from "./settings-state";
7
+
8
+ describe("Cost settings state", () => {
9
+ it("keeps source drafts inline while the Cost section is open", () => {
10
+ expect(shouldOfferCostReview(true, "cost")).toBe(false);
11
+ expect(
12
+ shouldShowSettingsFooter({
13
+ canEditConfig: true,
14
+ confirmingClose: false,
15
+ configDirty: false,
16
+ costIntegrationDirty: true,
17
+ section: "cost",
18
+ hasSaveMessage: false,
19
+ }),
20
+ ).toBe(false);
21
+ });
22
+
23
+ it("offers review from other sections and retains the close guard", () => {
24
+ expect(shouldOfferCostReview(true, "overview")).toBe(true);
25
+ expect(
26
+ shouldShowSettingsFooter({
27
+ canEditConfig: true,
28
+ confirmingClose: true,
29
+ configDirty: false,
30
+ costIntegrationDirty: true,
31
+ section: "cost",
32
+ hasSaveMessage: false,
33
+ }),
34
+ ).toBe(true);
35
+ });
36
+
37
+ it("only claims to test sources that the backend probes", () => {
38
+ expect(costSourceApplyLabel("auto")).toBe("Test & apply source");
39
+ expect(costSourceApplyLabel("kubecost")).toBe("Test & apply source");
40
+ expect(costSourceApplyLabel("prometheus")).toBe("Apply source");
41
+ });
42
+ });
@@ -0,0 +1,39 @@
1
+ export type SettingsSectionId =
2
+ | 'overview'
3
+ | 'perms'
4
+ | 'connection'
5
+ | 'prometheus'
6
+ | 'cost'
7
+ | 'argocd'
8
+ | 'ai'
9
+ | 'advanced'
10
+
11
+ export function shouldOfferCostReview(
12
+ costIntegrationDirty: boolean,
13
+ section: SettingsSectionId,
14
+ ): boolean {
15
+ return costIntegrationDirty && section !== 'cost'
16
+ }
17
+
18
+ export function shouldShowSettingsFooter(input: {
19
+ canEditConfig: boolean
20
+ confirmingClose: boolean
21
+ configDirty: boolean
22
+ costIntegrationDirty: boolean
23
+ section: SettingsSectionId
24
+ hasSaveMessage: boolean
25
+ }): boolean {
26
+ return (
27
+ input.canEditConfig &&
28
+ (input.confirmingClose ||
29
+ input.configDirty ||
30
+ shouldOfferCostReview(input.costIntegrationDirty, input.section) ||
31
+ input.hasSaveMessage)
32
+ )
33
+ }
34
+
35
+ export function costSourceApplyLabel(
36
+ source: 'auto' | 'prometheus' | 'kubecost',
37
+ ): string {
38
+ return source === 'prometheus' ? 'Apply source' : 'Test & apply source'
39
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { ErrorBoundary } from './ErrorBoundary'
4
+
5
+ // Navigating re-renders this boundary rather than remounting it, so a boundary
6
+ // that only latched would keep the fallback on screen for the rest of the
7
+ // session. These drive the lifecycle hooks directly - a caught error needs a
8
+ // real render loop, which the SSR-string tests used elsewhere here cannot give.
9
+ const caught = (resetKey: string) => ({
10
+ hasError: true,
11
+ error: new Error("Cannot read properties of undefined (reading 'nodeCount')"),
12
+ resetKey,
13
+ })
14
+
15
+ describe('ErrorBoundary reset', () => {
16
+ it('clears a caught error when resetKey changes', () => {
17
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/topology' }, caught('/'))).toEqual({
18
+ hasError: false,
19
+ error: null,
20
+ resetKey: '/topology',
21
+ })
22
+ })
23
+
24
+ it('clears it when only a later path segment changes', () => {
25
+ // The view is just the first path segment, so /resources/pods and
26
+ // /resources/services are the same view. Resetting per view would leave a
27
+ // crash under /resources trapping the user as they switch kinds.
28
+ const next = ErrorBoundary.getDerivedStateFromProps(
29
+ { children: null, resetKey: '/resources/services' },
30
+ caught('/resources/pods'),
31
+ )
32
+ expect(next?.hasError).toBe(false)
33
+ })
34
+
35
+ it('clears it when only the query changes', () => {
36
+ // Selection rides in the query, so a path-only key would strand a crash on
37
+ // the view that produced it.
38
+ const next = ErrorBoundary.getDerivedStateFromProps(
39
+ { children: null, resetKey: '/resources/pods?resource=kube-system/coredns' },
40
+ caught('/resources/pods'),
41
+ )
42
+ expect(next?.hasError).toBe(false)
43
+ })
44
+
45
+ it('holds the error while resetKey is unchanged', () => {
46
+ expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: '/' }, caught('/'))).toBeNull()
47
+ })
48
+
49
+ it('records the error', () => {
50
+ expect(ErrorBoundary.getDerivedStateFromError(new Error('boom'))).toEqual({
51
+ hasError: true,
52
+ error: new Error('boom'),
53
+ })
54
+ })
55
+ })
@@ -3,20 +3,35 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'
3
3
 
4
4
  interface Props {
5
5
  children: ReactNode
6
+ /**
7
+ * Clears a caught error whenever this value changes - pass whatever selects
8
+ * the children, typically the route. A boundary latches: nothing resets
9
+ * hasError on its own, so without this a view that throws keeps the fallback
10
+ * on screen and navigating away cannot escape it. Preferred over remounting
11
+ * via `key`, which would also discard the children's state on every change
12
+ * while nothing is wrong.
13
+ */
14
+ resetKey: string | number
6
15
  }
7
16
 
8
17
  interface State {
9
18
  hasError: boolean
10
19
  error: Error | null
20
+ resetKey: string | number
11
21
  }
12
22
 
13
23
  export class ErrorBoundary extends Component<Props, State> {
14
- state: State = { hasError: false, error: null }
24
+ state: State = { hasError: false, error: null, resetKey: this.props.resetKey }
15
25
 
16
- static getDerivedStateFromError(error: Error): State {
26
+ static getDerivedStateFromError(error: Error): Pick<State, 'hasError' | 'error'> {
17
27
  return { hasError: true, error }
18
28
  }
19
29
 
30
+ static getDerivedStateFromProps(props: Props, state: State): State | null {
31
+ if (Object.is(props.resetKey, state.resetKey)) return null
32
+ return { hasError: false, error: null, resetKey: props.resetKey }
33
+ }
34
+
20
35
  componentDidCatch(error: Error, info: React.ErrorInfo) {
21
36
  console.error('[ErrorBoundary]', error, info.componentStack)
22
37
  }
@@ -0,0 +1,49 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ let deploymentMode = 'local'
6
+
7
+ vi.mock('../../api/client', () => ({
8
+ useCapabilities: () => ({ data: { deployment: { mode: deploymentMode } } }),
9
+ useVersionCheck: () => ({
10
+ data: {
11
+ currentVersion: '1.2.3',
12
+ latestVersion: '1.3.0',
13
+ updateAvailable: true,
14
+ installMethod: 'direct',
15
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.3.0',
16
+ },
17
+ }),
18
+ useStartDesktopUpdate: () => ({ mutate: vi.fn(), isPending: false }),
19
+ useDesktopUpdateStatus: () => ({ data: undefined }),
20
+ useApplyDesktopUpdate: () => ({ mutate: vi.fn() }),
21
+ }))
22
+
23
+ import { UpdateNotification } from './UpdateNotification'
24
+
25
+ function renderNotification() {
26
+ const client = new QueryClient()
27
+ return renderToString(
28
+ <QueryClientProvider client={client}>
29
+ <UpdateNotification />
30
+ </QueryClientProvider>,
31
+ )
32
+ }
33
+
34
+ describe('UpdateNotification', () => {
35
+ beforeEach(() => {
36
+ deploymentMode = 'local'
37
+ })
38
+
39
+ it('keeps the update popup for local installations', () => {
40
+ const html = renderNotification()
41
+ expect(html).toContain('Update Available')
42
+ expect(html).toContain('1.3.0')
43
+ })
44
+
45
+ it('suppresses the update popup for shared in-cluster viewers', () => {
46
+ deploymentMode = 'in-cluster'
47
+ expect(renderNotification()).toBe('')
48
+ })
49
+ })
@@ -3,6 +3,7 @@ import { Download, X, Copy, Check, RotateCw, ArrowDownToLine, Loader2 } from 'lu
3
3
  import { useQueryClient } from '@tanstack/react-query'
4
4
  import {
5
5
  useVersionCheck,
6
+ useCapabilities,
6
7
  useStartDesktopUpdate,
7
8
  useDesktopUpdateStatus,
8
9
  useApplyDesktopUpdate,
@@ -14,6 +15,8 @@ const DISMISSED_KEY = 'radar-update-dismissed'
14
15
 
15
16
  export function UpdateNotification() {
16
17
  const queryClient = useQueryClient()
18
+ const { data: capabilities } = useCapabilities()
19
+ const deploymentMode = capabilities ? (capabilities.deployment?.mode ?? 'local') : undefined
17
20
  const { data: versionInfo } = useVersionCheck()
18
21
  const [dismissed, setDismissed] = useState(false)
19
22
  const [copied, setCopied] = useState(false)
@@ -98,8 +101,9 @@ export function UpdateNotification() {
98
101
  })
99
102
  }
100
103
 
101
- // Don't show if no update available, dismissed, or error
102
- if (!versionInfo?.updateAvailable || dismissed) {
104
+ // Shared in-cluster viewers get a persistent Home notice instead of a
105
+ // floating action prompt they may not be able to act on.
106
+ if (!versionInfo?.updateAvailable || dismissed || deploymentMode === undefined || deploymentMode === 'in-cluster' || deploymentMode === 'cloud') {
103
107
  return null
104
108
  }
105
109
 
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { gitOpsOwnerFromRelationships } from '@skyhook-io/k8s-ui'
4
+ import type { Relationships } from '../../types'
5
+ import { findInheritedGitOpsLookupRef } from './WorkloadView'
6
+
7
+ describe('findInheritedGitOpsLookupRef', () => {
8
+ it('follows a referenced ReplicaSet to its parent workload for inherited ownership', () => {
9
+ const relationships: Relationships = {
10
+ managedBy: [
11
+ {
12
+ kind: 'Deployment',
13
+ group: 'apps',
14
+ namespace: 'prod',
15
+ name: 'web',
16
+ },
17
+ ],
18
+ }
19
+
20
+ expect(
21
+ findInheritedGitOpsLookupRef(relationships, null, {
22
+ kind: 'ReplicaSet',
23
+ group: 'apps',
24
+ namespace: 'prod',
25
+ name: 'web-7b8d9',
26
+ }),
27
+ ).toEqual({
28
+ kind: 'Deployment',
29
+ group: 'apps',
30
+ namespace: 'prod',
31
+ name: 'web',
32
+ })
33
+ })
34
+
35
+ it('does not fetch a parent when the target has a direct GitOps owner', () => {
36
+ const relationships: Relationships = {
37
+ managedBy: [
38
+ {
39
+ kind: 'Application',
40
+ group: 'argoproj.io',
41
+ namespace: 'argocd',
42
+ name: 'web',
43
+ },
44
+ ],
45
+ }
46
+
47
+ expect(
48
+ findInheritedGitOpsLookupRef(
49
+ relationships,
50
+ gitOpsOwnerFromRelationships(relationships),
51
+ {
52
+ kind: 'Deployment',
53
+ group: 'apps',
54
+ namespace: 'prod',
55
+ name: 'web',
56
+ },
57
+ ),
58
+ ).toBeNull()
59
+ })
60
+ })