@skyhook-io/radar-app 1.16.1 → 1.16.3

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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
7
  import { Megaphone } from 'lucide-react'
8
8
  import type { WhatsNewState } from '../../api/client'
9
9
  import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './WhatsNew'
10
- import { RELEASE_NOTES } from './releaseNotes'
10
+ import { RELEASE_NOTES, type ReleaseNotes } from './releaseNotes'
11
11
 
12
12
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
13
13
 
@@ -37,13 +37,28 @@ function StatusProbe() {
37
37
  return null
38
38
  }
39
39
 
40
+ const SHIPPED = [...RELEASE_NOTES]
41
+
42
+ const card = (id: string, title: string, importance: number) => ({ id, icon: Megaphone, title, description: 'd', importance })
43
+
44
+ // A release worth opening the dialog for, and a quiet one that isn't.
45
+ const HEADLINE: ReleaseNotes = {
46
+ version: 'v1.15.0',
47
+ highlights: [{ ...card('capacity', 'Capacity views', 9), leadDescription: 'Capacity lead copy' }, card('diff', 'Manifest diffs', 9), card('rollouts', 'Rollout traffic', 9)],
48
+ improvements: ['Sort memory'],
49
+ }
50
+ const QUIET: ReleaseNotes = {
51
+ version: 'v1.16.0',
52
+ highlights: [card('timeline', 'Workload Timeline', 4), card('logs', 'Log levels', 4)],
53
+ improvements: ['Traffic fixes'],
54
+ }
55
+
56
+ function setCatalog(...entries: ReleaseNotes[]) {
57
+ RELEASE_NOTES.splice(0, RELEASE_NOTES.length, ...entries)
58
+ }
59
+
40
60
  beforeEach(() => {
41
- RELEASE_NOTES.push({
42
- version: 'v1.15.0',
43
- releaseUrl: 'https://github.com/skyhook-io/radar/releases',
44
- highlights: [{ id: 'x', icon: Megaphone, title: 'Capacity views', description: 'd' }],
45
- improvements: [],
46
- })
61
+ setCatalog(HEADLINE)
47
62
  seenPosts.length = 0
48
63
  failSeenPosts = 0
49
64
  pendingRead = null
@@ -80,7 +95,7 @@ beforeEach(() => {
80
95
 
81
96
  afterEach(async () => {
82
97
  await act(async () => root.unmount())
83
- RELEASE_NOTES.length = 0
98
+ setCatalog(...SHIPPED)
84
99
  client.clear()
85
100
  document.body.replaceChildren()
86
101
  vi.unstubAllGlobals()
@@ -155,7 +170,7 @@ describe('WhatsNew', () => {
155
170
 
156
171
  it('records nothing when previewing another release', async () => {
157
172
  serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
158
- RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v1.14.0' })
173
+ setCatalog(HEADLINE, { ...HEADLINE, version: 'v1.14.0' })
159
174
  serverState.seenVersion = 'v1.14.1'
160
175
  await render('/resources/pods?whats-new=v1.14.0')
161
176
  expect(dialog()?.textContent).toContain("What's new in Radar v1.14")
@@ -214,7 +229,7 @@ describe('WhatsNew', () => {
214
229
 
215
230
  it('never records a development build', async () => {
216
231
  serverState = { currentVersion: 'dev', storage: 'server', priorInstall: false }
217
- RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v0.0.0' })
232
+ setCatalog(HEADLINE, { ...HEADLINE, version: 'v0.0.0' })
218
233
  await act(async () => {
219
234
  root.render(
220
235
  <QueryClientProvider client={client}>
@@ -226,4 +241,111 @@ describe('WhatsNew', () => {
226
241
  expect(dialog()).toBeNull()
227
242
  expect(seenPosts).toEqual([])
228
243
  })
244
+ it('leaves a quiet release behind the unread dot, and records it once opened', async () => {
245
+ setCatalog(HEADLINE, QUIET)
246
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.15.0', priorInstall: true }
247
+ await render('/')
248
+ expect(dialog()).toBeNull()
249
+ expect(status.current.unread).toBe(true)
250
+ expect(seenPosts).toEqual([])
251
+ await act(async () => { openWhatsNew() })
252
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.16")
253
+ expect(dialog()?.textContent).not.toContain('Capacity views')
254
+ await clickGotIt()
255
+ expect(seenPosts).toEqual(['v1.16.0'])
256
+ expect(status.current.unread).toBe(false)
257
+ })
258
+
259
+ it('does not record a quiet upgrade without a seen record as read', async () => {
260
+ setCatalog(QUIET)
261
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', priorInstall: true }
262
+ await render('/')
263
+ expect(dialog()).toBeNull()
264
+ expect(seenPosts).toEqual([])
265
+ expect(status.current.unread).toBe(true)
266
+ })
267
+
268
+ it('composes the releases a user skipped, leading with the strongest', async () => {
269
+ setCatalog(HEADLINE, QUIET)
270
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
271
+ await render('/')
272
+ const text = dialog()?.textContent ?? ''
273
+ expect(text).toContain("What's new since Radar v1.14")
274
+ expect(text).toContain('Also new')
275
+ expect(dialog()?.querySelector('li')?.textContent).toContain('Capacity views')
276
+ expect(dialog()?.querySelector('li')?.textContent).toContain('v1.15')
277
+ const tiles = Array.from(dialog()!.querySelectorAll('ul > li span.w-8')).map(t => t.className)
278
+ expect(tiles).toHaveLength(4)
279
+ expect(new Set(tiles).size).toBe(4)
280
+ await clickGotIt()
281
+ expect(seenPosts).toEqual(['v1.16.0'])
282
+ })
283
+
284
+ it('previews an upgrade from another version without recording anything', async () => {
285
+ setCatalog(HEADLINE, QUIET)
286
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.16.0', priorInstall: true }
287
+ await render('/?whats-new=v1.16.0&whats-new-from=v1.14.0')
288
+ expect(dialog()?.textContent).toContain("What's new since Radar v1.14")
289
+ await clickGotIt()
290
+ expect(seenPosts).toEqual([])
291
+ })
292
+
293
+ it('records nothing when previewing the running release', async () => {
294
+ serverState = { currentVersion: 'v1.15.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
295
+ await render('/resources/pods?whats-new')
296
+ expect(dialog()).not.toBeNull()
297
+ await clickGotIt()
298
+ expect(seenPosts).toEqual([])
299
+ expect(status.current.unread).toBe(true)
300
+ })
301
+
302
+ it('does not name a seen record that is not a version', async () => {
303
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
304
+ localStorage.setItem('radar-whats-new-seen', 'vdev')
305
+ await render('/')
306
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.15")
307
+ expect(dialog()?.textContent).not.toContain('vdev')
308
+ })
309
+
310
+ it('links to the public changelog', async () => {
311
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
312
+ await render('/')
313
+ const link = Array.from(dialog()!.querySelectorAll('a')).find(a => a.textContent?.includes('Full changelog'))
314
+ expect(link?.getAttribute('href')).toBe('https://radarhq.io/changelog')
315
+ })
316
+ it('records the version it was opened for, even if the server is upgraded while it is open', async () => {
317
+ setCatalog(HEADLINE, QUIET)
318
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
319
+ await render('/')
320
+ expect(dialog()).not.toBeNull()
321
+ serverState = { ...serverState, currentVersion: 'v1.16.0' }
322
+ await act(async () => { await client.refetchQueries({ queryKey: ['whats-new'] }) })
323
+ await clickGotIt()
324
+ expect(seenPosts).toEqual(['v1.15.2'])
325
+ })
326
+
327
+ it('names the previewed release as the destination of a previewed upgrade', async () => {
328
+ setCatalog(HEADLINE, QUIET)
329
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
330
+ await render('/?whats-new=v1.16.0&whats-new-from=v1.14.0')
331
+ const text = dialog()?.textContent ?? ''
332
+ expect(text).toContain('v1.16.0')
333
+ expect(text).not.toContain('v1.15.2')
334
+ })
335
+ it('uses the lead copy only while a highlight leads the dialog', async () => {
336
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
337
+ await render('/')
338
+ expect(dialog()?.textContent).toContain('Capacity lead copy')
339
+ await clickGotIt()
340
+ const stronger: ReleaseNotes = { version: 'v1.16.0', highlights: [card('big', 'Bigger thing', 10), card('big2', 'Second thing', 10)], improvements: [] }
341
+ setCatalog(HEADLINE, stronger)
342
+ serverState = { currentVersion: 'v1.16.0', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
343
+ await act(async () => root.unmount())
344
+ root = createRoot(document.body.appendChild(document.createElement('div')))
345
+ client.clear()
346
+ await render('/')
347
+ const text = dialog()?.textContent ?? ''
348
+ expect(text).toContain('Capacity views')
349
+ expect(text).not.toContain('Capacity lead copy')
350
+ })
229
351
  })
@@ -1,53 +1,111 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
- import { Bot, GitCompareArrows, Layers, Network, Scale } from 'lucide-react'
2
+ import { Bot, GitCompareArrows, Layers, Network, RefreshCcw, Scale, ScrollText, SlidersHorizontal } from 'lucide-react'
3
3
  import { compareVersions } from '../../utils/version'
4
4
 
5
5
  export interface ReleaseHighlight {
6
6
  id: string
7
7
  icon: LucideIcon
8
8
  title: string
9
+ /**
10
+ * Shown on a grid card, so at most 150 characters. Read next to other
11
+ * releases' highlights, so it must stand on its own.
12
+ */
9
13
  description: string
14
+ /**
15
+ * Fuller copy for when this highlight leads the dialog. Only a release's
16
+ * top highlight can ever lead (every other one scores at or below it), so
17
+ * only that one may set it.
18
+ */
19
+ leadDescription?: string
20
+ /**
21
+ * 1-10, comparable across releases: it decides which highlights a user who
22
+ * skipped releases sees, and whether the dialog opens by itself.
23
+ * 9-10 a new capability that defines the release
24
+ * 7-8 a solid feature a typical user would go try now
25
+ * 4-6 a useful improvement
26
+ * 1-3 polish
27
+ * Most highlights are not 9s. When every card is a 7, every release
28
+ * interrupts the user, which is what the score exists to prevent.
29
+ */
30
+ importance: number
10
31
  /** In-app route the highlight's call to action opens. */
11
32
  path?: string
12
33
  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
34
  }
16
35
 
17
- export type HighlightTone = 'violet' | 'teal' | 'emerald' | 'indigo'
18
-
19
36
  export interface ReleaseNotes {
20
37
  version: string
21
- /** The first highlight leads the dialog at full width. */
22
38
  highlights: ReleaseHighlight[]
23
39
  improvements: string[]
24
- releaseUrl: string
25
40
  }
26
41
 
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
42
+ export const CHANGELOG_URL = 'https://radarhq.io/changelog'
43
+
44
+ // One entry per release that has something to announce. A user sees the
45
+ // entries for the releases they haven't seen yet, composed into one dialog, so
46
+ // a patch upgrade, or one that skips releases, still gets them. Every minor and
47
+ // major release must have its own entry: scripts/check-whats-new.sh refuses to
31
48
  // release one without it.
32
49
  export const RELEASE_NOTES: ReleaseNotes[] = [
50
+ {
51
+ version: 'v1.16.0',
52
+ highlights: [
53
+ {
54
+ id: 'restart-loop-issues',
55
+ icon: RefreshCcw,
56
+ title: 'One steady issue per crash loop',
57
+ description: 'A crash-looping container stays one issue until it recovers, with probe failures and exits as evidence. No more repeat alerts.',
58
+ leadDescription: 'A restarting container is one issue until it recovers, instead of a new one on every check, so no more repeat alerts and investigations. Probe failures, the last exit and how many replicas are affected sit on it as evidence, and severity follows impact.',
59
+ importance: 6,
60
+ path: '/issues',
61
+ cta: 'Open Issues',
62
+ },
63
+ {
64
+ id: 'log-viewer',
65
+ icon: ScrollText,
66
+ title: 'Log viewer enhancements',
67
+ description: 'Levels from the logger itself, stack traces kept with their errors, Hide matching, and a chip for lines without a level.',
68
+ importance: 5,
69
+ },
70
+ {
71
+ id: 'per-cluster-settings',
72
+ icon: SlidersHorizontal,
73
+ title: 'Separate settings for each cluster',
74
+ description: 'Running Radar locally, each kubeconfig context keeps its own Metrics, Argo CD and Cost connections.',
75
+ importance: 4,
76
+ },
77
+ ],
78
+ improvements: [
79
+ // The grid fills row by row: pair lines of similar length.
80
+ "A workload's Timeline shows only its own history",
81
+ "Timeline's Problems filter no longer hides crash loops",
82
+ 'Live Traffic figures corrected for Cilium and Istio',
83
+ 'Saved namespace picks survive a failing namespace list',
84
+ 'Terminal tabs show and keep the context they opened for',
85
+ 'YAML review flags a resource that changed after review',
86
+ 'Helm chart meets the Pod Security restricted profile',
87
+ 'Helm chart extraArgs passes through any Radar flag',
88
+ ],
89
+ },
33
90
  {
34
91
  version: 'v1.15.0',
35
- releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0',
36
92
  highlights: [
37
93
  {
38
94
  id: 'ai-investigations',
39
95
  icon: Bot,
40
96
  title: 'AI investigations that show their work',
41
- // No link: the investigations workspace redirects home when this run
97
+ // No link: the investigations page redirects home when this run
42
98
  // 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.',
99
+ description: 'A clear verdict and the story of what broke, with the evidence placed where the agent cites it. Runs on OpenCode too.',
100
+ leadDescription: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Runs on OpenCode too, including AWS Bedrock.',
101
+ importance: 9,
44
102
  },
45
103
  {
46
104
  id: 'gitops-manifest-diff',
47
105
  icon: GitCompareArrows,
48
106
  title: 'Full manifest diffs for Argo CD resources',
49
107
  description: 'Compare the Git-rendered and live manifest of a drifted Argo CD resource, side by side or unified.',
50
- tone: 'violet',
108
+ importance: 8,
51
109
  path: '/gitops',
52
110
  cta: 'Open GitOps',
53
111
  },
@@ -56,7 +114,7 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
56
114
  icon: Scale,
57
115
  title: 'Rightsizing and cost data in MCP',
58
116
  description: 'Agents get new optimized tools. Risks (OOM, limit conflicts and throttling) lead before cost-saving, in the UI as well.',
59
- tone: 'emerald',
117
+ importance: 7,
60
118
  path: '/cost/rightsizing',
61
119
  cta: 'Open Rightsizing',
62
120
  },
@@ -65,14 +123,14 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
65
123
  icon: Layers,
66
124
  title: 'Deeper batch + AI/ML workloads support',
67
125
  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',
126
+ importance: 7,
69
127
  },
70
128
  {
71
129
  id: 'rollout-traffic-topology',
72
130
  icon: Network,
73
131
  title: 'Canary and blue-green in Topology',
74
132
  description: 'See canary and stable Pods, which Services route to each side, and the canary traffic weight.',
75
- tone: 'indigo',
133
+ importance: 7,
76
134
  path: '/topology',
77
135
  cta: 'Open Topology',
78
136
  },
@@ -105,17 +163,90 @@ export function releaseLine(version: string): string {
105
163
  return version.replace(/^(v\d+\.\d+)\.\d+$/, '$1')
106
164
  }
107
165
 
108
- /** The newest notes for a release at or below `version` — what that version's user has access to. */
166
+ /** The entries at or below `version`, newest first: what that version's user has access to. */
167
+ function releasesUpTo(version: string, catalog: ReleaseNotes[]): ReleaseNotes[] {
168
+ return catalog
169
+ .filter(notes => (compareVersions(notes.version, version) ?? 1) <= 0)
170
+ .sort((a, b) => compareVersions(b.version, a.version) ?? 0)
171
+ }
172
+
173
+ /** The newest notes for a release at or below `version`. */
109
174
  export function latestReleaseNotesFor(
110
175
  version: string | undefined,
111
176
  catalog: ReleaseNotes[] = RELEASE_NOTES,
112
177
  ): ReleaseNotes | undefined {
113
178
  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
179
+ return releasesUpTo(version, catalog)[0]
180
+ }
181
+
182
+ /**
183
+ * The entries a user hasn't seen, newest first: those after `lastSeen`, up to
184
+ * the running version. A fresh install has nothing to compare against and gets
185
+ * none; an install that predates the seen record, or whose record isn't a
186
+ * version, gets them all.
187
+ */
188
+ export function unreadReleases(
189
+ currentVersion: string,
190
+ lastSeen: string | null,
191
+ priorInstall: boolean,
192
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
193
+ ): ReleaseNotes[] {
194
+ const available = releasesUpTo(currentVersion, catalog)
195
+ if (lastSeen === null) return priorInstall ? available : []
196
+ return available.filter(notes => (compareVersions(notes.version, lastSeen) ?? 1) > 0)
197
+ }
198
+
199
+ // How much a highlight counts by how far back its release is. A user who
200
+ // skipped releases sees the best of them, weighted toward the newest; older
201
+ // releases than this list covers are left to the changelog.
202
+ export const RELEASE_WEIGHTS = [1, 0.75, 0.5]
203
+ export const MAX_CARDS = 5
204
+ export const MAX_LINES = 8
205
+ // The dialog opens by itself only when the cards it shows add up to this.
206
+ // A release of mostly 4-6s stays behind the unread dot; one with a few solid
207
+ // features, or a skipped release with them, opens it.
208
+ export const AUTO_OPEN_SCORE = 25
209
+
210
+ export interface ComposedHighlight extends ReleaseHighlight {
211
+ version: string
212
+ score: number
213
+ }
214
+
215
+ export interface ComposedLine {
216
+ version: string
217
+ text: string
218
+ }
219
+
220
+ export interface ComposedNotes {
221
+ /** The releases shown, newest first. */
222
+ versions: string[]
223
+ /** The first one leads the dialog at full width. */
224
+ highlights: ComposedHighlight[]
225
+ lines: ComposedLine[]
226
+ /** Sum of the shown highlights' scores. */
227
+ score: number
228
+ /** False when older unseen releases were left out. */
229
+ complete: boolean
230
+ }
231
+
232
+ /** One dialog from releases ordered newest first. */
233
+ export function composeReleaseNotes(releases: ReleaseNotes[]): ComposedNotes {
234
+ const shown = releases.slice(0, RELEASE_WEIGHTS.length)
235
+ // Built newest release first, in authored order, so the stable sort breaks
236
+ // ties the same way.
237
+ const ranked = shown
238
+ .flatMap((notes, i) => notes.highlights.map(h => ({ ...h, version: notes.version, score: h.importance * RELEASE_WEIGHTS[i] })))
239
+ .sort((a, b) => b.score - a.score)
240
+ const highlights = ranked.slice(0, MAX_CARDS)
241
+ const lines: ComposedLine[] = [
242
+ ...ranked.slice(MAX_CARDS).map(h => ({ version: h.version, text: h.title })),
243
+ ...shown.flatMap(notes => notes.improvements.map(text => ({ version: notes.version, text }))),
244
+ ].slice(0, MAX_LINES)
245
+ return {
246
+ versions: shown.map(notes => notes.version),
247
+ highlights,
248
+ lines,
249
+ score: highlights.reduce((sum, h) => sum + h.score, 0),
250
+ complete: shown.length === releases.length,
119
251
  }
120
- return latest
121
252
  }
@@ -3,18 +3,20 @@ import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmission
3
3
  import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
4
4
  import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
5
5
  import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
6
- import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
6
+ import { useMemo, useEffect, useCallback, useRef, useState, type ReactNode } from 'react'
7
7
  import { useQueries, useQueryClient } from '@tanstack/react-query'
8
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
8
+ import { Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
9
9
  import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
10
10
  import { clsx } from 'clsx'
11
11
  import { Terminal, Stethoscope } from 'lucide-react'
12
12
  import {
13
13
  WorkloadView as BaseWorkloadView,
14
+ isApiGroup,
14
15
  EditableYamlView,
15
16
  FetchResult,
16
17
  Section,
17
18
  type WorkloadTabType,
19
+ type WorkloadExtraTab,
18
20
  type RendererOverrides,
19
21
  type GitOpsOwnerRef,
20
22
  type GitOpsStatus,
@@ -148,6 +150,9 @@ import {
148
150
  CNPGSubscriptionRenderer,
149
151
  } from '../resources/renderers/CNPGDeclarativeRenderer'
150
152
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
153
+ import { renderCNPGSummary } from '../cnpg/CNPGSummaryHost'
154
+ import { CNPGClusterLogs } from '../cnpg/CNPGClusterLogs'
155
+ import { cnpgDetailKindFor, cnpgDetailPath } from '../cnpg/routes'
151
156
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
152
157
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
153
158
  import { useCompareLauncher } from '../compare/useCompareLauncher'
@@ -277,6 +282,17 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
277
282
  )
278
283
  }
279
284
 
285
+ const cnpgPlural = cnpgDetailKindFor(kind, group)
286
+ if (cnpgPlural) {
287
+ const params = new URLSearchParams(searchParams)
288
+ params.delete('apiGroup')
289
+ const tab = params.get('tab')
290
+ if (cnpgPlural === 'clusters' && (tab === 'timeline' || tab === 'events')) params.set('tab', 'activity')
291
+ const base = cnpgDetailPath({ plural: cnpgPlural, namespace, name })
292
+ const qs = params.toString()
293
+ return <Navigate replace to={qs ? `${base}?${qs}` : base} state={location.state} />
294
+ }
295
+
280
296
  return (
281
297
  <WorkloadView
282
298
  kind={kind}
@@ -312,6 +328,8 @@ interface WorkloadViewProps {
312
328
  initialTab?: 'detail' | 'yaml'
313
329
  group?: string
314
330
  pushTabHistory?: boolean
331
+ breadcrumb?: ReactNode
332
+ extraTabs?: WorkloadExtraTab[]
315
333
  }
316
334
 
317
335
  interface ImageTargetOwnershipContext {
@@ -536,6 +554,7 @@ export function WorkloadView({
536
554
  ...rest
537
555
  }: WorkloadViewProps) {
538
556
  const [searchParams, setSearchParams] = useSearchParams()
557
+ const location = useLocation()
539
558
  const apiKind = kindToPluralWithGroup(kindProp, rest.group ?? '')
540
559
  const queryClient = useQueryClient()
541
560
  const [imageTargetOwnership, setImageTargetOwnership] =
@@ -561,9 +580,9 @@ export function WorkloadView({
561
580
  } else {
562
581
  params.set('tab', tab)
563
582
  }
564
- setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
583
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory, state: location.state })
565
584
  },
566
- [pushTabHistory, searchParams, setSearchParams],
585
+ [pushTabHistory, searchParams, setSearchParams, location.state],
567
586
  )
568
587
 
569
588
  const selectedRunKey = searchParams.get('run') ?? ''
@@ -1264,6 +1283,9 @@ export function WorkloadView({
1264
1283
  onSelectRun={handleSelectedRunChange}
1265
1284
  />
1266
1285
  )}
1286
+ renderSummary={({ apiKind: ak, namespace: ns, name: n, resource: res, context, onNavigate }) =>
1287
+ renderCNPGSummary({ apiKind: ak, namespace: ns, name: n, group: effectiveGroup, resource: res, context, onNavigate })
1288
+ }
1267
1289
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
1268
1290
  supportsBatchExecution(k, apiKind, effectiveGroup, res?.apiVersion) &&
1269
1291
  res ? (
@@ -1672,6 +1694,10 @@ function LogsTabContent({
1672
1694
  )
1673
1695
  }
1674
1696
 
1697
+ if (kind === 'Cluster' && isApiGroup(resource?.apiVersion, 'postgresql.cnpg.io')) {
1698
+ return <CNPGClusterLogs namespace={namespace} name={name} />
1699
+ }
1700
+
1675
1701
  // Workload kinds with stable pod selectors use the aggregated workload logs viewer
1676
1702
  if (WORKLOAD_LOG_KINDS.has(kind) && (kind !== 'Job' || isCoreBatchJob(apiKind, group))) {
1677
1703
  return (
@@ -11,7 +11,7 @@
11
11
  // agent-free.
12
12
  import { createContext, useContext, useMemo } from "react";
13
13
  import type { ReactNode } from "react";
14
- import type { RunSummary } from "../api/diagnose";
14
+ import type { InvestigationRefusal, RunSummary } from "../api/diagnose";
15
15
 
16
16
  /** Optional host controls beside shared run actions. Return a component element
17
17
  * if hooks are needed: this callback is invoked conditionally. Call onRunUpdated
@@ -21,6 +21,13 @@ export type RenderInvestigationRunActions = (props: {
21
21
  onRunUpdated: (run: RunSummary) => void;
22
22
  }) => ReactNode;
23
23
 
24
+ /** Host action beside a refused start or follow-up, e.g. Radar Cloud's
25
+ * "Upgrade" when an organization has used its investigations. Called only for
26
+ * refusals that carry a code, reason or action; return null to show none. */
27
+ export type RenderInvestigationRefusalAction = (
28
+ refusal: InvestigationRefusal,
29
+ ) => ReactNode;
30
+
24
31
  /** Render prop for the resource-level Investigate action. */
25
32
  export type RenderDiagnoseAction = (ctx: {
26
33
  /** Kubernetes Kind when loaded or discovered; otherwise its API resource name. */
@@ -63,6 +70,7 @@ export type DiagnoseConsentCopy = {
63
70
  export interface DiagnoseCustomization {
64
71
  renderAction: RenderDiagnoseAction | undefined;
65
72
  renderRunActions?: RenderInvestigationRunActions;
73
+ renderRefusalAction?: RenderInvestigationRefusalAction;
66
74
  consentCopy: DiagnoseConsentCopy | undefined;
67
75
  // undefined = default (CustomEvent → Radar's own Settings dialog);
68
76
  // null = hide the settings affordances.
@@ -82,20 +90,22 @@ export function DiagnoseCustomizationProvider({
82
90
  value,
83
91
  consentCopy,
84
92
  renderRunActions,
93
+ renderRefusalAction,
85
94
  onOpenSettings,
86
95
  children,
87
96
  }: {
88
97
  value: RenderDiagnoseAction | undefined;
89
98
  consentCopy?: DiagnoseConsentCopy;
90
99
  renderRunActions?: RenderInvestigationRunActions;
100
+ renderRefusalAction?: RenderInvestigationRefusalAction;
91
101
  /** Where "AI settings" affordances lead. Omit for Radar's own Settings
92
102
  * dialog; pass `null` to hide them. */
93
103
  onOpenSettings?: (() => void) | null;
94
104
  children: ReactNode;
95
105
  }) {
96
106
  const ctx = useMemo(
97
- () => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions }),
98
- [value, consentCopy, onOpenSettings, renderRunActions],
107
+ () => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions, renderRefusalAction }),
108
+ [value, consentCopy, onOpenSettings, renderRunActions, renderRefusalAction],
99
109
  );
100
110
  return (
101
111
  <DiagnoseCustomizationContext.Provider value={ctx}>
@@ -114,6 +114,10 @@ export function useCanHelmWrite(): boolean {
114
114
  return useContext(CapabilitiesContext).helmWrite
115
115
  }
116
116
 
117
+ export function useIsAuthEnabled(): boolean {
118
+ return useContext(CapabilitiesContext).authEnabled === true
119
+ }
120
+
117
121
  export function useCanNodeWrite(): boolean {
118
122
  return useContext(CapabilitiesContext).nodeWrite
119
123
  }
@@ -158,7 +162,11 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
158
162
  canExec: nsCaps?.exec ?? globalCaps.exec,
159
163
  canViewLogs: nsCaps?.logs ?? globalCaps.logs,
160
164
  canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
165
+ canHelmWrite: nsCaps?.helmWrite ?? globalCaps.helmWrite,
161
166
  workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
162
167
  workloadWritesPending: Boolean(namespace && isPending),
163
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, isPending, namespace, nsCaps])
168
+ // A failed refetch keeps the last successful namespace answer; with none,
169
+ // the global value is not an answer for this namespace.
170
+ helmWriteUnknown: Boolean(namespace && !nsCaps && (isPending || error)),
171
+ }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.helmWrite, globalCaps.workloadWrites, error, isPending, namespace, nsCaps])
164
172
  }
@@ -0,0 +1,49 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { debugNamespaceLog, fetchJSON, isStillLoadingError } from '../api/client'
3
+ import { useConnection } from '../context/ConnectionContext'
4
+
5
+ export interface ResourceCountsResponse {
6
+ counts: Record<string, number>
7
+ forbidden?: string[]
8
+ reasons?: Record<string, string>
9
+ unavailable?: string[]
10
+ }
11
+
12
+ // Lightweight per-kind counts for the Resources sidebar badges. Shared by the
13
+ // Resources view and any surface that renders the same sidebar standalone, so
14
+ // both read one cache entry.
15
+ export function useResourceCounts(namespaces: string[]) {
16
+ const { connection } = useConnection()
17
+ const namespacesParam = namespaces.join(',')
18
+ return useQuery({
19
+ queryKey: ['resource-counts', namespacesParam],
20
+ queryFn: async () => {
21
+ const params = new URLSearchParams()
22
+ if (namespaces.length > 0) params.set('namespaces', namespacesParam)
23
+ const startedAt = performance.now()
24
+ debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
25
+ try {
26
+ return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
27
+ } finally {
28
+ debugNamespaceLog('resources:counts-fetch-end', {
29
+ namespaces,
30
+ params: params.toString(),
31
+ durationMs: Math.round(performance.now() - startedAt),
32
+ })
33
+ }
34
+ },
35
+ staleTime: 10000,
36
+ // SSE invalidation isn't running while connecting, and mid-sync counts
37
+ // are what unlatch guarded kinds as their informers finish — poll fast
38
+ // during the shell, settle to the safety net once connected.
39
+ refetchInterval: connection.state === 'connecting' ? 3000 : 60000,
40
+ // During the first seconds of the progressive shell the endpoint 503s
41
+ // (cluster_connecting) until the mid-sync cache handle exists; keep the
42
+ // query pending rather than parking it in error state, which would
43
+ // unlatch the large-list guard at the connected flip.
44
+ retry: (failureCount: number, error: Error) =>
45
+ isStillLoadingError(error) ? true : failureCount < 3,
46
+ retryDelay: (failureCount: number, error: Error) =>
47
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
48
+ })
49
+ }