@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.
- package/package.json +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
}
|