@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.
- package/package.json +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- 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 +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- 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/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/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- 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 +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- 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
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Workflow } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Section, PropertyList, Property, AlertBanner } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
5
|
+
import { HEALTH_BADGE_COLORS } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
6
|
+
import { formatAge } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
|
|
7
|
+
import { isForbiddenError } from '../../../api/client'
|
|
8
|
+
import { isKyvernoMutateExistingEnabled } from '@skyhook-io/k8s-ui/components/resources/resource-utils-kyverno-modern'
|
|
9
|
+
import { usePolicyQueued } from '../../../api/policy'
|
|
10
|
+
|
|
11
|
+
/** A request sitting this long is not mid-flight. Kyverno retries on every
|
|
12
|
+
* reconcile, so past this point a backlog grows rather than drains. */
|
|
13
|
+
const STALLED_MINUTES = 5
|
|
14
|
+
|
|
15
|
+
/** Enough at once to be worth raising even while it is still moving. */
|
|
16
|
+
const BACKLOG_SIZE = 25
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The whole banner, or null when a queue is doing what a queue does. Headline
|
|
20
|
+
* and body are decided together — split across two places, the body went on
|
|
21
|
+
* diagnosing a stalled controller under a headline that had stopped claiming one.
|
|
22
|
+
*
|
|
23
|
+
* Only the first case has measured that the work stopped. The second says how
|
|
24
|
+
* much is waiting and nothing more: everything in it is younger than the stall
|
|
25
|
+
* threshold, so it is as likely to be a burst draining normally, and diagnosing
|
|
26
|
+
* a controller from that is a claim the evidence argues against.
|
|
27
|
+
*/
|
|
28
|
+
export function queueBanner(
|
|
29
|
+
pending: number,
|
|
30
|
+
stalledMinutes: number,
|
|
31
|
+
oldestAge: string,
|
|
32
|
+
): { title: string; message: string } | null {
|
|
33
|
+
if (stalledMinutes >= STALLED_MINUTES) {
|
|
34
|
+
return {
|
|
35
|
+
title: `Queued work has not moved for ${oldestAge}`,
|
|
36
|
+
message:
|
|
37
|
+
'Requests build up when the background controller cannot keep up or cannot reach what it needs. They are retried on every reconcile, so a backlog grows rather than drains.',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (pending >= BACKLOG_SIZE) {
|
|
41
|
+
return {
|
|
42
|
+
title: `${pending} requests are queued`,
|
|
43
|
+
message: `Nothing here has been waiting longer than ${STALLED_MINUTES} minutes, so this may be a burst still draining. Worth watching: if the count holds or the oldest keeps ageing, the controller is not keeping up.`,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether this policy can queue background work at all.
|
|
51
|
+
*
|
|
52
|
+
* Decides whether a denial is worth mentioning. A validate-only policy never
|
|
53
|
+
* queues anything, so "you can't see the queue" there is noise on the majority
|
|
54
|
+
* of policy pages. A policy that generates, or mutates resources that already
|
|
55
|
+
* exist, does queue — and for those a denial hides a backlog that looks exactly
|
|
56
|
+
* like a healthy policy with nothing pending.
|
|
57
|
+
*/
|
|
58
|
+
export function policyQueuesWork(data: any): boolean {
|
|
59
|
+
if (data?.kind === 'GeneratingPolicy') return true
|
|
60
|
+
if (isKyvernoMutateExistingEnabled(data)) return true
|
|
61
|
+
return (data?.spec?.rules ?? []).some(
|
|
62
|
+
(r: any) => !!r?.generate || Array.isArray(r?.mutate?.targets),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a queued request belongs to this policy.
|
|
68
|
+
*
|
|
69
|
+
* Exactly one form matches, never both. A namespaced Policy is recorded as
|
|
70
|
+
* `namespace/name` and a cluster-scoped one bare, and Kyverno permits the two
|
|
71
|
+
* to share a name — so accepting the bare form as a fallback hands a namespaced
|
|
72
|
+
* policy the backlog of a ClusterPolicy it has nothing to do with. The coverage
|
|
73
|
+
* lookup refuses the same fallback for the same reason.
|
|
74
|
+
*/
|
|
75
|
+
export function requestBelongsTo(request: any, name: string, namespace: string): boolean {
|
|
76
|
+
const policy = request?.spec?.policy
|
|
77
|
+
if (!policy || !name) return false
|
|
78
|
+
return policy === (namespace ? `${namespace}/${name}` : name)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What this policy has queued and not finished.
|
|
83
|
+
*
|
|
84
|
+
* A generate or mutate-existing rule does its work through queued requests, and
|
|
85
|
+
* the way that fails in practice is a pile-up rather than one bad request:
|
|
86
|
+
* upstream reports describe thousands stuck in Pending, never cleaned up, taken
|
|
87
|
+
* up again on every reconcile. That is a count, and a count belongs next to the
|
|
88
|
+
* policy — the requests have their own page, but nobody watches it.
|
|
89
|
+
*
|
|
90
|
+
* Silent when the policy has queued nothing, so it never adds an empty section
|
|
91
|
+
* to the majority of policies that only validate.
|
|
92
|
+
*/
|
|
93
|
+
export function KyvernoPolicyQueued({ data }: { data: any }) {
|
|
94
|
+
const name = data?.metadata?.name ?? ''
|
|
95
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
96
|
+
|
|
97
|
+
// Server-side, because the answer's scope is not the subject's: Kyverno keeps
|
|
98
|
+
// these in its own namespace, and the generic resource list would have
|
|
99
|
+
// answered for whatever namespaces the reader happens to be viewing.
|
|
100
|
+
const { data: queued, error } = usePolicyQueued(name, namespace, !!name)
|
|
101
|
+
|
|
102
|
+
const total = queued?.requests ?? 0
|
|
103
|
+
if (total === 0) {
|
|
104
|
+
// An empty list and an unreadable one are not the same answer. A denial is
|
|
105
|
+
// cluster-static, so disclosing it on every policy page would be noise on the
|
|
106
|
+
// majority that only validate and never queue anything — but staying silent
|
|
107
|
+
// about it on a policy that DOES queue hides a backlog behind a page that
|
|
108
|
+
// looks like a healthy one with nothing pending. So it is disclosed exactly
|
|
109
|
+
// where it can cost something.
|
|
110
|
+
if (!error) return null
|
|
111
|
+
if (isForbiddenError(error) && !policyQueuesWork(data)) return null
|
|
112
|
+
return (
|
|
113
|
+
<Section title="Queued Work" icon={Workflow}>
|
|
114
|
+
<LookupFailureNote errors={[error]} what="what this policy has queued" />
|
|
115
|
+
</Section>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const byState = queued?.byState ?? {}
|
|
120
|
+
const pending = byState['Pending'] ?? 0
|
|
121
|
+
const failed = byState['Failed'] ?? 0
|
|
122
|
+
const messages = queued?.messages ?? []
|
|
123
|
+
const oldestPending = queued?.oldestPending
|
|
124
|
+
|
|
125
|
+
const stalledMinutes = oldestPending
|
|
126
|
+
? Math.floor((Date.now() - new Date(oldestPending).getTime()) / 60000)
|
|
127
|
+
: 0
|
|
128
|
+
const banner = queueBanner(pending, stalledMinutes, oldestPending ? formatAge(oldestPending) : '')
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{banner && (
|
|
133
|
+
<AlertBanner variant="warning" title={banner.title} message={banner.message} />
|
|
134
|
+
)}
|
|
135
|
+
<Section title="Queued Work" icon={Workflow} defaultExpanded={pending > 0 || failed > 0}>
|
|
136
|
+
<PropertyList>
|
|
137
|
+
<Property label="Requests" value={String(total)} />
|
|
138
|
+
{Object.entries(byState).map(([state, n]) => (
|
|
139
|
+
<Property
|
|
140
|
+
key={state}
|
|
141
|
+
label={state}
|
|
142
|
+
value={
|
|
143
|
+
<span
|
|
144
|
+
className={clsx(
|
|
145
|
+
'badge',
|
|
146
|
+
HEALTH_BADGE_COLORS[
|
|
147
|
+
(state === 'Failed'
|
|
148
|
+
? 'unhealthy'
|
|
149
|
+
: state === 'Pending'
|
|
150
|
+
? 'degraded'
|
|
151
|
+
: state === 'Completed'
|
|
152
|
+
? 'healthy'
|
|
153
|
+
: 'unknown') as keyof typeof HEALTH_BADGE_COLORS
|
|
154
|
+
],
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
{n}
|
|
158
|
+
</span>
|
|
159
|
+
}
|
|
160
|
+
/>
|
|
161
|
+
))}
|
|
162
|
+
{oldestPending && (
|
|
163
|
+
<Property label="Oldest Pending" value={`${formatAge(oldestPending)} ago`} />
|
|
164
|
+
)}
|
|
165
|
+
</PropertyList>
|
|
166
|
+
{messages.length > 0 && (
|
|
167
|
+
<div className="mt-2 pt-2 border-t border-theme-border space-y-1">
|
|
168
|
+
{/* Kyverno writes why it could not complete a request into
|
|
169
|
+
status.message, and it is the only diagnosis this object
|
|
170
|
+
carries. A count without it says something is wrong and leaves
|
|
171
|
+
you to go and find out what. */}
|
|
172
|
+
{messages.map((m, i) => (
|
|
173
|
+
<div key={i} className="text-xs text-warning-text">{m}</div>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
178
|
+
These are deleted seconds after they complete, so this counts what is in flight right now
|
|
179
|
+
rather than everything this policy has ever done.
|
|
180
|
+
</div>
|
|
181
|
+
</Section>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -1 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
import { RolloutRenderer as BaseRolloutRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RolloutRenderer'
|
|
2
|
+
import { useRolloutAction, useRolloutCapabilities, type RolloutAction } from '../../../api/client'
|
|
3
|
+
|
|
4
|
+
interface RolloutRendererProps {
|
|
5
|
+
data: any
|
|
6
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function RolloutRenderer({ data, onNavigate }: RolloutRendererProps) {
|
|
10
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
11
|
+
const name = data?.metadata?.name ?? ''
|
|
12
|
+
const { data: capabilities } = useRolloutCapabilities(namespace, name)
|
|
13
|
+
const action = useRolloutAction()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<BaseRolloutRenderer
|
|
17
|
+
data={data}
|
|
18
|
+
onNavigate={onNavigate}
|
|
19
|
+
capabilities={capabilities}
|
|
20
|
+
onAction={(next: RolloutAction) => action.mutate({ action: next, namespace, name })}
|
|
21
|
+
pendingAction={action.isPending ? action.variables?.action ?? null : null}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -1 +1,44 @@
|
|
|
1
|
-
|
|
1
|
+
import { VeleroBSLRenderer as BaseVeleroBSLRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBSLRenderer'
|
|
2
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
3
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useVeleroStoredBackups } from '../../../api/policy'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Host wrapper adding the reverse lookup the package renderer cannot do: which
|
|
8
|
+
* Backups this storage location holds.
|
|
9
|
+
*
|
|
10
|
+
* Without it the page states a phase and stops. "Unavailable" is a fact about a
|
|
11
|
+
* bucket; what an operator came to find out is what it costs them, and that is
|
|
12
|
+
* the list of backups they cannot restore from until it recovers — every one of
|
|
13
|
+
* which Velero still reports as Completed.
|
|
14
|
+
*/
|
|
15
|
+
export function VeleroBSLRenderer({
|
|
16
|
+
data,
|
|
17
|
+
onNavigate,
|
|
18
|
+
}: {
|
|
19
|
+
data: any
|
|
20
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
21
|
+
}) {
|
|
22
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
23
|
+
const name = data?.metadata?.name ?? ''
|
|
24
|
+
const stored = useVeleroStoredBackups(namespace, name, !!namespace && !!name)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<BaseVeleroBSLRenderer
|
|
28
|
+
data={data}
|
|
29
|
+
// Undefined while unresolved: an empty list would say this location holds
|
|
30
|
+
// nothing, which is a different answer from not having looked yet.
|
|
31
|
+
storedBackups={stored.isLoading || stored.error ? undefined : (stored.data?.backups ?? [])}
|
|
32
|
+
storedTotal={stored.data?.stored}
|
|
33
|
+
restorableTotal={stored.data?.restorable}
|
|
34
|
+
expiredTotal={stored.data?.expired}
|
|
35
|
+
listTruncated={stored.data?.truncated}
|
|
36
|
+
lookupNote={
|
|
37
|
+
stored.error ? (
|
|
38
|
+
<LookupFailureNote errors={[stored.error]} what="which backups are stored here" />
|
|
39
|
+
) : undefined
|
|
40
|
+
}
|
|
41
|
+
onNavigate={onNavigate}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -1 +1,75 @@
|
|
|
1
|
-
|
|
1
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
2
|
+
import { VeleroBackupRenderer as BaseVeleroBackupRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroBackupRenderer'
|
|
3
|
+
import { resolveBackupStorageLocation } from '@skyhook-io/k8s-ui/components/resources/resource-utils-velero'
|
|
4
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useResources } from '../../../api/client'
|
|
6
|
+
import { useVeleroRunMessages } from '../../../api/policy'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host wrapper adding the two things a Backup cannot answer from its own status:
|
|
10
|
+
* whether the storage location holding it is reachable, and what the errors and
|
|
11
|
+
* warnings it counted actually say.
|
|
12
|
+
*
|
|
13
|
+
* The backup's own status says Completed and goes on saying it after the bucket
|
|
14
|
+
* behind it goes Unavailable. This is the page someone opens to decide whether
|
|
15
|
+
* they can restore to this point, so the answer belongs here and not one screen
|
|
16
|
+
* away.
|
|
17
|
+
*
|
|
18
|
+
* Namespace is explicit — storage locations live alongside the backups that name
|
|
19
|
+
* them, in Velero's own namespace. Omitting it would inherit the reader's
|
|
20
|
+
* namespace view filter, which is a browsing preference and not the scope of
|
|
21
|
+
* this question.
|
|
22
|
+
*/
|
|
23
|
+
export function VeleroBackupRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
|
|
24
|
+
const messages = useRunMessages('backups', data)
|
|
25
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
26
|
+
|
|
27
|
+
const locations = useResources<any>('backupstoragelocations', namespace, 'velero.io', {
|
|
28
|
+
enabled: !!namespace,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Everything below is gated on the lookup having answered. A location we have
|
|
32
|
+
// not read is not a healthy one, and neither is a name we have not resolved:
|
|
33
|
+
// an unset spec.storageLocation means whichever location carries spec.default,
|
|
34
|
+
// so resolving it against a list that has not loaded produces the literal
|
|
35
|
+
// "default", which is wrong on any install that renamed it — and wrong
|
|
36
|
+
// permanently when the list errors rather than for a moment.
|
|
37
|
+
const answered = !locations.isLoading && !locations.error && locations.data !== undefined
|
|
38
|
+
const resolved = answered ? resolveBackupStorageLocation(data, locations.data) : undefined
|
|
39
|
+
|
|
40
|
+
const match = answered
|
|
41
|
+
? (locations.data ?? []).find((l: any) => l?.metadata?.name === resolved)
|
|
42
|
+
: undefined
|
|
43
|
+
const phase = match?.status?.phase
|
|
44
|
+
|
|
45
|
+
// The location the backup names is gone. Velero restores from the location
|
|
46
|
+
// recorded on the backup, so this is not restorable — and it is invisible
|
|
47
|
+
// otherwise, because a location that does not exist has no phase to report.
|
|
48
|
+
const missing = answered && !!resolved && match === undefined
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<BaseVeleroBackupRenderer
|
|
52
|
+
data={data}
|
|
53
|
+
storageLocationPhase={phase}
|
|
54
|
+
storageLocationName={resolved}
|
|
55
|
+
storageLocationMissing={missing}
|
|
56
|
+
messages={messages}
|
|
57
|
+
onNavigate={onNavigate}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The messages behind the counts. Nothing is fetched until the operator asks:
|
|
63
|
+
// reading them makes Velero create a DownloadRequest and pulls an object out of
|
|
64
|
+
// storage, which is not work to do on every drawer open.
|
|
65
|
+
function useRunMessages(kind: 'backups' | 'restores', data: any) {
|
|
66
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
67
|
+
const name = data?.metadata?.name ?? ''
|
|
68
|
+
const fetcher = useVeleroRunMessages(kind, namespace, name)
|
|
69
|
+
return {
|
|
70
|
+
messages: fetcher.data,
|
|
71
|
+
loading: fetcher.isPending,
|
|
72
|
+
lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
|
|
73
|
+
onFetch: namespace && name ? () => fetcher.mutate() : undefined,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
import { LookupFailureNote } from '@skyhook-io/k8s-ui/components/resources/renderers/LookupFailureNote'
|
|
2
|
+
import { VeleroRestoreRenderer as BaseVeleroRestoreRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/VeleroRestoreRenderer'
|
|
3
|
+
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useVeleroRunMessages } from '../../../api/policy'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Host wrapper adding the messages behind the run's error and warning counts.
|
|
8
|
+
*
|
|
9
|
+
* The counts are on the Restore; the text is not. It lives in a results file in
|
|
10
|
+
* object storage that only Velero's controller can hand out a link to — so the
|
|
11
|
+
* page showed a number an operator could worry about but not act on. A stuck
|
|
12
|
+
* restore is the more urgent of the two kinds, because someone is waiting on
|
|
13
|
+
* their data.
|
|
14
|
+
*
|
|
15
|
+
* Fetched on click, not on open: it creates a DownloadRequest and pulls an
|
|
16
|
+
* object out of storage. Same bargain as the network trace's probes.
|
|
17
|
+
*/
|
|
18
|
+
export function VeleroRestoreRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
|
|
19
|
+
const namespace = data?.metadata?.namespace ?? ''
|
|
20
|
+
const name = data?.metadata?.name ?? ''
|
|
21
|
+
const fetcher = useVeleroRunMessages('restores', namespace, name)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<BaseVeleroRestoreRenderer
|
|
25
|
+
data={data}
|
|
26
|
+
onNavigate={onNavigate}
|
|
27
|
+
messages={{
|
|
28
|
+
messages: fetcher.data,
|
|
29
|
+
loading: fetcher.isPending,
|
|
30
|
+
lookupNote: fetcher.error ? <LookupFailureNote errors={[fetcher.error]} what="the messages behind these counts" /> : undefined,
|
|
31
|
+
onFetch: namespace && name ? () => fetcher.mutate() : undefined,
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -11,6 +11,7 @@ export { CronWorkflowRenderer } from './CronWorkflowRenderer'
|
|
|
11
11
|
export { HPARenderer } from './HPARenderer'
|
|
12
12
|
export { NodeRenderer } from './NodeRenderer'
|
|
13
13
|
export { PVCRenderer } from './PVCRenderer'
|
|
14
|
+
export { KyvernoPolicyCoverage } from './KyvernoPolicyCoverage'
|
|
14
15
|
export { RolloutRenderer } from './RolloutRenderer'
|
|
15
16
|
export { CertificateRenderer } from './CertificateRenderer'
|
|
16
17
|
export { WorkflowRenderer } from './WorkflowRenderer'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
-
import { AlertTriangle,
|
|
3
|
+
import { AlertTriangle, Coins, ExternalLink, Gauge, Loader2, RefreshCw } from 'lucide-react'
|
|
4
4
|
import {
|
|
5
5
|
Collapse,
|
|
6
6
|
CollapseChevron,
|
|
@@ -194,7 +194,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
|
|
|
194
194
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
195
195
|
<div className="mx-auto flex w-full max-w-[1920px] flex-col gap-4 px-6 py-6">
|
|
196
196
|
<PageHeader
|
|
197
|
-
icon={
|
|
197
|
+
icon={Coins}
|
|
198
198
|
title="Cost Insights"
|
|
199
199
|
description="Understand current allocation and find CPU and memory requests worth tuning."
|
|
200
200
|
/>
|