@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.
- package/package.json +6 -6
- package/src/App.tsx +2 -6
- package/src/api/client.ts +138 -6
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +187 -0
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +15 -5
- package/src/components/resources/CompositeRenderer.tsx +60 -3
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/PodRenderer.tsx +8 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +9 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +38 -21
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +155 -63
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
- package/src/components/workload/WorkloadView.tsx +55 -7
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- 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
|
+
})
|