@skyhook-io/radar-app 1.9.7 → 1.11.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 (41) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +2 -6
  3. package/src/api/client.ts +138 -6
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +187 -0
  6. package/src/components/home/HomeView.tsx +15 -3
  7. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  8. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  9. package/src/components/home/TopologyPreview.tsx +57 -11
  10. package/src/components/home/mcpToolCatalog.ts +15 -5
  11. package/src/components/resources/CompositeRenderer.tsx +60 -3
  12. package/src/components/resources/ResourcesView.tsx +15 -3
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  15. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  17. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  18. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  19. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  20. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  21. package/src/components/resources/renderers/PodRenderer.tsx +8 -0
  22. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  23. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  24. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  25. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
  27. package/src/components/resources/renderers/index.ts +1 -0
  28. package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
  29. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  30. package/src/components/traffic/TrafficGraph.tsx +155 -63
  31. package/src/components/traffic/TrafficView.tsx +168 -52
  32. package/src/components/traffic/TrafficWizard.tsx +13 -1
  33. package/src/components/traffic/trafficFilters.test.ts +103 -0
  34. package/src/components/traffic/trafficFilters.ts +117 -0
  35. package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
  36. package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
  37. package/src/components/workload/WorkloadView.tsx +55 -7
  38. package/src/utils/navigation.ts +44 -2
  39. package/src/utils/network-policy-navigation.test.ts +68 -0
  40. package/src/utils/topology-selection.test.ts +40 -0
  41. package/src/utils/topology-selection.ts +39 -0
@@ -1,6 +1,7 @@
1
1
  import type { DashboardNetworkPolicyCoverage } from '../../api/client'
2
2
  import { ShieldCheck, ArrowRight } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
+ import { Tooltip } from '../ui/Tooltip'
4
5
 
