@skyhook-io/radar-app 1.16.2 → 1.17.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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,13 +36,13 @@
36
36
  "diff": "^9.0.0",
37
37
  "monaco-editor": "0.55.1",
38
38
  "react-markdown": "^10.1.0",
39
- "react-virtuoso": "^4.18.13",
39
+ "react-virtuoso": "^4.18.16",
40
40
  "remark-gfm": "^4.0.1",
41
41
  "shiki": "^4.4.2",
42
42
  "yaml": "^2.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@skyhook-io/k8s-ui": ">=1.16.1",
45
+ "@skyhook-io/k8s-ui": ">=1.17.0",
46
46
  "@tanstack/react-query": ">=5",
47
47
  "@xyflow/react": ">=12.0.0",
48
48
  "clsx": ">=2",
@@ -73,14 +73,14 @@
73
73
  "globals": "^17.11.0",
74
74
  "lucide-react": "^1.48.0",
75
75
  "postcss": "^8.5.28",
76
- "prettier": "^3.9.8",
76
+ "prettier": "^3.9.9",
77
77
  "react": "^19.3.0",
78
78
  "react-dom": "^19.3.0",
79
79
  "react-router-dom": "^7.18.4",
80
80
  "tailwind-merge": "^3.7.0",
81
81
  "tailwindcss": "^4.3.3",
82
82
  "typescript": "^6.0.2",
83
- "typescript-eslint": "^8.70.0",
83
+ "typescript-eslint": "^8.71.0",
84
84
  "vite": "^8.3.1"
85
85
  },
86
86
  "sideEffects": [
package/src/App.tsx CHANGED
@@ -38,6 +38,9 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
38
38
  import { useNavCustomization } from './context/NavCustomization'
39
39
  import type { FleetTakeoverTarget } from './context/NavCustomization'
40
40
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
41
+ import { CNPGView } from './components/cnpg/CNPGView'
42
+ import { CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, parseCNPGRoute } from './components/cnpg/routes'
43
+ import { currentPageLabel } from './components/cnpg/paths'
41
44
  import { navigateFromPrimaryRail } from './components/nav/navigation'
42
45
  import { useNavRailPinned } from './hooks/useNavRailPinned'
43
46
  import { useMediaQuery } from './hooks/useMediaQuery'
@@ -45,6 +48,7 @@ import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitch
45
48
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
46
49
  import { ConnectionErrorView } from './components/ConnectionErrorView'
47
50
  import { SyncProgressPanel } from './components/SyncProgressPanel'
51
+ import { NoClusterAccessBanner } from './components/NoClusterAccessBanner'
48
52
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
49
53
  import { RadarUpgradeProvider } from './context/RadarUpgrade'
50
54
  import { UserMenu } from './components/UserMenu'
@@ -56,7 +60,7 @@ import { UsageDataPrompt } from './components/usage-data/UsageDataPrompt'
56
60
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
57
61
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
58
62
  import { useEventSource } from './hooks/useEventSource'
59
- import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
63
+ import { debugNamespaceLog, refetchOnResourceEvents, resourceEventKey, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
60
64
  import { buildAuditSeverityMap } from './utils/auditBadges'
61
65
  import { isInNamespaceScope, scopeNodesToNamespaces } from './utils/topology-namespace'
62
66
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from './api/config'
@@ -124,7 +128,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
124
128
 
125
129
  // Convert API resource name back to topology node ID prefix
126
130
  // Extended MainView type that includes traffic and cost
127
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
131
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'cnpg' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
128
132
 
129
133
  // Extract view from URL path
130
134
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -138,6 +142,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
138
142
  if (path === 'traffic') return 'traffic'
139
143
  if (path === 'cost') return 'cost'
140
144
  if (path === 'capacity') return 'capacity'
145
+ if (path === 'cnpg') return 'cnpg'
141
146
  if (path === 'workload') return 'workload'
142
147
  if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
143
148
  if (path === 'gitops') return 'gitops'
@@ -165,7 +170,7 @@ function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean):
165
170
  const CRASH_LABELS: Record<ExtendedMainView, string> = {
166
171
  home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
167
172
  issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
168
- cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
173
+ cost: 'Cost', capacity: 'Capacity', cnpg: 'CloudNativePG', checks: 'Checks', gitops: 'GitOps',
169
174
  applications: 'Applications', workload: 'Workload', compare: 'Compare',
170
175
  investigations: 'Investigations',
171
176
  }
@@ -282,6 +287,12 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
282
287
  if (pathSegments[1] === 'activity') return 'Capacity Activity'
283
288
  }
284
289
 
