@skyhook-io/radar-app 1.10.0 → 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 (38) 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 +166 -2
  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/ResourcesView.tsx +15 -3
  12. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  13. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  14. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  16. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  17. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  18. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  19. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  20. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  21. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  22. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  23. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  24. package/src/components/resources/renderers/index.ts +1 -0
  25. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  26. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  27. package/src/components/traffic/TrafficGraph.tsx +150 -58
  28. package/src/components/traffic/TrafficView.tsx +168 -52
  29. package/src/components/traffic/TrafficWizard.tsx +13 -1
  30. package/src/components/traffic/trafficFilters.test.ts +103 -0
  31. package/src/components/traffic/trafficFilters.ts +117 -0
  32. package/src/components/ui/DiagnosticsOverlay.test.ts +75 -0
  33. package/src/components/ui/DiagnosticsOverlay.tsx +47 -2
  34. package/src/components/workload/WorkloadView.tsx +55 -7
  35. package/src/utils/navigation.ts +44 -2
  36. package/src/utils/network-policy-navigation.test.ts +68 -0
  37. package/src/utils/topology-selection.test.ts +40 -0
  38. package/src/utils/topology-selection.ts +39 -0
@@ -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
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { policyQueuesWork, queueBanner, requestBelongsTo } from './KyvernoPolicyQueued'
3
+
4
+ /**
5
+ * Kyverno permits a namespaced Policy and a ClusterPolicy to share a name, and
6
+ * records the first qualified and the second bare. Accepting the bare form as a
7
+ * fallback for a namespaced policy shows it someone else's backlog — the same
8
+ * collision the coverage lookup refuses, reintroduced here once already.
9
+ */
10
+ describe('requestBelongsTo', () => {
11
+ const req = (policy: string) => ({ spec: { policy } })
12
+
13
+ it('matches a cluster-scoped policy on the bare name only', () => {
14
+ expect(requestBelongsTo(req('require-labels'), 'require-labels', '')).toBe(true)
15
+ expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', '')).toBe(false)
16
+ })
17
+
18
+ it('matches a namespaced policy on the qualified name only', () => {
19
+ expect(requestBelongsTo(req('team-a/require-labels'), 'require-labels', 'team-a')).toBe(true)
20
+ // The bug: this is a ClusterPolicy's request and must not appear here.
21
+ expect(requestBelongsTo(req('require-labels'), 'require-labels', 'team-a')).toBe(false)
22
+ })
23
+
24
+ it('does not match another namespace', () => {
25
+ expect(requestBelongsTo(req('team-b/require-labels'), 'require-labels', 'team-a')).toBe(false)
26
+ })
27
+
28
+ it('matches nothing when either side is missing', () => {
29
+ expect(requestBelongsTo({}, 'require-labels', '')).toBe(false)
30
+ expect(requestBelongsTo(req('require-labels'), '', '')).toBe(false)
31
+ })
32
+ })
33
+
34
+ /**
35
+ * The banner is the only thing on this page that makes a claim rather than a
36
+ * count, so the claim has to be the one the numbers support.
37
+ */
38
+ describe('queueBanner', () => {
39
+ it('says work stopped only when something has actually sat still', () => {
40
+ const b = queueBanner(3, 12, '12m')
41
+ expect(b?.title).toBe('Queued work has not moved for 12m')
42
+ expect(b?.message).toContain('cannot keep up')
43
+ })
44
+
45
+ // The bug: this branch fires when NOTHING is older than the stall threshold,
46
+ // which is the case most likely to be a burst draining normally. The body has
47
+ // to agree with the headline — diagnosing a stalled controller underneath a
48
+ // headline that only reports a size puts the claim straight back.
49
+ it('does not tell you a moving backlog is not being processed', () => {
50
+ const b = queueBanner(30, 1, '1m')
51
+ expect(b?.title).toBe('30 requests are queued')
52
+ expect(b?.title).not.toContain('not being processed')
53
+ expect(b?.message).toContain('may be a burst still draining')
54
+ expect(b?.message).not.toContain('cannot keep up')
55
+ expect(b?.message).not.toContain('grows rather than drains')
56
+ })
57
+
58
+ it('stays quiet for a queue doing what a queue does', () => {
59
+ expect(queueBanner(3, 1, '1m')).toBeNull()
60
+ expect(queueBanner(0, 0, '')).toBeNull()
61
+ })
62
+
63
+ it('prefers the measured stall over the size when both apply', () => {
64
+ expect(queueBanner(40, 30, '30m')?.title).toContain('has not moved')
65
+ })
66
+ })
67
+
68
+ /**
69
+ * A denial is only worth disclosing where it can cost something. Silence on a
70
+ * policy that queues nothing keeps the page clean; silence on one that DOES
71
+ * queue hides a backlog behind a page that looks healthy.
72
+ */
73
+ describe('policyQueuesWork', () => {
74
+ it('is true for a policy that generates resources', () => {
75
+ expect(policyQueuesWork({ spec: { rules: [{ name: 'g', generate: {} }] } })).toBe(true)
76
+ expect(policyQueuesWork({ kind: 'GeneratingPolicy', spec: {} })).toBe(true)
77
+ })
78
+
79
+ it('is true for a policy that mutates resources which already exist', () => {
80
+ expect(policyQueuesWork({ spec: { rules: [{ mutate: { targets: [{ kind: 'Pod' }] } }] } })).toBe(true)
81
+ expect(policyQueuesWork({ spec: { evaluation: { mutateExisting: { enabled: true } } } })).toBe(true)
82
+ })
83
+
84
+ // The common case, and the reason a blanket note would be noise.
85
+ it('is false for a validate-only policy', () => {
86
+ expect(policyQueuesWork({ spec: { rules: [{ name: 'v', validate: {} }] } })).toBe(false)
87
+ expect(policyQueuesWork({ kind: 'ValidatingPolicy', spec: {} })).toBe(false)
88
+ })
89
+
90
+ // A mutate rule with no targets runs at admission only — nothing is queued.
91
+ it('is false for admission-time mutation', () => {
92
+ expect(policyQueuesWork({ spec: { rules: [{ mutate: { patchStrategicMerge: {} } }] } })).toBe(false)
93
+ })
94
+
95
+ it('handles a policy with no spec at all', () => {
96
+ expect(policyQueuesWork({})).toBe(false)
97
+ expect(policyQueuesWork(undefined)).toBe(false)
98
+ })
99
+ })