@skyhook-io/radar-app 1.14.6 → 1.15.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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +51 -6
  3. package/src/api/client.admission.test.ts +34 -0
  4. package/src/api/client.ts +37 -3
  5. package/src/api/usage-data.fixtures.ts +33 -0
  6. package/src/api/usage-data.ts +165 -0
  7. package/src/components/CloudFunnelButton.test.tsx +2 -2
  8. package/src/components/CloudFunnelButton.tsx +99 -55
  9. package/src/components/SelfManagedStart.tsx +96 -0
  10. package/src/components/cloudConnectHandoff.test.ts +1 -1
  11. package/src/components/cloudConnectHandoff.ts +1 -1
  12. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  13. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  14. package/src/components/dialog-names.test.ts +69 -0
  15. package/src/components/execution/BatchExecutionView.tsx +1 -1
  16. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  17. package/src/components/execution/JobSetAdmission.tsx +16 -7
  18. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  19. package/src/components/home/HomeView.tsx +6 -2
  20. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  21. package/src/components/home/RadarVersionLine.tsx +28 -3
  22. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  23. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  24. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  25. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  26. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  27. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  28. package/src/components/settings/PrivacySection.test.tsx +84 -0
  29. package/src/components/settings/PrivacySection.tsx +140 -0
  30. package/src/components/settings/SettingsDialog.tsx +13 -52
  31. package/src/components/settings/controls.tsx +55 -0
  32. package/src/components/settings/settings-state.ts +1 -0
  33. package/src/components/ui/ErrorBoundary.tsx +5 -0
  34. package/src/components/ui/Omnibar.tsx +16 -0
  35. package/src/components/ui/UpdateNotification.tsx +13 -9
  36. package/src/components/ui/command-items.ts +14 -0
  37. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  38. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  39. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  40. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  41. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  42. package/src/components/whats-new/WhatsNew.tsx +423 -0
  43. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  44. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  45. package/src/components/whats-new/releaseNotes.ts +121 -0
  46. package/src/components/workload/WorkloadView.tsx +11 -1
  47. package/src/k8s-ui-exports.test.ts +73 -0
  48. package/src/utils/navigation.test.ts +17 -1
  49. package/src/utils/navigation.ts +16 -0
  50. package/src/utils/version.ts +11 -0
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ // scripts/check-whats-new.sh gates every minor release on having notes; these
8
+ // pin what counts as an entry, against catalogs written to a temp dir.
9
+ const script = join(__dirname, '..', '..', '..', '..', 'scripts', 'check-whats-new.sh')
10
+
11
+ function check(version: string, catalog: string) {
12
+ const file = join(mkdtempSync(join(tmpdir(), 'whats-new-')), 'releaseNotes.ts')
13
+ writeFileSync(file, catalog)
14
+ return spawnSync('bash', [script, version], { env: { ...process.env, WHATS_NEW_CATALOG: file }, encoding: 'utf8' }).status
15
+ }
16
+
17
+ const empty = `export const RELEASE_NOTES: ReleaseNotes[] = []\n`
18
+ const withEntry = (entry: string, before = '') => `${before}export const RELEASE_NOTES: ReleaseNotes[] = [\n${entry}\n]\n\nexport function after() {}\n`
19
+
20
+ describe('check-whats-new.sh', () => {
21
+ it('lets patches and prereleases through without notes', () => {
22
+ expect(check('v1.15.1', empty)).toBe(0)
23
+ expect(check('v1.15.0-rc.1', empty)).toBe(0)
24
+ })
25
+
26
+ it('refuses a minor or major release without notes, including one with build metadata', () => {
27
+ expect(check('v1.15.0', empty)).toBe(1)
28
+ expect(check('v2.0.0', empty)).toBe(1)
29
+ expect(check('v1.15.0+build.1', empty)).toBe(1)
30
+ })
31
+
32
+ it('refuses a version that is not vX.Y.Z', () => {
33
+ expect(check('v1.15', empty)).toBe(1)
34
+ expect(check('latest', empty)).toBe(1)
35
+ })
36
+
37
+ it('accepts a real entry, wherever version sits in it', () => {
38
+ const entry = ` {\n releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0', version: 'v1.15.0',\n highlights: [],\n },`
39
+ expect(check('v1.15.0', withEntry(entry))).toBe(0)
40
+ expect(check('1.15.0+build.1', withEntry(entry))).toBe(0)
41
+ })
42
+
43
+ it('does not count comments, objects outside the catalog, or look-alike properties', () => {
44
+ const decoys = `// { version: 'v1.16.0' },\n/* { version: 'v1.17.0' } */\nconst draft = { version: 'v1.18.0' }\n`
45
+ const catalog = withEntry(` // { version: 'v1.20.0' },\n { notversion: 'v1.19.0' },`, decoys)
46
+ for (const v of ['v1.16.0', 'v1.17.0', 'v1.18.0', 'v1.19.0', 'v1.20.0']) expect(check(v, catalog), v).toBe(1)
47
+ })
48
+
49
+ it('ignores an object after an empty catalog, even with a semicolon', () => {
50
+ expect(check('v1.15.0', `export const RELEASE_NOTES: ReleaseNotes[] = [];\nconst draft = { version: 'v1.15.0' };\n`)).toBe(1)
51
+ })
52
+
53
+ it('does not count the words "version:" inside another entry\'s text', () => {
54
+ const entry = ` {\n version: 'v1.14.0',\n improvements: ["Set version: 'v1.15.0' in your configuration"],\n },`
55
+ expect(check('v1.15.0', withEntry(entry))).toBe(1)
56
+ })
57
+
58
+ it('accepts a value on the line after its key, quoted keys, and space before the colon', () => {
59
+ expect(check('v1.15.0', withEntry(` {\n version:\n 'v1.15.0',\n },`))).toBe(0)
60
+ expect(check('v1.15.0', withEntry(` { version: "v1.15.0" },`))).toBe(0)
61
+ expect(check('v1.15.0', withEntry(` { "version": "v1.15.0" },`))).toBe(0)
62
+ expect(check('v1.15.0', withEntry(` { version : 'v1.15.0' },`))).toBe(0)
63
+ expect(check('v1.15.0', withEntry(` {\n "version" :\n "v1.15.0",\n },`))).toBe(0)
64
+ })
65
+ })
@@ -0,0 +1,121 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import { Bot, GitCompareArrows, Layers, Network, Scale } from 'lucide-react'
3
+ import { compareVersions } from '../../utils/version'
4
+
5
+ export interface ReleaseHighlight {
6
+ id: string
7
+ icon: LucideIcon
8
+ title: string
9
+ description: string
10
+ /** In-app route the highlight's call to action opens. */
11
+ path?: string
12
+ cta?: string
13
+ /** Icon tile color, so the cards read apart at a glance. The lead card always uses the accent. */
14
+ tone?: HighlightTone
15
+ }
16
+
17
+ export type HighlightTone = 'violet' | 'teal' | 'emerald' | 'indigo'
18
+
19
+ export interface ReleaseNotes {
20
+ version: string
21
+ /** The first highlight leads the dialog at full width. */
22
+ highlights: ReleaseHighlight[]
23
+ improvements: string[]
24
+ releaseUrl: string
25
+ }
26
+
27
+ // One entry per release that has something to announce. A version without its
28
+ // own entry shows the newest entry at or below it, so a patch upgrade (or one
29
+ // that skips the release with notes) still gets them. Every minor and major
30
+ // release must have its own entry: scripts/check-whats-new.sh refuses to
31
+ // release one without it.
32
+ export const RELEASE_NOTES: ReleaseNotes[] = [
33
+ {
34
+ version: 'v1.15.0',
35
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0',
36
+ highlights: [
37
+ {
38
+ id: 'ai-investigations',
39
+ icon: Bot,
40
+ title: 'AI investigations that show their work',
41
+ // No link: the investigations workspace redirects home when this run
42
+ // mode can't host local agents.
43
+ description: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Now also runs on OpenCode, including AWS Bedrock.',
44
+ },
45
+ {
46
+ id: 'gitops-manifest-diff',
47
+ icon: GitCompareArrows,
48
+ title: 'Full manifest diffs for Argo CD resources',
49
+ description: 'Compare the Git-rendered and live manifest of a drifted Argo CD resource, side by side or unified.',
50
+ tone: 'violet',
51
+ path: '/gitops',
52
+ cta: 'Open GitOps',
53
+ },
54
+ {
55
+ id: 'rightsizing-risks-first',
56
+ icon: Scale,
57
+ title: 'Rightsizing and cost data in MCP',
58
+ description: 'Agents get new optimized tools. Risks (OOM, limit conflicts and throttling) lead before cost-saving, in the UI as well.',
59
+ tone: 'emerald',
60
+ path: '/cost/rightsizing',
61
+ cta: 'Open Rightsizing',
62
+ },
63
+ {
64
+ id: 'batch-ai-workloads',
65
+ icon: Layers,
66
+ title: 'Deeper batch + AI/ML workloads support',
67
+ description: 'Dedicated pages for Kueue, JobSet and Ray resources, and Job and JobSet details now show why a queued job has no Pods.',
68
+ tone: 'teal',
69
+ },
70
+ {
71
+ id: 'rollout-traffic-topology',
72
+ icon: Network,
73
+ title: 'Canary and blue-green in Topology',
74
+ description: 'See canary and stable Pods, which Services route to each side, and the canary traffic weight.',
75
+ tone: 'indigo',
76
+ path: '/topology',
77
+ cta: 'Open Topology',
78
+ },
79
+ ],
80
+ improvements: [
81
+ // The grid fills row by row: pair lines of similar length.
82
+ 'Resource tables remember your sort for each kind',
83
+ 'Install Helm charts from OCI registries',
84
+ 'Checks flag expired and expiring TLS certificates',
85
+ 'Radar Desktop keeps your preferences across restarts',
86
+ 'Issues show where a failing Pod differs from its template',
87
+ 'PVC details say why usage data is unavailable',
88
+ 'Issues catch Strimzi connector and admission webhook failures',
89
+ 'Bind the web UI to a specific IP with --listen-address',
90
+ ],
91
+ },
92
+ ]
93
+
94
+ export function releaseNotesFor(
95
+ version: string | undefined,
96
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
97
+ ): ReleaseNotes | undefined {
98
+ if (!version) return undefined
99
+ const normalized = version.startsWith('v') ? version : `v${version}`
100
+ return catalog.find(notes => notes.version === normalized)
101
+ }
102
+
103
+ /** The release line a catalog entry covers, e.g. v1.15 for v1.15.0: its patches show the same notes. */
104
+ export function releaseLine(version: string): string {
105
+ return version.replace(/^(v\d+\.\d+)\.\d+$/, '$1')
106
+ }
107
+
108
+ /** The newest notes for a release at or below `version` — what that version's user has access to. */
109
+ export function latestReleaseNotesFor(
110
+ version: string | undefined,
111
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
112
+ ): ReleaseNotes | undefined {
113
+ if (!version) return undefined
114
+ let latest: ReleaseNotes | undefined
115
+ for (const notes of catalog) {
116
+ const atOrBelow = compareVersions(notes.version, version)
117
+ if (atOrBelow === null || atOrBelow > 0) continue
118
+ if (!latest || (compareVersions(notes.version, latest.version) ?? 0) > 0) latest = notes
119
+ }
120
+ return latest
121
+ }
@@ -1,3 +1,5 @@
1
+ import { RayJobRenderer } from '../resources/renderers/RayJobRenderer'
2
+ import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmissionRenderers'
1
3
  import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