5
6
  interface NetworkPolicyCoverageCardProps {
6
7
  data: DashboardNetworkPolicyCoverage
@@ -8,9 +9,23 @@ interface NetworkPolicyCoverageCardProps {
8
9
  }
9
10
 
10
11
  export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCoverageCardProps) {
12
+ const hasStagedPolicies = (data.stagedPolicies ?? 0) > 0
13
+ // A staged policy can stage a deletion, so the projected coverage is allowed
14
+ // to be lower than today's. Clamping it would hide exactly the case an
15
+ // operator most needs to see before promoting the staged set.
16
+ const coveredIfStaged = data.coveredWorkloadsIfStaged ?? data.coveredWorkloads
17
+ const stagedDelta = coveredIfStaged - data.coveredWorkloads
11
18
  const percentage = data.totalWorkloads > 0
12
19
  ? Math.round((data.coveredWorkloads / data.totalWorkloads) * 100)
13
20
  : 0
21
+ const percentageIfStaged = data.totalWorkloads > 0
22
+ ? Math.round((coveredIfStaged / data.totalWorkloads) * 100)
23
+ : 0
24
+ const enforcedPercentage = hasStagedPolicies ? Math.min(percentage, percentageIfStaged) : percentage
25
+ // Gated on the same condition as the segment that draws it, so the three
26
+ // widths always sum to the full track even for a host that supplies a
27
+ // projection without any staged policies.
28
+ const stagedDeltaPercentage = hasStagedPolicies ? Math.abs(percentageIfStaged - percentage) : 0
14
29
  const hasPolicies = data.totalPolicies > 0
15
30
  const accentColor = !hasPolicies
16
31
  ? 'text-theme-text-tertiary'
@@ -48,28 +63,56 @@ export function NetworkPolicyCoverageCard({ data, onNavigate }: NetworkPolicyCov
48
63
  <>
49
64
  <div className="flex items-center gap-3 w-full">
50
65
  <div className="flex-1 h-3 rounded-full overflow-hidden bg-theme-hover flex">
51
- {data.coveredWorkloads > 0 && (
66
+ {enforcedPercentage > 0 && (
52
67
  <div
53
68
  className="h-full bg-green-500"
54
- style={{ width: `${percentage}%` }}
69
+ style={{ width: `${enforcedPercentage}%` }}
55
70
  />
56
71
  )}
57
- {data.totalWorkloads - data.coveredWorkloads > 0 && (
72
+ {hasStagedPolicies && stagedDeltaPercentage > 0 && (
73
+ // The width belongs on the flex child; the tooltip wrapper
74
+ // inside it carries the hover target.
75
+ <div className="h-full" style={{ width: `${stagedDeltaPercentage}%` }}>
76
+ <Tooltip
77
+ content={stagedDelta > 0
78
+ ? `${stagedDelta} more workloads covered if staged policies are applied`
79
+ : `${-stagedDelta} workloads lose coverage if staged policies are applied`}
80
+ wrapperClassName="!block h-full w-full"
81
+ >
82
+ <div
83
+ className={clsx('h-full w-full', stagedDelta > 0 ? 'text-yellow-500' : 'text-red-500')}
84
+ style={{
85
+ backgroundImage: 'repeating-linear-gradient(135deg, currentColor 0, currentColor 2px, transparent 2px, transparent 5px)',
86
+ }}
87
+ />
88
+ </Tooltip>
89
+ </div>
90
+ )}
91
+ {100 - enforcedPercentage - stagedDeltaPercentage > 0 && (
58
92
  <div
59
93
  className="h-full bg-theme-hover"
60
- style={{ width: `${100 - percentage}%` }}
94
+ style={{ width: `${100 - enforcedPercentage - stagedDeltaPercentage}%` }}
61
95
  />
62
96
  )}
63
97
  </div>
64
- <span className={clsx('text-sm font-semibold tabular-nums', accentColor)}>
65
- {percentage}%
66
- </span>
98
+ <div className="flex shrink-0 flex-col items-end tabular-nums">
99
+ <span className={clsx('text-sm font-semibold', accentColor)}>{percentage}%</span>
100
+ {hasStagedPolicies && (
101
+ <span className="text-[10px] text-theme-text-tertiary">({percentageIfStaged}% if staged applied)</span>
102
+ )}
103
+ </div>
67
104
  </div>
68
105
 
69
106
  <div className="grid grid-cols-1 gap-y-2 mt-4 w-full">
70
107
  <StatRow label="Policies" value={data.totalPolicies} />
71
108
  <StatRow label="Covered workloads" value={data.coveredWorkloads} total={data.totalWorkloads} />
109
+ {hasStagedPolicies && (
110
+ <StatRow label="Covered if staged" value={coveredIfStaged} total={data.totalWorkloads} />
111
+ )}
72
112
  <StatRow label="Uncovered workloads" value={data.totalWorkloads - data.coveredWorkloads} warn />
113
+ {hasStagedPolicies && (
114
+ <StatRow label="Uncovered if staged" value={data.totalWorkloads - coveredIfStaged} warn />
115
+ )}
73
116
  </div>
74
117
  </>
75
118
  )}
@@ -1,12 +1,11 @@
1
1
  import { useMemo } from 'react'
2
2
  import type { Topology } from '../../types'
3
- import type { DashboardTopologySummary } from '../../api/client'
4
3
  import { Network, ArrowRight } from 'lucide-react'
5
4
  import { clsx } from 'clsx'
6
5
 
7
6
  interface TopologyPreviewProps {
8
7
  topology: Topology | null
9
- summary: DashboardTopologySummary
8
+ namespaceSelected: boolean
10
9
  onNavigate: () => void
11
10
  }
12
11
 
@@ -133,9 +132,30 @@ const kindDotColors: Record<string, string> = {
133
132
  ReplicaSet: 'bg-green-400', HPA: 'bg-pink-500', PVC: 'bg-cyan-400',
134
133
  }
135
134
 
136
- export function TopologyPreview({ topology, summary, onNavigate }: TopologyPreviewProps) {
135
+ export function TopologyPreview({ topology, namespaceSelected, onNavigate }: TopologyPreviewProps) {
137
136
  const stats = useTopologyStats(topology)
138
137
 
138
+ // A large cluster with no namespace filter gets an empty graph carrying this
139
+ // flag instead of a build. There is no graph to preview and there never will
140
+ // be until the user filters, so the card says that rather than counting a
141
+ // refusal as zero.
142
+ //
143
+ // With a pick already active the same flag means something else entirely: SSE
144
+ // only learns to filter server-side after a flagged frame arrives, so a
145
+ // cluster-wide refusal still reaches a viewer whose namespace is on its way
146
+ // to the server. That resolves on its own, and asking for what the viewer
147
+ // already chose is the wrong thing to say while it does.
148
+ const needsNamespaceFilter = topology?.requiresNamespaceFilter === true && !namespaceSelected
149
+
150
+ // The graph this card draws is the only source for its caption. Node count is
151
+ // not the test for whether a frame counts — the stream holds topology at null
152
+ // until one lands and resets it there on reconnect and context switch, so a
153
+ // delivered graph with no nodes is an empty scope and an honest zero.
154
+ const counts = useMemo(() => {
155
+ if (!topology || topology.requiresNamespaceFilter) return null
156
+ return { nodeCount: topology.nodes.length, edgeCount: topology.edges.length }
157
+ }, [topology])
158
+
139
159
  return (
140
160
  <button
141
161
  onClick={onNavigate}
@@ -147,13 +167,27 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
147
167
  <Network className="w-4 h-4 text-theme-text-tertiary" />
148
168
  <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Topology</span>
149
169
  </div>
150
- <span className="text-[11px] text-theme-text-tertiary">
151
- {summary.nodeCount} resources &middot; {summary.edgeCount} conn
152
- </span>
170
+ {counts ? (
171
+ <span className="text-[11px] text-theme-text-tertiary">
172
+ {counts.nodeCount} resources &middot; {counts.edgeCount} conn
173
+ </span>
174
+ ) : needsNamespaceFilter ? null : (
175
+ <span className="h-3 w-28 rounded bg-theme-text-tertiary/20 animate-pulse" />
176
+ )}
153
177
  </div>
154
178
 
155
179
  {/* Stats (left) + Schematic (right) */}
156
180
  <div className="flex-1 flex items-stretch min-h-0 px-3 py-1.5 gap-2">
181
+ {needsNamespaceFilter ? (
182
+ <div className="flex-1 flex items-center justify-center px-4">
183
+ <p className="text-[11px] leading-relaxed text-theme-text-tertiary text-center">
184
+ This cluster is too large to graph every namespace.
185
+ <br />
186
+ <span className="text-theme-text-secondary font-medium">Select a namespace</span> to view the topology.
187
+ </p>
188
+ </div>
189
+ ) : (
190
+ <>
157
191
  {/* Left: compact stats */}
158
192
  <div className="flex flex-col justify-center gap-0.5 min-w-0 w-[105px] shrink-0">
159
193
  {stats ? (
@@ -187,16 +221,26 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
187
221
  )}
188
222
  </>
189
223
  ) : (
190
- // Show summary-based placeholder while full topology loads via SSE
224
+ // No graph yet, or one with nothing in scope. Until a frame lands
225
+ // there is no honest number here — a zero would read as an empty
226
+ // cluster, so pulse instead.
191
227
  <div className="flex flex-col gap-0.5">
192
228
  <div className="flex items-center gap-1.5 text-[10px] leading-tight">
193
- <span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
194
- <span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{summary.nodeCount}</span>
229
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-blue-400' : 'bg-theme-text-tertiary/30 animate-pulse')} />
230
+ {counts ? (
231
+ <span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.nodeCount}</span>
232
+ ) : (
233
+ <span className="h-3 w-5 rounded bg-theme-text-tertiary/20 animate-pulse" />
234
+ )}
195
235
  <span className="text-theme-text-tertiary">resources</span>
196
236
  </div>
197
237
  <div className="flex items-center gap-1.5 text-[10px] leading-tight">
198
- <span className="w-1.5 h-1.5 rounded-full bg-theme-text-tertiary shrink-0" />
199
- <span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{summary.edgeCount}</span>
238
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', counts ? 'bg-theme-text-tertiary' : 'bg-theme-text-tertiary/30 animate-pulse')} />
239
+ {counts ? (
240
+ <span className="text-theme-text-primary font-medium w-5 text-right tabular-nums">{counts.edgeCount}</span>
241
+ ) : (
242
+ <span className="h-3 w-5 rounded bg-theme-text-tertiary/10 animate-pulse" />
243
+ )}
200
244
  <span className="text-theme-text-tertiary">connections</span>
201
245
  </div>
202
246
  </div>
@@ -207,6 +251,8 @@ export function TopologyPreview({ topology, summary, onNavigate }: TopologyPrevi
207
251
  <div className="flex-1 flex items-center min-w-0">
208
252
  <TopologySchematic />
209
253
  </div>
254
+ </>
255
+ )}
210
256
  </div>
211
257
 
212
258
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
@@ -52,13 +52,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
52
52
  },
53
53
  {
54
54
  name: 'get_resource',
55
- desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics data.',
55
+ desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics/change/revision data.',
56
56
  params: [
57
57
  { arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
58
58
  { arg: 'name', required: true, desc: 'resource name' },
59
59
  { arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
60
60
  { arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
61
- { arg: 'include', desc: 'events, metrics' },
61
+ { arg: 'include', desc: 'events, metrics, changes, revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
62
62
  { arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
63
63
  ],
64
64
  },
@@ -260,14 +260,24 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
260
260
  {
261
261
  name: 'manage_workload',
262
262
  write: true,
263
- desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision.',
263
+ desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision. Rollout rollback re-runs every canary step — pair it with manage_rollout promote-full, or abort instead.',
264
264
  params: [
265
265
  { arg: 'action', required: true, desc: 'restart, scale, or rollback' },
266
- { arg: 'kind', required: true, desc: 'deployment, statefulset, or daemonset' },
266
+ { arg: 'kind', required: true, desc: 'deployment, statefulset, daemonset, or rollout' },
267
267
  { arg: 'namespace', required: true, desc: 'workload namespace' },
268
268
  { arg: 'name', required: true, desc: 'workload name' },
269
269
  { arg: 'replicas', desc: 'target replica count (for scale)' },
270
- { arg: 'revision', desc: 'target revision (for rollback)' },
270
+ { arg: 'revision', desc: 'target revision (for rollback); list them with get_resource include=revisions' },
271
+ ],
272
+ },
273
+ {
274
+ name: 'manage_rollout',
275
+ write: true,
276
+ desc: 'Control an Argo Rollout progressive delivery: abort reverts traffic to the last stable version immediately, retry clears an abort, promote advances one step, promote-full skips all remaining steps/pauses/analysis, skip-step advances exactly one canary step. A Rollout paused on an inconclusive analysis names its AnalysisRun in status — read that first.',
277
+ params: [
278
+ { arg: 'action', required: true, desc: 'abort, retry, promote, promote-full, or skip-step' },
279
+ { arg: 'namespace', required: true, desc: 'rollout namespace' },
280
+ { arg: 'name', required: true, desc: 'rollout name' },
271
281
  ],
272
282
  },
273
283
  {
@@ -1,11 +1,14 @@
1
1
  import { useMemo } from 'react'
2
- import { useQueries } from '@tanstack/react-query'
2
+ import { useQueries, useQuery } from '@tanstack/react-query'
3
3
  import {
4
4
  CompositeRenderer as BaseCompositeRenderer,
5
5
  type ComposedRefStatus,
6
+ type BoundXRStatus,
6
7
  } from '@skyhook-io/k8s-ui/components/resources/renderers/CompositeRenderer'
7
8
  import {
8
9
  getCrossplaneResourceRefs,
10
+ getBoundXRRef,
11
+ isClaim,
9
12
  type CrossplaneResourceRef,
10
13
  } from '@skyhook-io/k8s-ui/components/resources/resource-utils-crossplane'
11
14
  import { getResourceStatus } from '@skyhook-io/k8s-ui'
@@ -41,7 +44,53 @@ function groupFromApiVersion(apiVersion: string | undefined): string {
41
44
  * it yet) is a normal state for a freshly-applied Composite, not a failure.
42
45
  */
43
46
  export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps) {
44
- const refs = useMemo<CrossplaneResourceRef[]>(() => getCrossplaneResourceRefs(data), [data])
47
+ // A v1 Claim carries only a singular spec.resourceRef to its bound XR; the
48
+ // composed-resource refs live on that XR. Follow the ref, fetch the XR, and
49
+ // read its refs — otherwise the claim panel reads its own (absent) resourceRefs
50
+ // and shows "No composed resources" for a claim that has composed plenty.
51
+ const boundXRRef = useMemo(() => (isClaim(data) ? getBoundXRRef(data) : null), [data])
52
+ const boundXRQuery = useQuery({
53
+ queryKey: [
54
+ 'bound-xr',
55
+ groupFromApiVersion(boundXRRef?.apiVersion),
56
+ boundXRRef?.kind ?? '',
57
+ boundXRRef?.namespace ?? '',
58
+ boundXRRef?.name ?? '',
59
+ ],
60
+ queryFn: async () => {
61
+ const ns = boundXRRef!.namespace || '_'
62
+ const plural = kindToPlural(boundXRRef!.kind)
63
+ const group = groupFromApiVersion(boundXRRef!.apiVersion)
64
+ const query = group ? `?group=${encodeURIComponent(group)}` : ''
65
+ return fetchJSON<{ resource: any }>(`/resources/${plural}/${ns}/${boundXRRef!.name}${query}`)
66
+ },
67
+ staleTime: 30000,
68
+ retry: false,
69
+ enabled: Boolean(boundXRRef?.kind && boundXRRef?.name),
70
+ })
71
+
72
+ const refs = useMemo<CrossplaneResourceRef[]>(() => {
73
+ // For a claim, refs come from the bound XR once it's fetched; for an XR/MR
74
+ // viewed directly, they're on `data` itself.
75
+ if (boundXRRef) return getCrossplaneResourceRefs(boundXRQuery.data?.resource)
76
+ return getCrossplaneResourceRefs(data)
77
+ }, [boundXRRef, boundXRQuery.data, data])
78
+
79
+ // Surface the bound-XR fetch state so the empty composed-resources branch can
80
+ // tell "XR still loading / unreadable" apart from "claim genuinely has none".
81
+ const boundXRStatus = useMemo<BoundXRStatus | undefined>(() => {
82
+ if (!boundXRRef) return undefined
83
+ if (boundXRQuery.isLoading) return { loading: true }
84
+ if (boundXRQuery.isError) {
85
+ if (boundXRQuery.error instanceof ApiError && boundXRQuery.error.status === 404) {
86
+ return { missing: true }
87
+ }
88
+ const message =
89
+ boundXRQuery.error instanceof Error ? boundXRQuery.error.message : 'Failed to fetch bound composite'
90
+ return { error: true, errorMessage: message }
91
+ }
92
+ return undefined
93
+ }, [boundXRRef, boundXRQuery.isLoading, boundXRQuery.isError, boundXRQuery.error])
45
94
 
46
95
  const queries = useQueries({
47
96
  queries: refs.map(ref => {
@@ -97,5 +146,13 @@ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps)
97
146
  return map
98
147
  }, [refs, queries])
99
148
 
100
- return <BaseCompositeRenderer data={data} onNavigate={onNavigate} composedRefStatuses={composedRefStatuses} />
149
+ return (
150
+ <BaseCompositeRenderer
151
+ data={data}
152
+ onNavigate={onNavigate}
153
+ composedRefStatuses={composedRefStatuses}
154
+ composedRefs={refs}
155
+ boundXRStatus={boundXRStatus}
156
+ />
157
+ )
101
158
  }
@@ -49,6 +49,13 @@ const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
49
49
  'apps/ReplicaSet',
50
50
  'discovery.k8s.io/EndpointSlice',
51
51
  ])
52
+ // Kinds the server slims with ?include=summary: rows carry only the fields the
53
+ // table reads (5–8x smaller for pods — a production pod is ~9–13KB raw,
54
+ // ~1–2.5KB slimmed), so the browser holds far more rows before the guard must
55
+ // block. The detail drawer is unaffected — row clicks always refetch the full
56
+ // object.
57
+ const SUMMARY_LIST_KINDS = new Set(['Pod', 'apps/ReplicaSet'])
58
+ const SUMMARY_LIST_LIMIT = 50000
52
59
 
53
60
  const deniedWorkloadWrites: WorkloadWritePermissions = {
54
61
  deployments: false,
@@ -189,13 +196,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
189
196
  const selectedCountKnown = selectedCountKey ? hasResourceCount(countsData?.counts, selectedCountKey) : false
190
197
  const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
191
198
  const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
199
+ const selectedKindSummaryServed = SUMMARY_LIST_KINDS.has(selectedCountKey)
200
+ const selectedKindRowLimit = selectedKindSummaryServed ? SUMMARY_LIST_LIMIT : LARGE_RESOURCE_LIST_LIMIT
192
201
  const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
193
- const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT))
202
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > selectedKindRowLimit))
194
203
  const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
195
204
  const podCount = countsData?.counts.Pod
196
205
  const podCountKnown = hasResourceCount(countsData?.counts, 'Pod')
197
206
  const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
198
- const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
207
+ // Metrics rows are ~100B each (ns/name + cpu/mem), so they track the pods
208
+ // guard rather than the raw-list limit.
209
+ const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= SUMMARY_LIST_LIMIT
199
210
  const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
200
211
  const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
201
212
  // Node metrics back the Nodes table and, for the Pods table, the pod-vs-node
@@ -210,7 +221,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
210
221
  kind: selectedKind.name,
211
222
  count: selectedCountUnavailable ? undefined : selectedCount,
212
223
  reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
213
- limit: LARGE_RESOURCE_LIST_LIMIT,
224
+ limit: selectedKindRowLimit,
214
225
  namespaces,
215
226
  }
216
227
  : null
@@ -223,6 +234,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
223
234
  const params = new URLSearchParams()
224
235
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
225
236
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
237
+ if (selectedKindSummaryServed) params.set('include', 'summary')
226
238
  const startedAt = performance.now()
227
239
  debugNamespaceLog('resources:selected-kind-fetch-start', {
228
240
  kind: selectedKind.name,
@@ -1 +1,116 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGClusterRenderer'
1
+ import { Database } from 'lucide-react'
2
+ import { CNPGClusterRenderer as BaseCNPGClusterRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGClusterRenderer'
3
+ import { Section, RelationshipGroup, getCNPGDeclarativeStatus } from '@skyhook-io/k8s-ui'
4
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
5
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
6
+ import { useResources } from '../../../api/client'
7
+
8
+ const CNPG_GROUP = 'postgresql.cnpg.io'
9
+
10
+ /**
11
+ * Host wrapper adding the reverse lookup: which Databases, Publications and
12
+ * Subscriptions are declared against this cluster.
13
+ *
14
+ * Each of those objects links forward to its cluster. Without this the
15
+ * relationship is one-way, so an operator reading a cluster has no way to see
16
+ * that three databases were declared against it and one of them never applied.
17
+ */
18
+ export function CNPGClusterRenderer({
19
+ data,
20
+ onNavigate,
21
+ }: {
22
+ data: any
23
+ onNavigate?: (ref: ResourceRef) => void
24
+ }) {
25
+ const namespace = data?.metadata?.namespace ?? ''
26
+ const name = data?.metadata?.name ?? ''
27
+ const enabled = !!namespace && !!name
28
+
29
+ const databases = useResources<any>('databases', namespace, CNPG_GROUP, { enabled })
30
+ const publications = useResources<any>('publications', namespace, CNPG_GROUP, { enabled })
31
+ const subscriptions = useResources<any>('subscriptions', namespace, CNPG_GROUP, { enabled })
32
+
33
+ const mine = (items: any[] | undefined) =>
34
+ (items ?? []).filter((o) => o?.spec?.cluster?.name === name)
35
+
36
+ const dbs = mine(databases.data)
37
+ const pubs = mine(publications.data)
38
+ const subs = mine(subscriptions.data)
39
+ const total = dbs.length + pubs.length + subs.length
40
+
41
+ const loading = databases.isLoading || publications.isLoading || subscriptions.isLoading
42
+ // Three independent lookups, so "it failed" is not all-or-nothing: two can
43
+ // return rows while the third does not, and every count below is then drawn
44
+ // from a population smaller than the real one.
45
+ const lookupErrors = [databases.error, publications.error, subscriptions.error]
46
+ const failed = lookupErrors.some(Boolean)
47
+
48
+ // Not applied is the state worth surfacing here: the manifest exists and
49
+ // PostgreSQL does not have the object. Counting it on the cluster is how an
50
+ // operator notices without opening each one.
51
+ const notApplied = [...dbs, ...pubs, ...subs].filter(
52
+ (o) => getCNPGDeclarativeStatus(o).level === 'unhealthy',
53
+ ).length
54
+
55
+ const refs = (items: any[], kind: string) =>
56
+ items.map((o) => ({
57
+ kind,
58
+ namespace: o?.metadata?.namespace ?? '',
59
+ name: o?.metadata?.name ?? '',
60
+ group: CNPG_GROUP,
61
+ }))
62
+
63
+ return (
64
+ <>
65
+ <BaseCNPGClusterRenderer
66
+ data={data}
67
+ onNavigate={onNavigate}
68
+ declared={
69
+ <Section title="Declared Objects" icon={Database} defaultExpanded={notApplied > 0}>
70
+ {loading ? (
71
+ <div className="text-sm text-theme-text-tertiary">Looking for declared objects…</div>
72
+ ) : total === 0 ? (
73
+ // "None declared" and "could not check" are different answers, and
74
+ // only one of them means the cluster has no declarative objects.
75
+ failed ? (
76
+ <LookupFailureNote
77
+ errors={lookupErrors}
78
+ what="which objects are declared against this cluster"
79
+ />
80
+ ) : (
81
+ <div className="text-sm text-theme-text-tertiary">
82
+ No Database, Publication or Subscription is declared against this cluster.
83
+ </div>
84
+ )
85
+ ) : (
86
+ <div className="space-y-3">
87
+ {notApplied > 0 && (
88
+ <div className="text-sm text-warning-text">
89
+ {`${notApplied} of ${total} could not be applied to PostgreSQL — the manifest exists, the object does not.`}
90
+ </div>
91
+ )}
92
+ {/* Rows arrived, so the failure is not "nothing found" — it is
93
+ that both counts above are drawn from less than the cluster
94
+ has, which is the reading they would otherwise invite. */}
95
+ <LookupFailureNote
96
+ errors={lookupErrors}
97
+ what="which objects are declared against this cluster"
98
+ incomplete
99
+ />
100
+ {dbs.length > 0 && (
101
+ <RelationshipGroup label="Databases" refs={refs(dbs, 'Database')} onNavigate={onNavigate} />
102
+ )}
103
+ {pubs.length > 0 && (
104
+ <RelationshipGroup label="Publications" refs={refs(pubs, 'Publication')} onNavigate={onNavigate} />
105
+ )}
106
+ {subs.length > 0 && (
107
+ <RelationshipGroup label="Subscriptions" refs={refs(subs, 'Subscription')} onNavigate={onNavigate} />
108
+ )}
109
+ </div>
110
+ )}
111
+ </Section>
112
+ }
113
+ />
114
+ </>
115
+ )
116
+ }