290
+ if (view === 'cnpg') {
291
+ const route = parseCNPGRoute(pathname)
292
+ if (route.detail) return route.detail.name
293
+ const screen = CNPG_SCREENS.find((s) => s.id === route.screen)
294
+ return `CloudNativePG ${screen?.label ?? 'Overview'}`
295
+ }
285
296
  if (view === 'home') return 'Overview'
286
297
  // Every other view's label is its id capitalized — getViewFromPath has already
287
298
  // normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
@@ -875,7 +886,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
875
886
  gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
876
887
  // Non-rail views (reachable via deep links / actions, not the rail) get no
877
888
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
878
- workload: '', compare: '', helmCompare: '', investigations: '',
889
+ workload: '', compare: '', helmCompare: '', investigations: '', cnpg: '',
879
890
  }
880
891
  const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
881
892
  (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
@@ -1058,12 +1069,14 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1058
1069
  // update that arrived *after* the fast structural flush already ran.
1059
1070
  const fastInvalidationRef = useRef<{
1060
1071
  changedKinds: Set<string> // every changed kind (any op) → detail drawer
1072
+ changedObjects: Set<string> // resourceEventKey of every event → detail views that got a 404
1061
1073
  structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
1062
1074
  environmentNamespaces: Set<string>
1063
1075
  environmentPods: Map<string, Set<string>>
1064
1076
  secretsChanged: boolean
1065
1077
  timer: number | null
1066
- }>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
1078
+ }>({ changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
1079
+ const cnpgInvalidationPendingRef = useRef(false)
1067
1080
  const slowInvalidationRef = useRef<{
1068
1081
  updatedKinds: Set<string> // update-only churn → throttled list + dashboard
1069
1082
  timer: number | null
@@ -1095,8 +1108,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1095
1108
  const structural = event.operation === 'add' || event.operation === 'delete'
1096
1109
  const applicationWorkload = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
1097
1110
 
1111
+ if (event.group?.endsWith('.cnpg.io')) cnpgInvalidationPendingRef.current = true
1112
+
1098
1113
  const fast = fastInvalidationRef.current
1099
1114
  fast.changedKinds.add(kind)
1115
+ fast.changedObjects.add(resourceEventKey(kind, event.namespace ?? '', event.name))
1100
1116
  if (structural) fast.structuralKinds.add(kind)
1101
1117
  if (kind === 'secrets') fast.secretsChanged = true
1102
1118
  if ((kind === 'configmaps' || kind === 'secrets') && event.namespace) fast.environmentNamespaces.add(event.namespace)
@@ -1114,7 +1130,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1114
1130
  fast.timer = window.setTimeout(() => {
1115
1131
  const f = fastInvalidationRef.current
1116
1132
  for (const k of f.changedKinds) {
1117
- queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
1133
+ // Open detail drawers stay live; a deleted one is left alone unless
1134
+ // an event names it.
1135
+ queryClient.invalidateQueries({
1136
+ queryKey: ['resource', k],
1137
+ predicate: (q) => refetchOnResourceEvents(q, k, f.changedObjects),
1138
+ })
1118
1139
  }
1119
1140
  for (const k of f.structuralKinds) {
1120
1141
  queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
@@ -1138,7 +1159,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1138
1159
  // GitOps view is mounted (Phase 2 will make this relevance-aware).
1139
1160
  queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
1140
1161
  queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
1141
- fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1162
+ if (cnpgInvalidationPendingRef.current) {
1163
+ cnpgInvalidationPendingRef.current = false
1164
+ queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace'] })
1165
+ }
1166
+ fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1142
1167
  }, 3000)
1143
1168
  }
1144
1169
 
@@ -1167,7 +1192,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1167
1192
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1168
1193
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1169
1194
  if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1170
- fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1195
+ fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1171
1196
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1172
1197
  timelineInvalidationRef.current = { timer: null }
1173
1198
  }, [])
@@ -1190,7 +1215,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1190
1215
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1191
1216
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1192
1217
  if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1193
- fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1218
+ fastInvalidationRef.current = { changedKinds: new Set(), changedObjects: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
1194
1219
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1195
1220
  timelineInvalidationRef.current = { timer: null }
1196
1221
 
@@ -1223,6 +1248,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1223
1248
  const nextParams = new URLSearchParams()
1224
1249
  const diagnoseRun = new URLSearchParams(location.search).get('ai-run')
1225
1250
  if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1251
+ // A CNPG detail keeps the context it belongs to, so it can say it is
1252
+ // not in the new one instead of loading a same-named object.
1253
+ const pinnedCtx = new URLSearchParams(location.search).get('ctx')
1254
+ if (pinnedCtx && location.pathname.startsWith('/cnpg/')) nextParams.set('ctx', pinnedCtx)
1226
1255
  navigate(
1227
1256
  { pathname: location.pathname, search: nextParams.toString() },
1228
1257
  { replace: true, state: location.state },
@@ -1749,7 +1778,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1749
1778
  setVisibleKinds(new Set())
1750
1779
  }, [])
1751
1780
 
1752
- const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
1781
+ const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView === 'cnpg' ? 'resources' : mainView
1753
1782
 
1754
1783
  return (
1755
1784
  <PortForwardProvider>
@@ -2010,6 +2039,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2010
2039
  </PaneLoader>
2011
2040
  )}