2
4
  import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
3
5
  import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
@@ -171,6 +173,9 @@ export function supportsBatchExecution(kind: string, apiKind: string, group?: st
171
173
 
172
174
  // Stable reference — web renderer wrappers inject platform hooks internally
173
175
  const rendererOverrides: RendererOverrides = {
176
+ RayJobRenderer,
177
+ JobRenderer,
178
+ JobSetRenderer,
174
179
  RayServiceRenderer,
175
180
  RayClusterRenderer,
176
181
  KueueWorkloadRenderer,
@@ -607,6 +612,11 @@ export function WorkloadView({
607
612
  const refetchResourceAndRuns = useCallback(async () => {
608
613
  await Promise.all([
609
614
  refetchResource(),
615
+ ...(apiKind === 'rayjobs' && effectiveGroup === 'ray.io' ? [
616
+ queryClient.refetchQueries({ queryKey: ['resource', 'jobs', namespace, name, 'batch'], type: 'active' }),
617
+ ...(resource?.spec?.clusterSelector?.['ray.io/cluster'] ? [queryClient.refetchQueries({ queryKey: ['resource', 'rayclusters', namespace, resource.spec.clusterSelector['ray.io/cluster'], 'ray.io'], type: 'active' })] : []),
618
+ ] : []),
619
+ queryClient.refetchQueries({ queryKey: ['kueue-admission', effectiveGroup, apiKind, namespace, name], type: 'active' }),
610
620
  queryClient.refetchQueries({
611
621
  queryKey: ['workload-runs', apiKind, namespace, name],
612
622
  }),
@@ -615,7 +625,7 @@ export function WorkloadView({
615
625
  ...(apiKind === 'rayclusters' ? { type: 'active' as const } : {}),
616
626
  }),
617
627
  ])
618
- }, [apiKind, name, namespace, queryClient, refetchResource])
628
+ }, [apiKind, effectiveGroup, name, namespace, queryClient, refetchResource, resource?.spec?.clusterSelector])
619
629
  const podWorkloadOwner = useMemo(
620
630
  () => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
621
631
  [apiKind, namespace, relationships, resource],
@@ -0,0 +1,73 @@
1
+ /// <reference types="node" />
2
+ import { describe, expect, test } from 'vitest'
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // Radar's own build aliases @skyhook-io/k8s-ui to its source, so a deep
8
+ // import that the package's `exports` map can't serve still builds here and
9
+ // only fails in a consumer that installs the published packages (Radar Hub).
10
+ // This resolves every deep import the way Node and bundlers do: the most
11
+ // specific matching key wins, and an array target resolves to its first entry
12
+ // without falling back when that file doesn't exist.
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const k8sUiRoot = resolve(here, '../../packages/k8s-ui')
16
+ const exportsMap: Record<string, string | string[]> = JSON.parse(
17
+ readFileSync(join(k8sUiRoot, 'package.json'), 'utf8'),
18
+ ).exports
19
+
20
+ function resolveExport(subpath: string): string | undefined {
21
+ const key = `./${subpath}`
22
+ let target = exportsMap[key]
23
+ let star: string | undefined
24
+ if (target === undefined) {
25
+ let best = ''
26
+ for (const pattern of Object.keys(exportsMap)) {
27
+ const i = pattern.indexOf('*')
28
+ if (i < 0) continue
29
+ const prefix = pattern.slice(0, i)
30
+ const suffix = pattern.slice(i + 1)
31
+ if (key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length && prefix.length > best.length) {
32
+ best = prefix
33
+ target = exportsMap[pattern]
34
+ star = key.slice(prefix.length, key.length - suffix.length)
35
+ }
36
+ }
37
+ }
38
+ if (target === undefined) return undefined
39
+ const first = Array.isArray(target) ? target[0] : target
40
+ return join(k8sUiRoot, star === undefined ? first : first.split('*').join(star))
41
+ }
42
+
43
+ // Everything radar-app publishes that can import from k8s-ui, stylesheets
44
+ // included (vitest doesn't load CSS, so these are read from disk).
45
+ function sourceFiles(dir: string): string[] {
46
+ return readdirSync(dir).flatMap((name) => {
47
+ const path = join(dir, name)
48
+ if (statSync(path).isDirectory()) return sourceFiles(path)
49
+ return /\.(ts|tsx|css)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) ? [path] : []
50
+ })
51
+ }
52
+
53
+ describe('@skyhook-io/k8s-ui deep imports', () => {
54
+ const imports = new Set<string>()
55
+ for (const file of sourceFiles(here)) {
56
+ // Any quoted specifier: `from` imports, side-effect and dynamic imports,
57
+ // and stylesheet @imports all resolve through the same map.
58
+ for (const m of readFileSync(file, 'utf8').matchAll(/['"]@skyhook-io\/k8s-ui\/([^'"]+)['"]/g)) {
59
+ imports.add(m[1])
60
+ }
61
+ }
62
+
63
+ test('finds the deep imports it checks, stylesheets included', () => {
64
+ expect(imports.size).toBeGreaterThan(10)
65
+ expect(imports).toContain('theme/variables.css')
66
+ })
67
+
68
+ test.each([...imports].sort())('%s resolves through the published exports map', (subpath) => {
69
+ const file = resolveExport(subpath)
70
+ expect(file, `no exports entry serves "@skyhook-io/k8s-ui/${subpath}"`).toBeDefined()
71
+ expect(existsSync(file!), `"@skyhook-io/k8s-ui/${subpath}" resolves to missing ${file}`).toBe(true)
72
+ })
73
+ })
@@ -4,7 +4,7 @@ import {
4
4
  initNavigationMap,
5
5
  resetNavigationMap,
6
6
  } from '@skyhook-io/k8s-ui/utils/navigation'
7
- import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath } from './navigation'
7
+ import { getNetworkPolicyResourceTarget, relatedResourcePath, resourcePath, withCrossViewParams } from './navigation'
8
8
 
9
9
  afterEach(resetNavigationMap)
10
10
 
@@ -60,3 +60,19 @@ it('uses the Kubernetes kind for a Calico aggregate target', () => {
60
60
  expect(getNetworkPolicyResourceTarget({nodes: [{id: 'policy', kind: 'CalicoNetworkPolicy', name: 'allow', status: 'healthy', data: {apiVersion: 'crd.projectcalico.org/v1', resourceKind: 'NetworkPolicy'}}], edges: []} as unknown as Topology))
61
61
  .toEqual({kind: 'networkpolicies', group: 'crd.projectcalico.org'})
62
62
  })
63
+
64
+ describe('withCrossViewParams', () => {
65
+ it('carries the namespace pick and open investigation onto a bare path', () => {
66
+ expect(withCrossViewParams('/gitops/argo/argocd/app', '?namespaces=a,b&ai-run=r1&tab=yaml'))
67
+ .toBe('/gitops/argo/argocd/app?namespaces=a%2Cb&ai-run=r1')
68
+ })
69
+
70
+ it('keeps what the path sets itself, and its hash', () => {
71
+ expect(withCrossViewParams('/resources/pods?namespaces=x#top', '?namespaces=a&ai-run=r1'))
72
+ .toBe('/resources/pods?namespaces=x&ai-run=r1#top')
73
+ })
74
+
75
+ it('leaves the path alone when nothing is scoped', () => {
76
+ expect(withCrossViewParams('/helm', '')).toBe('/helm')
77
+ })
78
+ })
@@ -133,3 +133,19 @@ export function openExternal(url: string): void {
133
133
  window.open(url, '_blank')
134
134
  })
