@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
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react'
2
+ import { useLocation } from 'react-router-dom'
3
+ import { PolicyCoverageSection } from '@skyhook-io/k8s-ui/components/resources/renderers/PolicyCoverageSection'
4
+ import type { PolicyCoverageSubject, ResourceRef } from '@skyhook-io/k8s-ui'
5
+ import { usePolicyCoverage } from '../../../api/policy'
6
+
7
+ /** Mirrors maxPolicyCoverageSubjectsHard in internal/server/policy_handlers.go —
8
+ * asking for more than the server will send just returns the same list. */
9
+ const COVERAGE_MAX_SUBJECTS = 5000
10
+
11
+ /**
12
+ * Host wrapper for the policy coverage section — the inverse lookup that answers
13
+ * "which resources does this policy pass and fail".
14
+ *
15
+ * The package renderers take it as a slot rather than fetching it themselves, so
16
+ * a library consumer that does not wire this endpoint keeps the original policy
17
+ * drawer and nothing breaks.
18
+ */
19
+ export function KyvernoPolicyCoverage({
20
+ data,
21
+ onNavigate,
22
+ }: {
23
+ data: any
24
+ onNavigate?: (ref: ResourceRef) => void
25
+ }) {
26
+ const name = data?.metadata?.name ?? ''
27
+ // A namespaced Kyverno Policy reports as "namespace/name"; the server tries
28
+ // both shapes, so the namespace is passed through whenever the policy has one.
29
+ const namespace = data?.metadata?.namespace ?? ''
30
+ // The server bounds each rule's subject list so an ordinary drawer open stays
31
+ // small. Asking for the rest raises that bound once, up to the server's own
32
+ // ceiling — past which the response says what it could not send.
33
+ const [limit, setLimit] = useState<number | undefined>(undefined)
34
+
35
+ // The header's namespace filter is applied SERVER-side from session state, so
36
+ // the same URL returns a different body once it changes and nothing in the
37
+ // request distinguishes the two. Without it in the cache key, a policy opened
38
+ // under "All namespaces" keeps serving that body after the filter narrows —
39
+ // the view then shows other people's namespaces under your scope, and which
40
+ // behaviour you get depends on where you navigated from.
41
+ const viewFilter = useLocation().search
42
+ const query = usePolicyCoverage(name, namespace || undefined, !!name, limit, viewFilter)
43
+
44
+ return (
45
+ <PolicyCoverageSection
46
+ resource={data}
47
+ data={query.data ?? null}
48
+ loading={query.isLoading}
49
+ error={query.error as Error | null}
50
+ onLoadMore={limit ? undefined : () => setLimit(COVERAGE_MAX_SUBJECTS)}
51
+ loadingMore={query.isFetching}
52
+ onSelectSubject={
53
+ onNavigate
54
+ ? (subject: PolicyCoverageSubject) =>
55
+ onNavigate({
56
+ kind: subject.kind,
57
+ namespace: subject.namespace ?? '',
58
+ name: subject.name,
59
+ group: subject.group,
60
+ })
61
+ : undefined
62
+ }
63
+ />
64
+ )
65
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+
4
+ // The denial is the state under test, so the fetch is stubbed rather than run.
5
+ const forbidden = Object.assign(new Error('forbidden'), { status: 403 })
6
+ let queuedResult: { data?: unknown; error?: unknown } = {}
7
+
8
+ vi.mock('../../../api/policy', () => ({
9
+ usePolicyQueued: () => queuedResult,
10
+ }))
11
+ vi.mock('../../../api/client', () => ({
12
+ isForbiddenError: (e: any) => e?.status === 403,
13
+ }))
14
+
15
+ const { KyvernoPolicyQueued } = await import('./KyvernoPolicyQueued')
16
+
17
+ const generating = {
18
+ kind: 'ClusterPolicy',
19
+ metadata: { name: 'gen-companion' },
20
+ spec: { rules: [{ name: 'g', generate: {} }] },
21
+ }
22
+ const validating = {
23
+ kind: 'ClusterPolicy',
24
+ metadata: { name: 'require-labels' },
25
+ spec: { rules: [{ name: 'v', validate: {} }] },
26
+ }
27
+
28
+ /**
29
+ * A denial here is cluster-static, so disclosing it everywhere would be noise on
30
+ * the majority of policies that only validate. Staying silent about it on a
31
+ * policy that DOES queue is worse: a stuck backlog then looks exactly like a
32
+ * healthy policy with nothing pending, on the page someone checks to find out.
33
+ */
34
+ describe('KyvernoPolicyQueued — a denial you cannot see', () => {
35
+ it('says it cannot check when the policy actually queues work', () => {
36
+ queuedResult = { error: forbidden }
37
+ const html = renderToString(<KyvernoPolicyQueued data={generating} />)
38
+ expect(html).toContain('permission')
39
+ expect(html).toContain('Queued Work')
40
+ })
41
+
42
+ it('stays silent for a policy that never queues anything', () => {
43
+ queuedResult = { error: forbidden }
44
+ expect(renderToString(<KyvernoPolicyQueued data={validating} />)).toBe('')
45
+ })
46
+
47
+ it('stays silent when there is genuinely nothing queued', () => {
48
+ queuedResult = { data: { requests: 0 } }
49
+ expect(renderToString(<KyvernoPolicyQueued data={generating} />)).toBe('')
50
+ })
51
+
52
+ // A fault is not a denial: it is neither permanent nor beyond acting on, so it
53
+ // is loud regardless of what the policy does.
54
+ it('reports a genuine fault on any policy', () => {
55
+ queuedResult = { error: new Error('could not read queued work') }
56
+ const html = renderToString(<KyvernoPolicyQueued data={validating} />)
57
+ expect(html).toContain('could not read queued work')
58
+ })
59
+ })