2012
2041
 
2042
+ {contentReady && <NoClusterAccessBanner />}
2013
2043
  {/* Main content - only show when connected and authenticated */}
2014
2044
  {/* inert while a fullscreen detail overlay covers the views — keeps the
2015
2045
  retained background list out of the focus order + a11y tree (the visual
@@ -2290,7 +2320,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2290
2320
 
2291
2321
  {/* Traffic view */}
2292
2322
  {!viewsSyncGated && mainView === 'traffic' && (
2293
- <TrafficView namespaces={namespaces} />
2323
+ <TrafficView namespaces={namespaces} onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }} />
2294
2324
  )}
2295
2325
 
2296
2326
  {/* Cost detail view */}
@@ -2302,6 +2332,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2302
2332
  <CapacityView onOpenResource={navigateToResource} />
2303
2333
  )}
2304
2334
 
2335
+ {!viewsSyncGated && mainView === 'cnpg' && (
2336
+ <CNPGView
2337
+ namespaces={namespaces}
2338
+ selectedResource={routeSelectedResource}
2339
+ onOpenResource={navigateToResource}
2340
+ onCloseResource={() => setSelectedResource(null)}
2341
+ onClearNamespaces={clearAllNamespaces}
2342
+ />
2343
+ )}
2344
+
2305
2345
  {/* Takeover splash. When the host claims the current view via
2306
2346
  fleetTakeoverHref, the redirect effect above is mid-flight — render a
2307
2347
  brief splash instead of the inline view (which would flash + fire its
@@ -2369,7 +2409,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2369
2409
  onClose={closeDrawer}
2370
2410
  onNavigate={(res) => navigateToResource(res)}
2371
2411
  canCollapseToDrawer={!isMobile}
2372
- onExpand={(_res, opts) => {
2412
+ onExpand={(res, opts) => {
2413
+ const cnpgPlural = cnpgDetailKindFor(res.kind, res.group)
2414
+ if (cnpgPlural) {
2415
+ navigate(
2416
+ cnpgDetailPath({ plural: cnpgPlural, namespace: res.namespace, name: res.name }, connection.context || undefined, opts?.yaml ? 'yaml' : undefined),
2417
+ { state: { returnLabel: currentPageLabel(), returnCtx: connection.context } },
2418
+ )
2419
+ return
2420
+ }
2373
2421
  // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2374
2422
  // collapses) over whatever view is underneath — list, topology graph,
2375
2423
  // GitOps, Applications — which stays mounted. Carry the YAML tab when
package/src/RadarApp.tsx CHANGED
@@ -46,6 +46,7 @@ import type {
46
46
  import type { ClusterLoadState } from "./types/clusterLoadState";
47
47
  import { TimelineSourceProvider } from "./context/TimelineSource";
48
48
  import type { TimelineSourceConfig } from "./api/timelineSource";
49
+ import { TimelineRetentionNoteProvider } from "./context/TimelineCustomization";
49
50
  import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
50
51
  import type {
51
52
  RenderDiagnoseAction,
@@ -180,6 +181,12 @@ export interface RadarAppProps {
180
181
  * React hook-order violation). Set it once at mount when possible.
181
182
  */
182
183
  timelineSource?: TimelineSourceConfig;
184
+ /**
185
+ * A note shown above the timeline and the Applications history, saying how
186
+ * far back history goes. Omitted → no note. Set it only when the timeline server
187
+ * actually limits history, or the note misstates what is shown.
188
+ */
189
+ timelineRetentionNote?: React.ReactNode;
183
190
  }
184
191
 
185
192
  // Default QueryClient with the same shape Radar's standalone binary uses.