135
135
  }
136
+
137
+ // setMainView's cross-view params. A navigation whose search omits
138
+ // ?namespaces= reads as an empty pick to App's URL sync, which clears the
139
+ // user's namespace scope.
140
+ const CROSS_VIEW_PARAMS = ['namespaces', 'ai-run'] as const
141
+
142
+ /** Carries the current cross-view params onto an in-app path that doesn't set them itself. */
143
+ export function withCrossViewParams(path: string, currentSearch: string): string {
144
+ const destination = new URL(path, 'http://radar.invalid')
145
+ const current = new URLSearchParams(currentSearch)
146
+ for (const key of CROSS_VIEW_PARAMS) {
147
+ const value = current.get(key)
148
+ if (value && !destination.searchParams.has(key)) destination.searchParams.set(key, value)
149
+ }
150
+ return `${destination.pathname}${destination.search}${destination.hash}`
151
+ }
@@ -32,6 +32,17 @@ function parseVersion(version: string): [major: number, minor: number, patch: nu
32
32
  return [Number(match[1]), Number(match[2]), Number(match[3]), !!match[4]]
33
33
  }
34
34
 
35
+ /** Semver order (a prerelease sorts before its release); null when either side isn't a version. */
36
+ export function compareVersions(a: string, b: string): number | null {
37
+ const x = parseVersion(a)
38
+ const y = parseVersion(b)
39
+ if (!x || !y) return null
40
+ for (let i = 0; i < 3; i++) {
41
+ if (x[i] !== y[i]) return (x[i] as number) - (y[i] as number)
42
+ }
43
+ return Number(y[3]) - Number(x[3])
44
+ }
45
+
35
46
  export function getVersionUpdateStatus(current: string, latest?: string): VersionUpdateStatus {
36
47
  if (!latest) return { tier: 'none' }
37
48