@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
@@ -1,12 +1,16 @@
1
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
- import type { TrafficSourcesResponse, TrafficFlowsResponse } from '../types'
1
+ import { useCallback, useMemo } from 'react'
2
+ import { useQuery, useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query'
3
+ import type { TrafficSourcesResponse, TrafficFlowsResponse, TrafficRecordsResponse } from '../types'
4
+ import { fetchJSON as fetchApiJSON, useRadarFeature } from './client'
5
+ import { shouldRetryRadarQuery } from './radarFeatures'
3
6
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
7
  import { readErrorBody } from './httpErrors'
5
8
 
6
- async function fetchJSON<T>(path: string): Promise<T> {
9
+ async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
7
10
  const response = await fetch(apiUrl(path), {
8
11
  credentials: getCredentialsMode(),
9
12
  headers: getAuthHeaders(),
13
+ signal,
10
14
  })
11
15
  if (!response.ok) {
12
16
  const error = await readErrorBody(response)
@@ -26,34 +30,102 @@ export interface TrafficConnectionInfo {
26
30
  error?: string
27
31
  }
28
32
 
29
- // Get available traffic sources and recommendations
33
+ // Get available traffic sources and recommendations. A detection up to a
34
+ // couple of minutes old is reused when it found a source: a full one probes
35
+ // every source in turn and takes seconds, and the page that linked here has
36
+ // usually just made one. One that found none is redone, since it is about to
37
+ // be shown as setup advice. detectAgain always detects afresh.
38
+ export async function fetchTrafficSources(queryClient: QueryClient, fresh: boolean, signal?: AbortSignal): Promise<TrafficSourcesResponse> {
39
+ let sources = await fetchJSON<TrafficSourcesResponse>(fresh ? '/traffic/sources' : '/traffic/sources?recent=1', signal)
40
+ if (!fresh && !sources.detected.some(s => s.status === 'available')) {
41
+ sources = await fetchJSON<TrafficSourcesResponse>('/traffic/sources', signal)
42
+ }
43
+ // A detection is also the newest answer to "is traffic available".
44
+ queryClient.setQueryData(['traffic-sources', 'recent'], sources)
45
+ return sources
46
+ }
47
+
48
+ export async function detectTrafficSourcesAgain(queryClient: QueryClient): Promise<TrafficSourcesResponse> {
49
+ // A pending fetch of the same key would otherwise be returned in its place.
50
+ await queryClient.cancelQueries({ queryKey: ['traffic-sources'], exact: true })
51
+ return queryClient.fetchQuery({
52
+ queryKey: ['traffic-sources'],
53
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, true, signal),
54
+ staleTime: 0,
55
+ })
56
+ }
57
+
30
58
  export function useTrafficSources() {
31
- return useQuery<TrafficSourcesResponse>({
59
+ const queryClient = useQueryClient()
60
+ const query = useQuery<TrafficSourcesResponse>({
32
61
  queryKey: ['traffic-sources'],
33
- queryFn: () => fetchJSON('/traffic/sources'),
62
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, false, signal),
34
63
  staleTime: 30000, // 30 seconds
35
64
  retry: 1,
36
65
  })
66
+ const detectAgain = useCallback(() => detectTrafficSourcesAgain(queryClient), [queryClient])
67
+ return { ...query, detectAgain }
37
68
  }
38
69
 
39
- // Get traffic flows
40
- export interface UseTrafficFlowsOptions {
70
+ // Whether this cluster has a traffic source: true only once one is detected
71
+ // as available, so a page offering traffic never offers it on a guess. Accepts
72
+ // a detection up to a couple of minutes old (?recent=1), so browsing workloads
73
+ // does not probe every source on each page.
74
+ export function useTrafficAvailable(enabled = true): boolean {
75
+ // A workload focus needs the server to name each pod's workload, which
76
+ // shipped with flow records. An older Radar leaves Hubble pods unnamed, so
77
+ // the focus it opened would match nothing.
78
+ const resolvesWorkloads = useRadarFeature('trafficRecords').support === 'supported'
79
+ const { data } = useQuery<TrafficSourcesResponse>({
80
+ queryKey: ['traffic-sources', 'recent'],
81
+ queryFn: () => fetchJSON('/traffic/sources?recent=1'),
82
+ staleTime: 2 * 60_000,
83
+ retry: false,
84
+ enabled: enabled && resolvesWorkloads,
85
+ })
86
+ return resolvesWorkloads && !!data?.detected?.some(s => s.status === 'available')
87
+ }
88
+
89
+ // What the flows and records queries share: the namespaces in view, the
90
+ // window, and the traffic the view hides. The hidden traffic is sent so the
91
+ // source can drop it before it counts against its limits; the view still
92
+ // filters it too.
93
+ export interface TrafficScope {
41
94
  namespaces?: string[]
42
95
  since?: string // Duration like "5m", "1h"
43
- enabled?: boolean
96
+ excludeNamespaces?: readonly string[]
97
+ excludeHost?: boolean
44
98
  }
45
99
 
46
- export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
47
- const { namespaces = [], since, enabled = true } = options
48
-
100
+ function trafficScopeParams({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope): URLSearchParams {
49
101
  const params = new URLSearchParams()
50
- // Traffic backend only supports single namespace, use first if provided
102
+ // With several namespaces in view the server reads them from the saved pick.
51
103
  if (namespaces.length === 1) params.set('namespace', namespaces[0])
52
104
  if (since) params.set('since', since)
105
+ if (excludeNamespaces?.length) params.set('excludeNamespaces', excludeNamespaces.join(','))
106
+ if (excludeHost) params.set('excludeHost', 'true')
107
+ return params
108
+ }
109
+
110
+ function trafficScopeKey({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope) {
111
+ return [namespaces, since, excludeNamespaces ?? [], !!excludeHost]
112
+ }
113
+
114
+ // Get traffic flows
115
+ export interface UseTrafficFlowsOptions extends TrafficScope {
116
+ /** One graph node per pod instead of per workload. */
117
+ byPod?: boolean
118
+ enabled?: boolean
119
+ }
120
+
121
+ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
122
+ const { enabled = true, byPod = false } = options
123
+ const params = trafficScopeParams(options)
124
+ if (byPod) params.set('groupBy', 'pod')
53
125
  const queryString = params.toString()
54
126
 
55
127
  return useQuery<TrafficFlowsResponse>({
56
- queryKey: ['traffic-flows', namespaces, since],
128
+ queryKey: ['traffic-flows', ...trafficScopeKey(options), byPod],
57
129
  queryFn: () => fetchJSON(`/traffic/flows${queryString ? `?${queryString}` : ''}`),
58
130
  staleTime: 5000, // 5 seconds
59
131
  enabled,
@@ -61,6 +133,93 @@ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
61
133
  })
62
134
  }
63
135
 
136
+ /** An endpoint as the server's aggregation keys it. */
137
+ export interface TrafficEndpointRef {
138
+ namespace?: string
139
+ name: string
140
+ kind?: string
141
+ workloadKind?: string
142
+ }
143
+
144
+ /** One edge of the server's aggregation, keyed as the server keys it. */
145
+ export interface TrafficEndpointPair {
146
+ source: TrafficEndpointRef
147
+ destination: TrafficEndpointRef
148
+ port: number
149
+ directionUnknown?: boolean
150
+ }
151
+
152
+ /** The selection rides in the query string, URL-encoded. Proxies in front of
153
+ * Radar commonly refuse request lines over 8 KiB (ingress-nginx's default),
154
+ * so a larger selection is not sent at all. */
155
+ const MAX_TRAFFIC_MATCH_CHARS = 6_000
156
+
157
+ /** A selection as the server matches it: its edges, or a node as the one
158
+ * endpoint it is, which every one of its edges has at an end. */
159
+ export type TrafficMatch = { pairs: TrafficEndpointPair[] } | { endpoints: TrafficEndpointRef[] }
160
+
161
+ // Every pair encodes to more than this many characters, so a selection with
162
+ // more pairs than the request can hold is rejected without serializing it.
163
+ const MIN_MATCH_PAIR_CHARS = 40
164
+
165
+ /** The whole query string's limit: the selection plus the scope beside it,
166
+ * under the 8 KiB request line proxies commonly allow. */
167
+ const MAX_TRAFFIC_QUERY_CHARS = 7_000
168
+
169
+ function encodedMatch(match: TrafficMatch): string | null {
170
+ if ('pairs' in match && match.pairs.length * MIN_MATCH_PAIR_CHARS > MAX_TRAFFIC_MATCH_CHARS) return null
171
+ const json = JSON.stringify(match)
172
+ return encodeURIComponent(json).length > MAX_TRAFFIC_MATCH_CHARS ? null : json
173
+ }
174
+
175
+ export interface UseTrafficRecordsOptions extends TrafficScope {
176
+ /** The selection, or null for no selection. */
177
+ match: TrafficMatch | null
178
+ /** Namespaces also excluded when the request has room for them; the list
179
+ * filters them out anyway. */
180
+ optionalExcludeNamespaces?: readonly string[]
181
+ enabled?: boolean
182
+ }
183
+
184
+ // The newest records behind a graph selection. The flows response carries only
185
+ // a capped sample of records, so a selected edge is looked up on its own —
186
+ // otherwise a quiet edge on a busy cluster would show nothing.
187
+ export function useTrafficRecords(options: UseTrafficRecordsOptions) {
188
+ const { enabled = true } = options
189
+ const { guard, gatedKey, support } = useRadarFeature('trafficRecords')
190
+ // Serialized when the selection changes (each refresh), not on every
191
+ // render: a merged edge can carry tens of thousands of pairs.
192
+ const match = useMemo(() => (options.match ? encodedMatch(options.match) : null), [options.match])
193
+ const optional = options.optionalExcludeNamespaces ?? []
194
+ let scope: TrafficScope = optional.length > 0
195
+ ? { ...options, excludeNamespaces: [...(options.excludeNamespaces ?? []), ...optional] }
196
+ : options
197
+ let params = trafficScopeParams(scope)
198
+ if (match) params.set('match', match)
199
+ if (optional.length > 0 && params.toString().length > MAX_TRAFFIC_QUERY_CHARS) {
200
+ scope = options
201
+ params = trafficScopeParams(scope)
202
+ if (match) params.set('match', match)
203
+ }
204
+ const tooLarge = options.match !== null && (match === null || params.toString().length > MAX_TRAFFIC_QUERY_CHARS)
205
+ // The view this answers, apart from the optional exclusions.
206
+ const view = JSON.stringify([...trafficScopeKey(options), match])
207
+
208
+ const query = useQuery<TrafficRecordsResponse>({
209
+ queryKey: ['traffic-flows', 'records', ...trafficScopeKey(scope), match, ...gatedKey],
210
+ queryFn: () => guard(() => fetchApiJSON<TrafficRecordsResponse>(`/traffic/flows/records?${params.toString()}`)),
211
+ staleTime: 5000,
212
+ enabled: enabled && match !== null && !tooLarge && support !== 'unsupported',
213
+ meta: { view },
214
+ // Kept while only the optional exclusions change (a namespace hidden in
215
+ // the sidebar, which the list filters out anyway). Not across another
216
+ // selection, window or scope: those records would show under this one.
217
+ placeholderData: (prev, prevQuery) => (prevQuery?.meta?.view === view ? prev : undefined),
218
+ retry: shouldRetryRadarQuery,
219
+ })
220
+ return { ...query, tooLarge, supported: support !== 'unsupported' }
221
+ }
222
+
64
223
  // Get active traffic source
65
224
  export function useActiveTrafficSource() {
66
225
  return useQuery<{ active: string }>({
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { ShieldAlert } from 'lucide-react'
3
+ import { AlertBanner, Badge, Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui'
4
+ import { useAuthMe, useNamespaceAccess } from '../api/client'
5
+
6
+ // Every read is filtered to the user's namespaces, so someone bound to none
7
+ // sees empty lists in every view and reads it as an empty cluster. Stated in
8
+ // terms of the user's own access, not how the cluster is meant to be set up:
9
+ // Radar can't know whether an org intends hub roles or IdP groups here.
10
+ export function NoClusterAccessBanner() {
11
+ const { data: auth } = useAuthMe()
12
+ // Mounted once content is ready (and again after a context switch), so the
13
+ // cluster is connected by the time this asks.
14
+ const { data } = useNamespaceAccess(Boolean(auth?.authEnabled))
15
+ // Keep the last definite answer through a check that couldn't decide, so a
16
+ // transient discovery failure doesn't hide the banner from a user without access.
17
+ const [me, setMe] = useState<typeof data>()
18
+ useEffect(() => {
19
+ if (data && typeof data.noNamespaceAccess === 'boolean') setMe(data)
20
+ }, [data])
21
+ const [groupsOpen, setGroupsOpen] = useState(false)
22
+ const groupsDisclosure = useDisclosure(groupsOpen)
23
+
24
+ // The remembered answer only counts for the person signed in now.
25
+ if (!me?.noNamespaceAccess || me.username !== auth?.username) return null
26
+
27
+ const groups = me.groups ?? []
28
+ return (
29
+ <div className="px-4 pt-3">
30
+ <AlertBanner
31
+ variant="warning"
32
+ icon={ShieldAlert}
33
+ title="You can't read any namespace in this cluster"
34
+ message={
35
+ <>
36
+ Radar is connected, but your Kubernetes permissions don't allow reading any namespace, so
37
+ every view looks empty. Signed in as <span className="font-mono">{me.username}</span>.
38
+ </>
39
+ }
40
+ >
41
+ {groups.length > 0 && (
42
+ // ph-no-capture: group IDs name the customer's directory structure.
43
+ // Radar Cloud embeds this view with session replay on, and its text
44
+ // mask only catches credential-shaped values. A no-op without PostHog.
45
+ <div className="ph-no-capture mt-2">
46
+ <button
47
+ {...groupsDisclosure.buttonProps}
48
+ onClick={() => setGroupsOpen((v) => !v)}
49
+ className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
50
+ >
51
+ <CollapseChevron open={groupsOpen} className="w-3.5 h-3.5" />
52
+ Your groups ({groups.length})
53
+ </button>
54
+ <Collapse open={groupsOpen} id={groupsDisclosure.panelId}>
55
+ <div className="mt-2 flex max-h-32 max-w-4xl flex-wrap gap-1.5 overflow-y-auto">
56
+ {groups.map((g) => (
57
+ <Badge key={g} tone="structural" size="sm" className="font-mono">
58
+ {g}
59
+ </Badge>
60
+ ))}
61
+ </div>
62
+ </Collapse>
63
+ </div>
64
+ )}
65
+ </AlertBanner>
66
+ </div>
67
+ )
68
+ }
@@ -55,6 +55,7 @@ import { useTimelineSource } from "../../context/TimelineSource";
55
55
  import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
56
56
  import { WorkloadView } from "../workload/WorkloadView";
57
57
  import { ApplicationCostTab } from "../cost/ApplicationCostTab";
58
+ import { useTimelineRetentionNote } from "../../context/TimelineCustomization";
58
59
  import { isOpenCostWorkloadKind } from "../cost/kinds";
59
60
 
60
61
  type ApplicationRouteView = ApplicationView | "cost";
@@ -302,6 +303,7 @@ function AppDetailRoute({
302
303
  : historyRangeOptions[0].value;
303
304
  const historyTimelineEnabled =
304
305
  !selectedWorkloadKey && selectedView === "history";
306
+ const retentionNote = useTimelineRetentionNote();
305
307
  const historyTimelineQuery = timelineSource.useEvents({
306
308
  namespaces: appHistoryNamespaces,
307
309
  timeRange: historyRange,
@@ -665,6 +667,7 @@ function AppDetailRoute({
665
667
  historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
666
668
  historyRuntimeLimited={historyRuntimeLimited}
667
669
  historyRingTruncated={historyTimelineQuery.truncated ?? false}
670
+ historyRetentionNote={retentionNote}
668
671
  onHistoryRangeChange={setRetainedHistoryRange}
669
672
  onOpenTimeline={openApplicationTimeline}
670
673
  onOpenSource={openSource}
@@ -676,12 +676,23 @@ function LifecycleGrid({
676
676
  Nodes
677
677
  </div>
678
678
  {pool.nodes ? (
679
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
680
- <MiniCount label="Ready" value={pool.nodes.ready} />
681
- <MiniCount label="Not ready" value={pool.nodes.notReady} />
682
- <MiniCount label="Cordoned" value={pool.nodes.cordoned} />
683
- <MiniCount label="Terminating" value={pool.nodes.terminating} />
684
- </div>
679
+ <>
680
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
681
+ <MiniCount label="Ready" value={pool.nodes.operational ? pool.nodes.operational.ready : pool.nodes.ready} />
682
+ <MiniCount label="Not ready" value={pool.nodes.operational ? pool.nodes.operational.notReady : pool.nodes.notReady} />
683
+ <MiniCount label="Cordoned" value={pool.nodes.operational ? pool.nodes.operational.cordoned : pool.nodes.cordoned} />
684
+ {pool.nodes.operational ? (
685
+ <MiniCount label="Removing" value={pool.nodes.operational.removing} />
686
+ ) : (
687
+ <MiniCount label="Terminating" value={pool.nodes.terminating} />
688
+ )}
689
+ </div>
690
+ {pool.nodes.operational ? pool.nodes.operational.removingUnhealthy > 0 && (
691
+ <p className="mt-2 text-xs text-theme-text-secondary">{pool.nodes.operational.removingUnhealthy} removing {pool.nodes.operational.removingUnhealthy === 1 ? "node has" : "nodes have"} critical pressure or prolonged removal.</p>
692
+ ) : (
693
+ <p className="mt-2 text-xs text-theme-text-secondary">Readiness counts only; removal status is unavailable from this Radar.</p>
694
+ )}
695
+ </>
685
696
  ) : (
686
697
  <p className="text-sm text-theme-text-secondary">
687
698
  {coverageMessage(pool.coverage.nodes, "Node lifecycle")}
@@ -1353,7 +1364,7 @@ function PoolMembersTab({
1353
1364
  />
1354
1365
  </td>
1355
1366
  <td className={TD}>
1356
- <NodeReadyBadge ready={node.ready} cordoned={node.cordoned} />
1367
+ <NodeReadyBadge ready={node.ready} cordoned={node.cordoned} lifecycle={node.lifecycle} />
1357
1368
  </td>
1358
1369
  <td className={`${TD} font-mono text-xs`}>
1359
1370
  {node.instanceType ?? "—"}
@@ -24,7 +24,7 @@ import type {
24
24
  } from "@skyhook-io/k8s-ui";
25
25
  import { CapacityView } from "./CapacityView";
26
26
  import { updateDemandSearchParam } from "./CapacityDemand";
27
- import { integrationBlock } from "./shared";
27
+ import { integrationBlock, NodeReadyBadge } from "./shared";
28
28
  import { ApiError } from "../../api/client";
29
29
  import { RadarFeatureUnsupportedError } from "../../api/radarFeatures";
30
30
 
@@ -2315,3 +2315,33 @@ describe("integrationBlock version skew", () => {
2315
2315
  expect(html).not.toContain("needs a newer Radar");
2316
2316
  });
2317
2317
  });
2318
+
2319
+ describe("Node lifecycle in Capacity", () => {
2320
+ it("uses backend lifecycle while preserving raw readiness", () => {
2321
+ const lifecycle = { label: "Removing (Karpenter)", level: "neutral" as const, removing: true, readinessFailed: false, delayed: false };
2322
+ for (const ready of [true, false, undefined]) {
2323
+ const html = renderToString(<NodeReadyBadge ready={ready} cordoned={false} lifecycle={lifecycle} />);
2324
+ expect(html).toContain("Removing (Karpenter)");
2325
+ expect(html).not.toContain("NotReady");
2326
+ expect(html).not.toContain("text-red-");
2327
+ }
2328
+ const failed = renderToString(<NodeReadyBadge ready={false} cordoned={false} lifecycle={{ ...lifecycle, label: "Removing (Karpenter) · NotReady", level: "unhealthy", readinessFailed: true }} />);
2329
+ expect(failed).toContain("NotReady");
2330
+ expect(failed).toContain("text-red-");
2331
+ const raw = renderToString(<NodeReadyBadge ready={false} cordoned={false} />);
2332
+ expect(raw).toContain("NotReady (condition)");
2333
+ expect(raw).toContain("text-red-");
2334
+ });
2335
+ it("renders exclusive operational counts, not overlapping raw counts", () => {
2336
+ const html = renderCapacity("/capacity/pools/default", (client) => client.setQueryData(
2337
+ ["capacity", "pool", "default"], poolDetailResponse({ ...cleanPoolDetail, nodes: {
2338
+ total: 6, ready: 4, notReady: 2, cordoned: 1, terminating: 2,
2339
+ operational: { total: 6, ready: 2, notReady: 1, cordoned: 1, removing: 2, removingUnhealthy: 1 },
2340
+ } }),
2341
+ ));
2342
+ expect(html).toMatch(/>2<\/div><div[^>]*>Ready<\/div>/);
2343
+ expect(html).toMatch(/>1<\/div><div[^>]*>Not ready<\/div>/);
2344
+ expect(html).toMatch(/>2<\/div><div[^>]*>Removing<\/div>/);
2345
+ expect(html.replace(/<!-- -->/g, "")).toContain("1 removing node has critical pressure or prolonged removal.");
2346
+ });
2347
+ });
@@ -14,6 +14,7 @@ import {
14
14
  } from "lucide-react";
15
15
  import {
16
16
  FreshnessControl,
17
+ healthToSeverity,
17
18
  formatDuration,
18
19
  getRadarUpgradeRequirement,
19
20
  RadarUpgradeAction,
@@ -38,6 +39,7 @@ import {
38
39
  type CapacityManagerRollupStatus,
39
40
  type CapacityManagerSummary,
40
41
  type CapacityMemberType,
42
+ type CapacityNodeMember,
41
43
  type CapacityPoolSummary,
42
44
  type CapacityQuantityObservation,
43
45
  type CapacityResourceIdentity,
@@ -603,32 +605,21 @@ export function PoolReadyBadge({ ready }: { ready?: boolean }) {
603
605
  export function NodeReadyBadge({
604
606
  ready,
605
607
  cordoned,
608
+ lifecycle,
606
609
  }: {
607
610
  ready?: boolean;
608
611
  cordoned: boolean;
612
+ lifecycle?: CapacityNodeMember["lifecycle"];
609
613
  }) {
610
- if (ready === false)
611
- return (
612
- <Badge severity="error" size="sm">
613
- NotReady
614
- </Badge>
615
- );
616
- if (ready === undefined)
617
- return (
618
- <Badge severity="neutral" size="sm">
619
- Unknown
620
- </Badge>
621
- );
622
- if (cordoned)
623
- return (
624
- <Badge severity="warning" size="sm">
625
- Cordoned
626
- </Badge>
627
- );
614
+ if (lifecycle) {
615
+ return <Badge severity={healthToSeverity(lifecycle.level)} size="sm">{lifecycle.label}</Badge>;
616
+ }
628
617
  return (
629
- <Badge severity="success" size="sm">
630
- Ready
631
- </Badge>
618
+ <WithTooltip tip="Kubernetes readiness only; removal status is unavailable from this Radar.">
619
+ <Badge severity={ready === false ? "error" : ready === undefined ? "neutral" : cordoned ? "warning" : "success"} size="sm">
620
+ {ready === false ? "NotReady (condition)" : ready === undefined ? "Readiness unknown" : cordoned ? "Cordoned" : "Ready (condition)"}
621
+ </Badge>
622
+ </WithTooltip>
632
623
  );
633
624
  }
634
625
 
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react'
2
+ import { PaneLoader, TimelineList, formatAge, type NavigateToResource } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGClusterActivity } from '../../api/cnpg'
4
+ import { Notice } from '../capacity/shared'
5
+ import { Segments } from './shared'
6
+
7
+ const RANGES = [
8
+ { id: '6', label: '6 h' },
9
+ { id: '24', label: '24 h' },
10
+ { id: '168', label: '7 d' },
11
+ ] as const
12
+
13
+ /**
14
+ * Kubernetes events and changes for the Cluster, its instance Pods and the
15
+ * CNPG objects attributed to it — including Backups and declarations deleted
16
+ * since. Attribution of child objects relies on a label Radar records at
17
+ * ingestion, so history older than that is marked incomplete.
18
+ */
19
+ export function CNPGClusterActivity({ namespace, name, onNavigate }: { namespace: string; name: string; onNavigate?: NavigateToResource }) {
20
+ const [hours, setHours] = useState<string>('24')
21
+ const q = useCNPGClusterActivity(namespace, name, Number(hours))
22
+
23
+ return (
24
+ <div className="flex h-full min-h-0 flex-col gap-3 p-4">
25
+ <div className="flex flex-wrap items-center gap-3">
26
+ <Segments label="Range" value={hours} onChange={setHours} options={RANGES.map((r) => ({ id: r.id, label: r.label }))} />
27
+ <span className="text-xs text-theme-text-tertiary">
28
+ Kubernetes events and changes for the Cluster, its instances, Backups, Poolers and declarations.
29
+ </span>
30
+ </div>
31
+ <div className="text-xs text-theme-text-tertiary">
32
+ {q.data?.attributionSince
33
+ ? `The earliest recorded event linking a Backup, Pooler or declaration to this cluster is ${formatAge(q.data.attributionSince)} old. Deleted child objects from before Radar recorded that link are not shown.`
34
+ : 'Radar has not recorded any Backup, Pooler or declaration events linked to this cluster yet, so deleted child objects may be missing.'}
35
+ {' '}Events for kinds you cannot list are omitted.
36
+ </div>
37
+ {q.data?.truncated && <Notice>Showing the most recent events only; narrow the range to see all of them.</Notice>}
38
+ {q.error && !q.data ? (
39
+ <Notice>Activity could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
40
+ ) : !q.data ? (
41
+ <PaneLoader label="Loading activity…" className="h-40" />
42
+ ) : (
43
+ <div className="min-h-0 flex-1">
44
+ <TimelineList events={q.data.events} isLoading={q.isFetching && !q.data} onResourceClick={onNavigate} compact hideRangeSelector />
45
+ </div>
46
+ )}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,62 @@
1
+ import { useCallback } from 'react'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { WorkloadLogsViewer, type WorkloadLogsFetchParams, type WorkloadLogsResult } from '@skyhook-io/k8s-ui'
4
+ import { fetchJSON } from '../../api/client'
5
+ import { getApiBase, getCredentialsMode } from '../../api/config'
6
+ import { useDesktopDownload } from '../../hooks/useDesktopDownload'
7
+ import { useTheme } from '../../context/ThemeContext'
8
+
9
+ function logsPath(namespace: string, name: string) {
10
+ return `/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/logs`
11
+ }
12
+
13
+ function query(params: WorkloadLogsFetchParams, tailDefault?: number) {
14
+ const q = new URLSearchParams()
15
+ if (params.container) q.set('container', params.container)
16
+ const tail = params.tailLines ?? tailDefault
17
+ if (tail) q.set('tailLines', String(tail))
18
+ if (params.sinceSeconds) q.set('sinceSeconds', String(params.sinceSeconds))
19
+ const s = q.toString()
20
+ return s ? `?${s}` : ''
21
+ }
22
+
23
+ /**
24
+ * Logs merged from every instance Pod of a CloudNativePG Cluster. `?pod=`
25
+ * preselects one instance (the fleet's "Logs" action and "Open instance logs").
26
+ */
27
+ export function CNPGClusterLogs({ namespace, name }: { namespace: string; name: string }) {
28
+ const [searchParams] = useSearchParams()
29
+ const pod = searchParams.get('pod')
30
+ const desktopDownload = useDesktopDownload()
31
+ const { theme } = useTheme()
32
+
33
+ const fetchAll = useCallback(
34
+ (params: WorkloadLogsFetchParams) =>
35
+ fetchJSON<WorkloadLogsResult>(`${logsPath(namespace, name)}${query(params)}`, { signal: params.signal }),
36
+ [namespace, name],
37
+ )
38
+ const createStream = useCallback(
39
+ (params: WorkloadLogsFetchParams) =>
40
+ new EventSource(`${getApiBase()}${logsPath(namespace, name)}/stream${query(params, 50)}`, {
41
+ withCredentials: getCredentialsMode() === 'include',
42
+ }),
43
+ [namespace, name],
44
+ )
45
+
46
+ return (
47
+ <div className="h-full">
48
+ <WorkloadLogsViewer
49
+ key={pod ?? ''}
50
+ name={name}
51
+ fetchAll={fetchAll}
52
+ createStream={createStream}
53
+ overrideDownload={desktopDownload}
54
+ forceDark={theme === 'dark' ? true : undefined}
55
+ // Light logs on a dark app are never wanted: dark theme pins the palette,
56
+ // light theme only sets where it starts and leaves the toggle available.
57
+ defaultDark={false}
58
+ initialPods={pod ? [pod] : undefined}
59
+ />
60
+ </div>
61
+ )
62
+ }