@@ -236,6 +243,7 @@ export function RadarApp({
236
243
  onClusterLoadStateChange,
237
244
  onInvestigationFocus,
238
245
  timelineSource,
246
+ timelineRetentionNote,
239
247
  }: RadarAppProps): React.ReactElement {
240
248
  // Apply runtime config during render so module-level singletons are set
241
249
  // before children construct URLs. getApiBase() / getAuthHeaders() /
@@ -266,24 +274,26 @@ export function RadarApp({
266
274
  <RadarUpgradeHostProvider value={upgradeHost}>
267
275
  <FilterLocationBridge>
268
276
  <TimelineSourceProvider config={timelineSource}>
269
- <DiagnoseCustomizationProvider
270
- value={renderDiagnoseAction ?? defaultDiagnoseAction}
271
- consentCopy={diagnoseConsent}
272
- renderRunActions={renderInvestigationRunActions}
273
- renderRefusalAction={renderInvestigationRefusalAction}
274
- >
275
- <DiagnoseProvider
276
- browserURLState={router !== "memory"}
277
- forceRouterURLState={router === "memory"}
278
- onFocusedRun={onInvestigationFocus}
277
+ <TimelineRetentionNoteProvider value={timelineRetentionNote}>
278
+ <DiagnoseCustomizationProvider
279
+ value={renderDiagnoseAction ?? defaultDiagnoseAction}
280
+ consentCopy={diagnoseConsent}
281
+ renderRunActions={renderInvestigationRunActions}
282
+ renderRefusalAction={renderInvestigationRefusalAction}
279
283
  >
280
- <App
281
- manageDocumentTitle={manageDocumentTitle}
282
- documentTitleSuffix={documentTitleSuffix}
283
- onClusterLoadStateChange={onClusterLoadStateChange}
284
- />
285
- </DiagnoseProvider>
286
- </DiagnoseCustomizationProvider>
284
+ <DiagnoseProvider
285
+ browserURLState={router !== "memory"}
286
+ forceRouterURLState={router === "memory"}
287
+ onFocusedRun={onInvestigationFocus}
288
+ >
289
+ <App
290
+ manageDocumentTitle={manageDocumentTitle}
291
+ documentTitleSuffix={documentTitleSuffix}
292
+ onClusterLoadStateChange={onClusterLoadStateChange}
293
+ />
294
+ </DiagnoseProvider>
295
+ </DiagnoseCustomizationProvider>
296
+ </TimelineRetentionNoteProvider>
287
297
  </TimelineSourceProvider>
288
298
  </FilterLocationBridge>
289
299
  </RadarUpgradeHostProvider>
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ApiError, goneRecheckInterval, refetchOnResourceEvents, resourceEventKey } from "./client";
3
+
4
+ const notFound = new ApiError('pods "web-1" not found', 404);
5
+ const detail = (namespace: string, name: string, error: unknown) => ({
6
+ queryKey: ["resource", "pods", namespace, name, undefined],
7
+ state: { error },
8
+ });
9
+
10
+ describe("refetchOnResourceEvents", () => {
11
+ it("refetches a live detail view on any change to its kind", () => {
12
+ expect(refetchOnResourceEvents(detail("shop", "web-1", null), "pods", new Set())).toBe(true);
13
+ });
14
+
15
+ it("refetches a detail view that failed for a reason other than 404", () => {
16
+ const failed = detail("shop", "web-1", new ApiError("unavailable", 503));
17
+ expect(refetchOnResourceEvents(failed, "pods", new Set())).toBe(true);
18
+ });
19
+
20
+ it("leaves a deleted object alone when other objects of its kind change", () => {
21
+ // The production shape: pods churning next to an open view of a deleted
22
+ // pod refetched it, 404 every time, for as long as the view stayed open.
23
+ const named = new Set([resourceEventKey("pods", "shop", "web-2")]);
24
+ expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(false);
25
+ });
26
+
27
+ it("refetches a deleted object as soon as an event names it", () => {
28
+ // A StatefulSet recreates its pod under the same name.
29
+ const named = new Set([resourceEventKey("pods", "shop", "web-1")]);
30
+ expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(true);
31
+ });
32
+
33
+ it("matches a cluster-scoped object, whose namespace is empty", () => {
34
+ const query = { queryKey: ["resource", "nodes", "", "node-a", undefined], state: { error: notFound } };
35
+ expect(refetchOnResourceEvents(query, "nodes", new Set([resourceEventKey("nodes", "", "node-a")]))).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("goneRecheckInterval", () => {
40
+ it("re-checks a deleted object once a minute", () => {
41
+ expect(goneRecheckInterval({ state: { error: notFound } }, undefined)).toBe(60_000);
42
+ expect(goneRecheckInterval({ state: { error: notFound } }, 5_000)).toBe(60_000);
43
+ });
44
+
45
+ it("keeps the caller's interval otherwise", () => {
46
+ expect(goneRecheckInterval({ state: { error: null } }, 5_000)).toBe(5_000);
47
+ expect(goneRecheckInterval({ state: { error: new ApiError("unavailable", 503) } }, undefined)).toBeUndefined();
48
+ });
49
+ });