@skyhook-io/radar-app 1.10.0 → 1.12.2

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -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
+ }
@@ -0,0 +1,227 @@
1
+ import { Share2 } from 'lucide-react'
2
+ import {
3
+ CNPGDatabaseRenderer as BaseDatabase,
4
+ CNPGPublicationRenderer as BasePublication,
5
+ CNPGSubscriptionRenderer as BaseSubscription,
6
+ } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGDeclarativeRenderer'
7
+ import { ResourceLink, Section, RelationshipGroup } from '@skyhook-io/k8s-ui'
8
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
9
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
10
+ import { useResources } from '../../../api/client'
11
+ import { splitCNPGDeclarativeByApplied } from '../resource-utils-cnpg'
12
+
13
+ const CNPG_GROUP = 'postgresql.cnpg.io'
14
+
15
+ /**
16
+ * Resolves a PostgreSQL-side name back to the CR that declares it.
17
+ *
18
+ * A Publication says it lives in database `demo_app`; the Database CR is called
19
+ * `demo-app` and carries `spec.name: demo_app`. The two are not the same string,
20
+ * so the page can name the database and still leave the reader with no way to
21
+ * open it — the dead end this exists to close.
22
+ *
23
+ * Returns undefined when nothing matches, and the caller falls back to plain
24
+ * text: an unresolved name is still the truth, it just isn't a link.
25
+ */
26
+ function useDeclaredRef(
27
+ plural: 'databases' | 'publications',
28
+ namespace: string,
29
+ cluster: string | undefined,
30
+ pgName: string | undefined,
31
+ onNavigate?: (ref: ResourceRef) => void,
32
+ ) {
33
+ const enabled = !!namespace && !!cluster && !!pgName
34
+ const { data } = useResources<any>(plural, namespace, CNPG_GROUP, { enabled })
35
+ if (!enabled) return undefined
36
+ const match = (data ?? []).find(
37
+ (o: any) => o?.spec?.cluster?.name === cluster && o?.spec?.name === pgName,
38
+ )
39
+ if (!match) return undefined
40
+ return (
41
+ <ResourceLink
42
+ name={pgName as string}
43
+ kind={plural}
44
+ namespace={match?.metadata?.namespace ?? namespace}
45
+ group={CNPG_GROUP}
46
+ label={pgName}
47
+ onNavigate={
48
+ onNavigate
49
+ ? () =>
50
+ onNavigate({
51
+ kind: plural === 'databases' ? 'Database' : 'Publication',
52
+ namespace: match?.metadata?.namespace ?? namespace,
53
+ name: match?.metadata?.name ?? '',
54
+ group: CNPG_GROUP,
55
+ })
56
+ : undefined
57
+ }
58
+ />
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Database, with the objects that replicate out of it.
64
+ *
65
+ * The link only exists in one direction in the API: a Publication names its
66
+ * database, a Database names nothing. Without this, "what publishes from here"
67
+ * has no answer on the page that raises the question.
68
+ */
69
+ export function CNPGDatabaseRenderer({
70
+ data,
71
+ onNavigate,
72
+ }: {
73
+ data: any
74
+ onNavigate?: (ref: ResourceRef) => void
75
+ }) {
76
+ const ns = data?.metadata?.namespace ?? ''
77
+ const cluster = data?.spec?.cluster?.name
78
+ const dbname = data?.spec?.name
79
+ const enabled = !!ns && !!cluster && !!dbname
80
+
81
+ const publications = useResources<any>('publications', ns, CNPG_GROUP, { enabled })
82
+ const subscriptions = useResources<any>('subscriptions', ns, CNPG_GROUP, { enabled })
83
+
84
+ const inThisDatabase = (o: any) =>
85
+ o?.spec?.cluster?.name === cluster && o?.spec?.dbname === dbname
86
+ const pubs = (publications.data ?? []).filter(inThisDatabase)
87
+ const subs = (subscriptions.data ?? []).filter(inThisDatabase)
88
+
89
+ const toRef = (kind: 'Publication' | 'Subscription') => (o: any) => ({
90
+ kind,
91
+ namespace: o?.metadata?.namespace ?? ns,
92
+ name: o?.metadata?.name ?? '',
93
+ group: CNPG_GROUP,
94
+ })
95
+
96
+ // Three states, not two — the same distinction the status badge and the issue
97
+ // detector make. Applied is replicating. `applied: false` is a failed apply
98
+ // the operator can explain. ABSENT is not yet reconciled, and calling that
99
+ // "exists in Kubernetes and not in PostgreSQL" condemns every object in its
100
+ // first seconds.
101
+ const p = splitCNPGDeclarativeByApplied(pubs)
102
+ const sub = splitCNPGDeclarativeByApplied(subs)
103
+ const tagged = (list: any[], kind: 'Publication' | 'Subscription') =>
104
+ list.map((o: any) => ({ o, kind }))
105
+ const livePubs = p.applied
106
+ const liveSubs = sub.applied
107
+ const notApplied = [...tagged(p.notApplied, 'Publication'), ...tagged(sub.notApplied, 'Subscription')]
108
+ const pending = [...tagged(p.pending, 'Publication'), ...tagged(sub.pending, 'Subscription')]
109
+
110
+ const loading = publications.isLoading || subscriptions.isLoading
111
+ // Two independent lookups: one can return rows while the other does not, and
112
+ // the groups below would then describe half the replication as all of it.
113
+ const lookupErrors = [publications.error, subscriptions.error]
114
+ const failed = lookupErrors.some(Boolean)
115
+
116
+ return (
117
+ <BaseDatabase
118
+ data={data}
119
+ onNavigate={onNavigate}
120
+ usedBy={
121
+ <Section title="Replication" icon={Share2} defaultExpanded>
122
+ {loading ? (
123
+ <div className="text-sm text-theme-text-tertiary">Looking for publications…</div>
124
+ ) : pubs.length === 0 && subs.length === 0 ? (
125
+ failed ? (
126
+ <LookupFailureNote
127
+ errors={lookupErrors}
128
+ what="what replicates out of this database"
129
+ />
130
+ ) : (
131
+ <div className="text-sm text-theme-text-tertiary">
132
+ Nothing publishes from or subscribes to this database.
133
+ </div>
134
+ )
135
+ ) : (
136
+ <div className="space-y-3">
137
+ {/* Rows are showing, so the failure means this list is short —
138
+ not that nothing replicates. */}
139
+ <LookupFailureNote
140
+ errors={lookupErrors}
141
+ what="what replicates out of this database"
142
+ incomplete
143
+ />
144
+ {livePubs.length > 0 && (
145
+ <RelationshipGroup
146
+ label="Publishes from here"
147
+ refs={livePubs.map(toRef('Publication'))}
148
+ onNavigate={onNavigate}
149
+ />
150
+ )}
151
+ {liveSubs.length > 0 && (
152
+ <RelationshipGroup
153
+ label="Subscribes into here"
154
+ refs={liveSubs.map(toRef('Subscription'))}
155
+ onNavigate={onNavigate}
156
+ />
157
+ )}
158
+ {notApplied.length > 0 && (
159
+ <div className="space-y-1.5">
160
+ <RelationshipGroup
161
+ label="Declared, but not replicating"
162
+ refs={notApplied.map(({ o, kind }) => toRef(kind)(o))}
163
+ onNavigate={onNavigate}
164
+ />
165
+ <div className="text-xs text-warning-text">
166
+ {notApplied.length === 1
167
+ ? 'This exists in Kubernetes and not in PostgreSQL, so no data moves through it. Open it for the operator’s reason.'
168
+ : 'These exist in Kubernetes and not in PostgreSQL, so no data moves through them. Open one for the operator’s reason.'}
169
+ </div>
170
+ </div>
171
+ )}
172
+ {pending.length > 0 && (
173
+ <div className="space-y-1.5">
174
+ <RelationshipGroup
175
+ label="Not reconciled yet"
176
+ refs={pending.map(({ o, kind }) => toRef(kind)(o))}
177
+ onNavigate={onNavigate}
178
+ />
179
+ <div className="text-xs text-theme-text-secondary">
180
+ The operator has not reported on these yet. They have not failed.
181
+ </div>
182
+ </div>
183
+ )}
184
+ </div>
185
+ )}
186
+ </Section>
187
+ }
188
+ />
189
+ )
190
+ }
191
+
192
+ export function CNPGPublicationRenderer({
193
+ data,
194
+ onNavigate,
195
+ }: {
196
+ data: any
197
+ onNavigate?: (ref: ResourceRef) => void
198
+ }) {
199
+ const ns = data?.metadata?.namespace ?? ''
200
+ const cluster = data?.spec?.cluster?.name
201
+ const database = useDeclaredRef('databases', ns, cluster, data?.spec?.dbname, onNavigate)
202
+ return <BasePublication data={data} onNavigate={onNavigate} links={{ database }} />
203
+ }
204
+
205
+ export function CNPGSubscriptionRenderer({
206
+ data,
207
+ onNavigate,
208
+ }: {
209
+ data: any
210
+ onNavigate?: (ref: ResourceRef) => void
211
+ }) {
212
+ const ns = data?.metadata?.namespace ?? ''
213
+ const cluster = data?.spec?.cluster?.name
214
+ const database = useDeclaredRef('databases', ns, cluster, data?.spec?.dbname, onNavigate)
215
+ // The publication a subscription reads from lives on the *upstream* cluster in
216
+ // a real topology. Resolving locally is right for the single-cluster demo and
217
+ // simply finds nothing otherwise, which falls back to plain text rather than
218
+ // linking to the wrong object.
219
+ const publication = useDeclaredRef(
220
+ 'publications',
221
+ ns,
222
+ cluster,
223
+ data?.spec?.publicationName,
224
+ onNavigate,
225
+ )
226
+ return <BaseSubscription data={data} onNavigate={onNavigate} links={{ database, publication }} />
227
+ }
@@ -0,0 +1,123 @@
1
+ import { Boxes } from 'lucide-react'
2
+ import { CNPGImageCatalogRenderer as BaseImageCatalog } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGDeclarativeRenderer'
3
+ import { Section, RelationshipGroup } from '@skyhook-io/k8s-ui'
4
+ import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
5
+ import { getCNPGImageCatalogEntries } from '../resource-utils-cnpg'
6
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
7
+ import { useCNPGCatalogUsers } from '../../../api/policy'
8
+
9
+ const CNPG_GROUP = 'postgresql.cnpg.io'
10
+
11
+ /**
12
+ * Host wrapper adding the reverse lookup: which clusters are pinned to this
13
+ * catalog.
14
+ *
15
+ * This is the diagnosis path. A cluster that references a catalog lacking its
16
+ * major version reports "incomplete or invalid image catalog" and stops; the
17
+ * catalog is where you check what it offers, and from here you need to see who
18
+ * depends on it before changing anything.
19
+ */
20
+ export function CNPGImageCatalogRenderer({
21
+ data,
22
+ onNavigate,
23
+ }: {
24
+ data: any
25
+ onNavigate?: (ref: ResourceRef) => void
26
+ }) {
27
+ const kind = data?.kind
28
+ const name = data?.metadata?.name ?? ''
29
+ const namespace = data?.metadata?.namespace ?? ''
30
+ // A ClusterImageCatalog is cluster-scoped and referenceable from any namespace,
31
+ // so the answer's scope is not this resource's. Read server-side rather than
32
+ // filtering a client-side list: omitting the namespace there means "the
33
+ // caller's namespace view filter", which would answer a question about the
34
+ // whole cluster from whichever namespaces they happen to be showing.
35
+ const scoped = kind === 'ImageCatalog'
36
+ const clusters = useCNPGCatalogUsers(name, scoped ? namespace : '', !!name)
37
+
38
+ const users = clusters.data?.clusters ?? []
39
+
40
+ // A cluster asking for a major this catalog does not carry is the failure the
41
+ // catalog page exists to explain, and it is invisible from the cluster side —
42
+ // there the reference looks fine.
43
+ const majors = new Set(getCNPGImageCatalogEntries(data).map((e) => e.major))
44
+ const unmet = users.filter((c) => !majors.has(c.major as number))
45
+ const met = users.filter((c) => majors.has(c.major as number))
46
+
47
+ const toRef = (c: { namespace: string; name: string }) => ({
48
+ kind: 'Cluster',
49
+ namespace: c.namespace,
50
+ name: c.name,
51
+ group: CNPG_GROUP,
52
+ })
53
+
54
+ return (
55
+ <BaseImageCatalog
56
+ data={data}
57
+ usedBy={
58
+ <Section title="Used By" icon={Boxes} defaultExpanded>
59
+ {clusters.isLoading ? (
60
+ <div className="text-sm text-theme-text-tertiary">Looking for clusters…</div>
61
+ ) : users.length === 0 ? (
62
+ clusters.error ? (
63
+ <LookupFailureNote
64
+ errors={[clusters.error]}
65
+ what="which clusters use this catalog"
66
+ />
67
+ ) : (
68
+ <div className="text-sm text-theme-text-tertiary">
69
+ {/* "Changing it affects nothing" is a claim about the cluster,
70
+ and this search does not cover one. A ClusterImageCatalog is
71
+ referenced from any namespace, but the lookup that answers
72
+ this falls back to the reader's namespace view filter, so a
73
+ filtered reader is told an edit is safe on the strength of
74
+ the namespaces they happen to be looking at. The namespaced
75
+ ImageCatalog has no such gap — its users can only be in its
76
+ own namespace, which is exactly what was searched. */}
77
+ {scoped
78
+ ? 'No cluster in this namespace is pinned to this catalog. Changing it affects nothing here today.'
79
+ : 'No cluster in view is pinned to this catalog. Clusters in namespaces you are not currently showing are not covered by this check.'}
80
+ </div>
81
+ )
82
+ ) : (
83
+ <div className="space-y-3">
84
+ {met.length > 0 && (
85
+ <RelationshipGroup label="Clusters" refs={met.map(toRef)} onNavigate={onNavigate} />
86
+ )}
87
+ {unmet.length > 0 && (
88
+ <div className="space-y-1.5">
89
+ <RelationshipGroup
90
+ label="Pinned to a version this catalog does not list"
91
+ refs={unmet.map(toRef)}
92
+ onNavigate={onNavigate}
93
+ />
94
+ {unmet.some((c) => !!c.image) && (
95
+ <div className="text-xs text-theme-text-secondary">
96
+ {`Running now: ${unmet
97
+ .filter((c) => !!c.image)
98
+ .map((c) => `${c.name} on ${c.image}`)
99
+ .join(', ')}`}
100
+ </div>
101
+ )}
102
+ <div className="text-xs text-warning-text">
103
+ {/* What the catalog can prove, and no further. A cluster
104
+ that already resolved an image keeps running on it, so
105
+ "will not start" sends a responder hunting for
106
+ crash-looping pods that are not there — the failure is
107
+ the NEXT time an image has to be resolved. The named
108
+ form only when there is a major to name: a reference
109
+ missing one lands here too, and "asks for PostgreSQL
110
+ undefined" would be worse than the general sentence. */}
111
+ {unmet.length === 1 && typeof unmet[0]?.major === 'number'
112
+ ? `${unmet[0]?.name} asks for PostgreSQL ${unmet[0].major}, which this catalog does not list. Whatever image it is running now, the next time it has to resolve one it will fail.`
113
+ : 'These clusters ask for a major version this catalog does not list. Whatever image they are running now, the next time they have to resolve one they will fail.'}
114
+ </div>
115
+ </div>
116
+ )}
117
+ </div>
118
+ )}
119
+ </Section>
120
+ }
121
+ />
122
+ )
123
+ }
@@ -0,0 +1,152 @@
1
+ import { Database } from 'lucide-react'
2
+ import { CNPGObjectStoreRenderer as BaseCNPGObjectStoreRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/CNPGObjectStoreRenderer'
3
+ import { Section, RelationshipGroup, CNPG_BARMAN_PLUGIN_NAME } 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
+ /**
9
+ * Host wrapper adding the reverse lookup the package renderer cannot do: which
10
+ * CNPG Clusters back up into this store.
11
+ *
12
+ * The Cluster page already links forward to its ObjectStore. Without this the
13
+ * relationship is one-way, so an operator reading a recovery window keyed by
14
+ * server name has no route to the cluster behind it.
15
+ */
16
+ export function CNPGObjectStoreRenderer({
17
+ data,
18
+ onNavigate,
19
+ }: {
20
+ data: any
21
+ onNavigate?: (ref: ResourceRef) => void
22
+ }) {
23
+ const namespace = data?.metadata?.namespace ?? ''
24
+ const storeName = data?.metadata?.name ?? ''
25
+
26
+ // Clusters reference their store by name within their own namespace, so the
27
+ // search is namespace-scoped rather than cluster-wide.
28
+ const clusters = useResources<any>('clusters', namespace, 'postgresql.cnpg.io', {
29
+ enabled: !!storeName && !!namespace,
30
+ })
31
+
32
+ // Servers the store holds data for, from its own status. Deliberately a
33
+ // separate question from the one below.
34
+ const servers: string[] = Object.keys(data?.status?.serverRecoveryWindow ?? {})
35
+
36
+ const users = (clusters.data ?? []).filter((c: any) => {
37
+ const plugins = c?.spec?.plugins
38
+ if (!Array.isArray(plugins)) return false
39
+ return plugins.some(
40
+ (p: any) =>
41
+ p?.name === CNPG_BARMAN_PLUGIN_NAME &&
42
+ p?.enabled !== false &&
43
+ p?.parameters?.barmanObjectName === storeName,
44
+ )
45
+ })
46
+
47
+ // A recovery window is keyed by the archive's server name, which defaults to
48
+ // the cluster's name and is overridden by the plugin's own `serverName`
49
+ // parameter — the ObjectStore rejects the field outright ("use the
50
+ // 'serverName' plugin parameter in the Cluster resource"), so the Cluster is
51
+ // the only place it can come from. Matching keys against cluster names alone
52
+ // reports a cluster that renamed its archive as a stranger.
53
+ const clusterForServer = new Map<string, string>()
54
+ for (const c of users) {
55
+ const plugin = (c?.spec?.plugins ?? []).find(
56
+ (p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.parameters?.barmanObjectName === storeName,
57
+ )
58
+ const server = plugin?.parameters?.serverName || c?.metadata?.name
59
+ if (server && c?.metadata?.name) clusterForServer.set(server, c.metadata.name)
60
+ }
61
+
62
+ // A recovery window is only advancing while WAL archiving works. The Cluster
63
+ // owns that condition, and the ObjectStore's own status cannot see it — so a
64
+ // server whose archiving has stopped reports its last successful backup and
65
+ // reads as green here while the recovery point is frozen. The page whose job is
66
+ // "how far back can I restore" is the last place that should have to be
67
+ // cross-checked against another screen.
68
+ const archivingFailing = new Set<string>()
69
+ for (const c of users) {
70
+ const stalled = (c?.status?.conditions ?? []).some(
71
+ (cond: any) => cond?.type === 'ContinuousArchiving' && cond?.status === 'False',
72
+ )
73
+ if (!stalled) continue
74
+ const plugin = (c?.spec?.plugins ?? []).find(
75
+ (p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.parameters?.barmanObjectName === storeName,
76
+ )
77
+ const server = plugin?.parameters?.serverName || c?.metadata?.name
78
+ if (server) archivingFailing.add(server)
79
+ }
80
+
81
+ // Named in the window, traceable to none of the clusters above — the gap the
82
+ // note explains.
83
+ const unlisted = servers.filter((s) => !clusterForServer.has(s))
84
+
85
+ return (
86
+ <>
87
+ <BaseCNPGObjectStoreRenderer
88
+ data={data}
89
+ onNavigate={onNavigate}
90
+ // Server key -> the Cluster that archives under it. Built from the
91
+ // plugin parameters rather than by name, so a cluster that renamed its
92
+ // archive still resolves. Undefined while the lookup is unresolved.
93
+ clusterForServer={clusters.isLoading || clusters.error ? undefined : clusterForServer}
94
+ archivingFailing={clusters.isLoading || clusters.error ? undefined : archivingFailing}
95
+ />
96
+ <Section title="Used By" icon={Database} defaultExpanded>
97
+ {clusters.isLoading ? (
98
+ <div className="text-sm text-theme-text-tertiary">Looking for clusters…</div>
99
+ ) : users.length === 0 ? (
100
+ // Not the same as "nothing uses it" — the caller may not be able to
101
+ // list Clusters here, and a store with no users is worth noticing.
102
+ clusters.error ? (
103
+ <LookupFailureNote
104
+ errors={[clusters.error]}
105
+ what="which clusters use this store"
106
+ />
107
+ ) : (
108
+ <div className="text-sm text-theme-text-tertiary">
109
+ No cluster in this namespace backs up to this store.
110
+ </div>
111
+ )
112
+ ) : (
113
+ // The house pattern for "these other resources relate to this one":
114
+ // labelled group with a count, ref badges, truncate-then-expand.
115
+ <RelationshipGroup
116
+ label="Archiving here through the plugin"
117
+ refs={users.map((c: any) => ({
118
+ kind: 'Cluster',
119
+ namespace: c.metadata?.namespace ?? '',
120
+ name: c.metadata?.name ?? '',
121
+ group: 'postgresql.cnpg.io',
122
+ }))}
123
+ onNavigate={onNavigate}
124
+ />
125
+ )}
126
+ {/* This lookup can only see `spec.plugins`. A cluster still on the
127
+ deprecated in-tree `spec.backup.barmanObjectStore` names a
128
+ destination path and never references this CR, so it CANNOT appear
129
+ above however much data it has here. Read as blast radius — "who
130
+ breaks if I rotate these credentials" — the list would silently omit
131
+ every legacy-path cluster, which mid-migration is most of a fleet.
132
+ The recovery window is the other half of the answer. */}
133
+ {/* Only once the plugin lookup has actually answered. Derived while the
134
+ list is still loading — or after it failed — every server in the
135
+ window looks like an in-tree cluster, which is a claim about their
136
+ configuration made from having no data. */}
137
+ {!clusters.isLoading && !clusters.error && unlisted.length > 0 && (
138
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
139
+ {/* Two causes reach this note and the screen cannot tell them
140
+ apart, so it names both rather than asserting the one that is
141
+ usually true: a cluster on the older in-tree settings never
142
+ references this record, and a cluster that renamed its archive
143
+ leaves the data it already wrote under the previous name. */}
144
+ {`${unlisted.join(', ')} ${unlisted.length === 1 ? 'has' : 'have'} data here but ${
145
+ unlisted.length === 1 ? 'is' : 'are'
146
+ } not listed above. This finds clusters through the barman-cloud plugin, so an archive written by a cluster on the older in-tree backup settings — or under a server name it has since changed — is not traced back to one.`}
147
+ </div>
148
+ )}
149
+ </Section>
150
+ </>
151
+ )
152
+ }